Compare commits

..
Author SHA1 Message Date
ephraimduncan c07eeba178 refactor: unify settings layout, navigation and copy
Consolidate the personal, team and organisation settings surfaces behind a single
system so every page shares one width, one navigation implementation and one copy
standard.

- add shared SettingsNav (aria-current active state, exact matching for the team
  General route, non-interactive section labels) and delete the four divergent nav
  implementations it replaces
- constrain the settings content column to max-w-3xl in all three layouts and strip
  the per-page max-w-2xl/max-w-xl wrappers so forms and tables render identically
- rewrite SettingsHeader subtitles to remove "Here you can" / "On this page" filler,
  normalize title casing and punctuation, and translate the hardcoded team general
  header
- align the billing header with SettingsHeader typography and drop redundant fieldset
  width constraints in the document/email preference forms
- fix the wrong team group success toast, team email meta title, misnamed page
  exports, untranslated Manage label and Create Team casing mismatch
- update manage-organisation e2e assertions for the corrected copy
2026-07-07 10:28:19 +00:00
228 changed files with 4285 additions and 13865 deletions
@@ -1,146 +0,0 @@
---
date: 2026-05-28
title: Rejected Expired Recipient Filters
---
## Context
Customers need to find (a) envelopes/documents in the `REJECTED` state and (b) envelopes
with at least one recipient whose signing link has **expired**. Today the UI only exposes
`INBOX / PENDING / COMPLETED / DRAFT / ALL` tabs, and the public API has no way to filter by
expired recipient links — forcing a fetch-all-`PENDING`-then-inspect-each-recipient workaround.
Two key facts from exploration shaped this plan:
- **`REJECTED` is already fully wired in the backend** — the where-clause (`find-documents.ts`),
stats counts (`get-stats.ts`), tRPC response schema, `ExtendedDocumentStatus` enum, and the
`FRIENDLY_STATUS_MAP` display all handle it. It is simply absent from the UI tab array.
- **Renewing expired links already works.** `resendDocument` refreshes `expiresAt` and clears
`expirationNotifiedAt` for unsigned, non-CC recipients (`resend-document.ts:98-121`), exposed
publicly via `POST /api/v2/document/redistribute` and `/api/v2/envelope/redistribute` and via the
resend/redistribute UI dialogs. No new renew mechanism is needed — only documentation/wording.
Expiration is a per-recipient condition (not an envelope status). The approved design models it
in the UI as an `EXPIRED` **pseudo-status tab** (reusing the existing tab machinery, mirroring how
`REJECTED` works) and in the public API as an orthogonal boolean `hasExpiredRecipients`. Both share
one EXISTS predicate.
Definition of "expired recipient" (matches `isRecipientExpired`, `packages/lib/utils/recipients.ts:118`):
a `Recipient` with `expiresAt IS NOT NULL AND expiresAt <= now() AND signingStatus = NOT_SIGNED AND role != CC`.
## Approach
### A. Shared EXISTS predicate (reused 4x, justified)
Add a local `hasExpiredRecipient(eb)` helper — modeled on the existing per-file `recipientExists` /
`senderEmailIs` helpers — to `find-documents.ts`, `get-stats.ts`, and `find-envelopes.ts`. It is the
single source of truth for the expired condition above (using `new Date()` for `now`, matching the
`period` filter's `.toJSDate()` style).
### B. REJECTED tab (UI only — backend already done)
- `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx`: add
`ExtendedDocumentStatus.REJECTED` to the tab array (lines 149-155). Count badge, highlight, and
`?status=REJECTED` filtering already work via existing machinery.
### C. EXPIRED pseudo-status (UI + internal stats)
1. `packages/prisma/types/extended-document-status.ts`: add `EXPIRED: 'EXPIRED'`. Internal-only —
the public `DocumentStatus` enum is unaffected. This intentionally surfaces TS errors at the three
exhaustive/`Record<ExtendedDocumentStatus>` sites below, forcing them to be handled.
2. `packages/lib/server-only/document/find-documents.ts`:
- Add `.with(ExtendedDocumentStatus.EXPIRED, ...)` to **both** `applyPersonalFilters` and
`applyTeamFilters`, mirroring the `COMPLETED` branch's access control (deleted + visibility +
owner/recipient access) with `hasExpiredRecipient(eb)` AND-ed in. Do **not** constrain
`Envelope.status` — the EXISTS already restricts to unsigned recipients.
3. `packages/lib/server-only/document/get-stats.ts`:
- Add an `expiredQuery` mirroring `pendingQuery`'s access control + `hasExpiredRecipient(eb)`.
- Add it to the `Promise.all`, add `[ExtendedDocumentStatus.EXPIRED]: expired` to the `stats`
record. **Do not** add `expired` to the `all` sum (it overlaps `PENDING`).
4. `packages/trpc/server/document-router/find-documents-internal.types.ts`: add
`[ExtendedDocumentStatus.EXPIRED]: z.number()` to the `stats` response object. (`status` already
accepts the extended enum via `z.nativeEnum(ExtendedDocumentStatus)`.)
5. `apps/remix/app/components/general/document/document-status.tsx`: add an `EXPIRED` entry to
`FRIENDLY_STATUS_MAP``label: msg` Expired, an icon (e.g. lucide `TimerOff`, matching the
`/sign/$token/expired` page), and a distinct color (e.g. `text-orange-500`) to differentiate from
`REJECTED` (red).
6. `documents._index.tsx`: add `[ExtendedDocumentStatus.EXPIRED]: 0` to the `stats` `useState`
initializer and `ExtendedDocumentStatus.EXPIRED` to the tab array. Final order:
`INBOX, PENDING, COMPLETED, DRAFT, REJECTED, EXPIRED, ALL`.
7. (Optional, recommended) `apps/remix/app/components/tables/documents-table-empty-state.tsx`: add
tailored `EXPIRED` and `REJECTED` empty-state copy (currently both fall through to `.otherwise()`).
### D. Public API boolean `hasExpiredRecipients` (document + envelope, v2)
1. `packages/lib/server-only/document/find-documents.ts`: add `hasExpiredRecipients?: boolean` to
`FindDocumentsOptions`; when true, apply `.where((eb) => hasExpiredRecipient(eb))` inside
`buildBaseQuery` (orthogonal/additive to any `status`).
2. `packages/trpc/server/document-router/find-documents.types.ts`: add a query-safe boolean
`hasExpiredRecipients` to `ZFindDocumentsRequestSchema` with a `.describe(...)`. Mirror the
existing boolean-query-param handling in `find-document-audit-logs.types.ts`
(`filterForRecentActivity`) — avoid raw `z.coerce.boolean()` (the "false" -> true footgun); use a
string transform if needed. Pass it through in `find-documents.ts` (public handler).
3. `packages/lib/server-only/envelope/find-envelopes.ts`: add `hasExpiredRecipients?: boolean` to
`FindEnvelopesOptions` + the `hasExpiredRecipient(eb)` helper + the additive `.where`.
4. `packages/trpc/server/envelope-router/find-envelopes.types.ts`: add the same param to
`ZFindEnvelopesRequestSchema`; pass it through in the envelope-router find handler.
The param auto-appears in the generated `/api/v2/openapi.json`.
Note: REST v1 `GET /api/v1/documents` is deprecated and lacks status filtering — left unchanged.
`REJECTED` is already a valid public `status` value (`DocumentStatus.REJECTED`), so no API change is
needed for rejected filtering.
### E. Renew expired links — documentation only
No functional change. Document that resending renews expired links:
- Update the `.description` in `packages/trpc/server/document-router/redistribute-document.types.ts`
and `packages/trpc/server/envelope-router/redistribute-envelope.types.ts` to state that
redistributing refreshes the signing-link expiration for unsigned recipients.
- Optionally adjust resend/redistribute dialog copy
(`apps/remix/app/components/dialogs/document-resend-dialog.tsx`,
`envelope-redistribute-dialog.tsx`) to mention it renews expired links.
## Files To Modify (summary)
| Area | File |
|------|------|
| Enum | `packages/prisma/types/extended-document-status.ts` |
| Where-clause + API option | `packages/lib/server-only/document/find-documents.ts` |
| Stats counts | `packages/lib/server-only/document/get-stats.ts` |
| Envelope find (API) | `packages/lib/server-only/envelope/find-envelopes.ts` |
| Internal tRPC stats schema | `packages/trpc/server/document-router/find-documents-internal.types.ts` |
| Public doc API schema + handler | `packages/trpc/server/document-router/find-documents.types.ts`, `find-documents.ts` |
| Public envelope API schema + handler | `packages/trpc/server/envelope-router/find-envelopes.types.ts`, `find-envelopes.ts` |
| Status display | `apps/remix/app/components/general/document/document-status.tsx` |
| Tabs + stats init | `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx` |
| Empty state (optional) | `apps/remix/app/components/tables/documents-table-empty-state.tsx` |
| Renew docs | `redistribute-document.types.ts`, `redistribute-envelope.types.ts` (+ resend dialogs, optional) |
## Reused Utilities / Patterns
- `recipientExists` / `senderEmailIs` (per-file Kysely EXISTS helpers) — the template for the new
`hasExpiredRecipient` helper.
- `REJECTED` branches in `find-documents.ts` (lines 279, 416) and `rejectedQuery` in `get-stats.ts`
(line 227) — the template for the `EXPIRED` branches / `expiredQuery`.
- `isRecipientExpired` (`packages/lib/utils/recipients.ts:118`) — defines the `expiresAt <= now`
semantics to match.
- Existing tab machinery in `documents._index.tsx` (`getTabHref`, count badge, personal-org `.filter`)
— works unchanged for the new tabs.
- `resendDocument` / `trpc.document.redistribute` / `trpc.envelope.redistribute` — existing renew path.
## Verification
1. **Typecheck** (the enum change forces all exhaustive/Record sites): `npm run typecheck -w @documenso/remix`.
2. **Seed + UI** (dev server already running): seed a team via `seedTeam`, send a document, then:
- Reject one as a recipient -> it appears under the new **Rejected** tab with a count.
- Force expiry (set a recipient `expiresAt` in the past, e.g. via Prisma Studio or a short
`envelopeExpirationPeriod`) -> the doc appears under the new **Expired** tab with a count, and the
count excludes signed/CC recipients.
3. **Public API**: `GET /api/v2/document?hasExpiredRecipients=true` and
`GET /api/v2/envelope?hasExpiredRecipients=true` (Bearer API token) return only envelopes with >=1
expired unsigned recipient; confirm `GET /api/v2/document?status=REJECTED` works. Verify the param
appears in `/api/v2/openapi.json`.
4. **Renew**: on an expired doc, run resend/redistribute (UI dialog or
`POST /api/v2/document/redistribute`) -> recipient `expiresAt` is refreshed, the doc leaves the
Expired tab, and the signing link no longer redirects to `/sign/$token/expired`.
5. **E2E** (optional): extend `packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts`
with an Expired-tab assertion.
6. Do **not** modify/commit `packages/lib/translations/*.po`; run `npm run translate` only if needed
for new `msg`/`Trans` strings, and keep generated `.po` files out of the branch.
## Open Questions
- Exact icon/color for the `EXPIRED` tab (proposed: `TimerOff`, `text-orange-500`).
- Whether to add the optional tailored empty-state copy now or defer.
+1 -1
View File
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
# min-release-age = 7
min-release-age = 7
+1 -1
View File
@@ -42,8 +42,8 @@ Documenso is an open-source document signing platform built as a **monorepo** us
| Package | Description | Port |
| -------------------------- | -------------------------------------------------------- | ---- |
| `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 |
| `@documenso/documentation` | Documentation site (Next.js + Nextra) | 3002 |
| `@documenso/openpage-api` | Public analytics API | 3003 |
| `@documenso/docs` | Documentation site | 3004 |
### Core Packages (`packages/`)
@@ -6,8 +6,6 @@ description: Create, manage, and send documents for signing via the API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -32,9 +30,9 @@ A document object contains the following properties:
| --------------- | -------------- | -------------------------------------------------------------- |
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
| `type` | string | `DOCUMENT` or `TEMPLATE` |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` |
| `title` | string | Document title |
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
| `externalId` | string \| null | Your custom identifier for the document |
| `createdAt` | string | ISO 8601 timestamp |
@@ -53,7 +51,7 @@ A document object contains the following properties:
"id": "envelope_abc123xyz",
"type": "DOCUMENT",
"status": "PENDING",
"source": "DOCUMENT",
"source": "API",
"visibility": "EVERYONE",
"title": "Service Agreement",
"externalId": "contract-2025-001",
@@ -73,13 +71,13 @@ A document object contains the following properties:
],
"fields": [
{
"id": 123,
"id": "field_123",
"type": "SIGNATURE",
"page": 1,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5,
"recipientId": 1
}
],
@@ -99,8 +97,6 @@ A document object contains the following properties:
}
```
Field position and size values are stored as decimals and serialized as strings in API responses.
## List Documents
Retrieve a paginated list of documents.
@@ -116,7 +112,7 @@ GET /envelope
| `page` | integer | Page number (default: 1) |
| `perPage` | integer | Results per page (default: 10, max: 100) |
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` |
| `source` | string | Filter by creation source |
| `folderId` | string | Filter by folder ID |
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
@@ -156,8 +152,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
},
});
const { data, count } = await response.json();
console.log(`Found ${count} documents`);
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} documents`);
// Filter by status
const pendingResponse = await fetch(
@@ -199,10 +195,12 @@ const pendingDocs = await pendingResponse.json();
]
}
],
"count": 42,
"currentPage": 1,
"perPage": 10,
"totalPages": 5
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 5,
"totalItems": 42
}
}
```
@@ -628,72 +626,6 @@ The response includes signing URLs for each recipient:
---
## Cancel Document
Cancel a pending document. This changes its status from `PENDING` to `CANCELLED`.
```
POST /envelope/cancel
```
### Request Body
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------------- |
| `envelopeId` | string | Yes | Document ID |
| `reason` | string | No | Reason for cancelling the document |
### Code Examples
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123",
"reason": "The agreement is no longer needed."
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', {
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abc123',
reason: 'The agreement is no longer needed.',
}),
});
const { success } = await response.json();
```
</Tab>
</Tabs>
### Response
```json
{
"success": true
}
```
### Behavior
- Only documents in `PENDING` status can be cancelled. Other statuses return `400`.
- Cancellation is not idempotent. Cancelling the same document again returns `400`.
- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`.
- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook.
- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document.
---
## Delete Document
Delete a document. Completed documents cannot be deleted.
@@ -736,7 +668,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
const { success } = await response.json();
```
````
</Tab>
</Tabs>
@@ -746,7 +678,7 @@ const { success } = await response.json();
{
"success": true
}
```
````
---
@@ -760,11 +692,9 @@ POST /envelope/get-many
### Request Body
| Field | Type | Required | Description |
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
| `ids` | object | Yes | ID selector containing `type` and `ids` |
| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` |
| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` |
| Field | Type | Required | Description |
| ------------- | ----- | -------- | --------------------- |
| `envelopeIds` | array | Yes | Array of document IDs |
### Code Examples
@@ -775,17 +705,12 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"ids": {
"type": "envelopeId",
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
}
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
method: 'POST',
headers: {
@@ -793,36 +718,16 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: {
type: 'envelopeId',
ids: requestedIds,
},
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
}),
});
const { data } = await response.json();
const documents = await response.json();
```
````
</Tab>
</Tabs>
### Response
```json
{
"data": [
{
"id": "envelope_abc123",
"type": "DOCUMENT",
"status": "PENDING",
"title": "Service Agreement"
}
]
}
```
The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions.
---
## Document Statuses
@@ -833,7 +738,6 @@ The endpoint silently omits envelopes you cannot access instead of returning `40
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
| `COMPLETED` | All recipients have signed. Document is sealed. |
| `REJECTED` | A recipient rejected the document. |
| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. |
### Status Transitions
@@ -841,13 +745,11 @@ The endpoint silently omits envelopes you cannot access instead of returning `40
flowchart LR
DRAFT --> PENDING --> COMPLETED
PENDING --> REJECTED
PENDING --> CANCELLED
```
- **DRAFT to PENDING**: Call the distribute endpoint
- **PENDING to COMPLETED**: All recipients complete their signing
- **PENDING to REJECTED**: A recipient rejects the document
- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document
<Callout type="warn">
You cannot modify recipients or fields after a document moves to `PENDING` status.
@@ -869,8 +771,8 @@ flowchart LR
| Parameter | Values | Description |
| ---------- | ------------------------------------------- | ------------------------- |
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
| `folderId` | string | Filter by folder |
### Sorting
@@ -896,10 +798,10 @@ async function getAllPendingDocuments() {
},
);
const { data, currentPage, totalPages } = await response.json();
const { data, pagination } = await response.json();
documents.push(...data);
hasMore = currentPage < totalPages;
hasMore = page < pagination.totalPages;
page++;
}
+87 -159
View File
@@ -19,13 +19,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| `secondaryId` | string | Secondary identifier for audit logs |
| `type` | string | Field type (see [Field Types](#field-types)) |
| `recipientId` | number | ID of the recipient assigned to this field |
| `envelopeId` | string | ID of the parent envelope |
| `envelopeId` | number | ID of the parent envelope |
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
| `page` | number | Page number (1-indexed) |
| `positionX` | string | X coordinate as percentage (0-100), a decimal serialized as a string |
| `positionY` | string | Y coordinate as percentage (0-100), a decimal serialized as a string |
| `width` | string | Width as percentage of page (0-100), a decimal serialized as a string |
| `height` | string | Height as percentage of page (0-100), a decimal serialized as a string |
| `positionX` | number | X coordinate as percentage (0-100) |
| `positionY` | number | Y coordinate as percentage (0-100) |
| `width` | number | Width as percentage of page (0-100) |
| `height` | number | Height as percentage of page (0-100) |
| `customText` | string | Value entered by the recipient |
| `inserted` | boolean | Whether the field has been completed |
| `fieldMeta` | object \| null | Type-specific configuration options |
@@ -38,19 +38,18 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
"secondaryId": "field_abc123",
"type": "SIGNATURE",
"recipientId": 123,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"envelopeId": 789,
"envelopeItemId": "envelope_item_xyz",
"page": 1,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5,
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"required": true,
"overflow": "auto"
"required": true
}
}
```
@@ -135,10 +134,10 @@ POST /envelope/field/create-many
### Request Body
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------------- |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field configurations |
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------- |
| `documentId`| number | Yes | The document ID |
| `fields` | array | Yes | Array of field configurations |
### Code Examples
@@ -149,32 +148,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
"documentId": 123,
"fields": [
{
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 80,
"pageNumber": 1,
"pageX": 10,
"pageY": 80,
"width": 30,
"height": 5
},
{
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": 50,
"positionY": 80,
"pageNumber": 1,
"pageX": 50,
"pageY": 80,
"width": 20,
"height": 3
},
{
"type": "TEXT",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 70,
"pageNumber": 1,
"pageX": 10,
"pageY": 70,
"width": 40,
"height": 4,
"fieldMeta": {
@@ -200,32 +199,32 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
documentId: 123,
fields: [
{
type: 'SIGNATURE',
recipientId: 456,
page: 1,
positionX: 10,
positionY: 80,
pageNumber: 1,
pageX: 10,
pageY: 80,
width: 30,
height: 5,
},
{
type: 'DATE',
recipientId: 456,
page: 1,
positionX: 50,
positionY: 80,
pageNumber: 1,
pageX: 50,
pageY: 80,
width: 20,
height: 3,
},
{
type: 'TEXT',
recipientId: 456,
page: 1,
positionX: 10,
positionY: 70,
pageNumber: 1,
pageX: 10,
pageY: 70,
width: 40,
height: 4,
fieldMeta: {
@@ -240,8 +239,8 @@ const response = await fetch(
}
);
const { data } = await response.json();
console.log(`Created ${data.length} fields`);
const { fields } = await response.json();
console.log(`Created ${fields.length} fields`);
````
</Tab>
@@ -251,68 +250,36 @@ console.log(`Created ${data.length} fields`);
```json
{
"data": [
"fields": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": "50",
"positionY": "80",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
},
{
"id": 103,
"secondaryId": "field_ghi789",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "TEXT",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "70",
"width": "40",
"height": "4",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "text",
"label": "Job Title",
"placeholder": "Enter your job title",
"required": true
}
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4
}
]
}
@@ -332,8 +299,8 @@ POST /envelope/field/update-many
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------- |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field update objects |
| `documentId` | number | Yes | The document ID |
| `fields` | array | Yes | Array of field update objects |
### Code Examples
@@ -344,17 +311,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
"documentId": 123,
"fields": [
{
"id": 101,
"type": "SIGNATURE",
"positionY": 85
"pageY": 85
},
{
"id": 102,
"type": "DATE",
"positionY": 85
"pageY": 85
}
]
}'
@@ -371,16 +338,16 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{ id: 101, type: 'SIGNATURE', positionY: 85 },
{ id: 102, type: 'DATE', positionY: 85 },
documentId: 123,
fields: [
{ id: 101, type: 'SIGNATURE', pageY: 85 },
{ id: 102, type: 'DATE', pageY: 85 },
],
}),
}
);
const { data } = await response.json();
const { fields } = await response.json();
````
</Tab>
@@ -390,48 +357,9 @@ const { data } = await response.json();
```json
{
"data": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "85",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": "50",
"positionY": "85",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
}
"fields": [
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
{ "id": 102, "type": "DATE", "positionY": 85 }
]
}
````
@@ -515,8 +443,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
(0,0) ─────────────────────────── (100,0)
│ │
│ ┌─────────┐ │
│ │ Field │ (positionX: 10, │
│ │ │ positionY: 20, │
│ │ Field │ (pageX: 10,
│ │ │ pageY: 20,
│ └─────────┘ width: 30, │
│ height: 5) │
│ │
@@ -529,9 +457,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
const field = {
type: 'SIGNATURE',
recipientId: 123,
page: 1,
positionX: 60, // 60% from left
positionY: 85, // 85% from top (near bottom)
pageNumber: 1,
pageX: 60, // 60% from left
pageY: 85, // 85% from top (near bottom)
width: 30, // 30% of page width
height: 8, // 8% of page height
};
@@ -715,15 +643,15 @@ All field types support these base options:
Create a document with a signature block containing multiple field types:
```typescript
async function addSignatureBlock(envelopeId: string, recipientId: number) {
const data = [
async function addSignatureBlock(documentId: number, recipientId: number) {
const fields = [
// Signature
{
type: 'SIGNATURE',
recipientId,
page: 1,
positionX: 10,
positionY: 80,
pageNumber: 1,
pageX: 10,
pageY: 80,
width: 30,
height: 8,
fieldMeta: {
@@ -735,9 +663,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'NAME',
recipientId,
page: 1,
positionX: 10,
positionY: 90,
pageNumber: 1,
pageX: 10,
pageY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -749,9 +677,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'DATE',
recipientId,
page: 1,
positionX: 50,
positionY: 80,
pageNumber: 1,
pageX: 50,
pageY: 80,
width: 20,
height: 4,
fieldMeta: {
@@ -763,9 +691,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'TEXT',
recipientId,
page: 1,
positionX: 50,
positionY: 90,
pageNumber: 1,
pageX: 50,
pageY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -782,7 +710,7 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId, data }),
body: JSON.stringify({ documentId, fields }),
});
return response.json();
@@ -5,8 +5,6 @@ description: Complete reference for the Documenso REST API.
import { Callout } from 'fumadocs-ui/components/callout';
<EnvelopeWarning />
<Callout type="warn">
The guides below cover common API patterns but may not reflect the latest endpoints or parameters.
For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -8,7 +8,6 @@
"teams",
"rate-limits",
"versioning",
"migrate-to-envelopes",
"developer-mode",
"common-errors"
]
@@ -1,249 +0,0 @@
---
title: Migrating to Envelopes
description: Why Documenso unified documents and templates into envelopes, and how to migrate from the deprecated document and template create endpoints.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Summary
The following items have been deprecated and will be removed on the <strong>1st of March 2027</strong>:
- <strong>API V1</strong>
- <strong>A subset of SDK/API V2 endpoints</strong>
- <strong>Legacy documents and templates</strong>
- <strong>EmbedCreateDocumentV1</strong>
- <strong>EmbedCreateTemplateV1</strong>
- <strong>EmbedUpdateDocumentV1</strong>
- <strong>EmbedUpdateTemplateV1</strong>
The beta endpoint `/api/v2-beta` will also be removed. Use `/api/v2` instead, which is a drop-in replacement.
Nothing breaks before 1st of March 2027, so you can migrate at your own pace.
## What are legacy documents and templates
These are documents and templates created by the following endpoints:
- `POST /api/v2/document/create`
- `POST /api/v2/document/create/beta`
- `POST /api/v2/template/create`
- `POST /api/v2/template/create/beta`
- `POST /api/v1/documents`
- `POST /api/v1/templates`
- `POST /api/v1/templates/create-document`
- `POST /api/v1/templates/generate-document`
## What replaces legacy documents and templates
At the end of 2025 we introduced a unified system for documents and templates, called <strong>envelopes</strong>.
We still reference documents and templates throughout the documentation and application to distinguish them, but internally they are envelopes.
Moving to the envelope system gives you:
- **Multiple PDFs in one envelope.** Send several documents to sign in a single request.
- **One API for documents and templates.** Learn one set of endpoints instead of two misaligned ones.
- **A better editor and signing experience** for you and your recipients.
## How to migrate
{/* prettier-ignore */}
<Steps>
<Step>
### Switch to the envelope endpoints
Replace each deprecated endpoint with its `/api/v2/envelope/*` equivalent from the [mapping tables](#endpoint-mapping-reference) below.
</Step>
<Step>
### Set the envelope `type` on create
A single endpoint, `POST /api/v2/envelope/create`, can create both documents and templates. Set `type` to `DOCUMENT` or `TEMPLATE`. You can now upload more than one PDF using the `files` field.
</Step>
<Step>
### Update how you store IDs
Envelope IDs are **strings** (for example `envelope_abc123`), not numbers. Update any code that stores, parses, or compares IDs.
</Step>
<Step>
### Test, then remove the old calls
Verify the new flow against your account, then delete the deprecated calls.
</Step>
</Steps>
The main data differences are as follows:
- ID format changed from number to string (e.g. `42` to `envelope_abc123`)
- pageNumber becomes page
- pageX becomes positionX
- pageY becomes positionY
See the [Documents API](/docs/developers/api/documents) and [Templates API](/docs/developers/api/templates) for the full envelope reference.
### Deprecated V1 API Endpoints
Full reference in the [V1 OpenAPI reference](https://openapi-v1.documenso.com).
| Deprecated endpoint | Replacement |
| -------------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v1/documents` | `GET /api/v2/envelope` |
| `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v1/documents` | `POST /api/v2/envelope/create` |
| `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/distribute` |
| `POST /api/v1/documents/{id}/resend` | `POST /api/v2/envelope/redistribute` |
| `DELETE /api/v1/documents/{id}` | `POST /api/v2/envelope/delete` |
| `GET /api/v1/documents/{id}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
| `POST /api/v1/documents/{id}/recipients` | `POST /api/v2/envelope/recipient/create-many` |
| `PATCH /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/update-many` |
| `DELETE /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/delete` |
| `POST /api/v1/documents/{id}/fields` | `POST /api/v2/envelope/field/create-many` |
| `PATCH /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/update-many` |
| `DELETE /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/delete` |
| `GET /api/v1/templates` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
| `GET /api/v1/templates/{id}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v1/templates` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `DELETE /api/v1/templates/{id}` | `POST /api/v2/envelope/delete` |
| `POST /api/v1/templates/{templateId}/create-document` | `POST /api/v2/envelope/use` |
| `POST /api/v1/templates/{templateId}/generate-document` | `POST /api/v2/envelope/use` |
### Deprecated V2 API Endpoints
Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
#### Documents
| Deprecated endpoint | Replacement |
| ------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v2/document` | `GET /api/v2/envelope` |
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) |
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
| `POST /api/v2/document/delete` | `POST /api/v2/envelope/delete` |
| `POST /api/v2/document/duplicate` | `POST /api/v2/envelope/duplicate` |
| `POST /api/v2/document/distribute` | `POST /api/v2/envelope/distribute` |
| `POST /api/v2/document/redistribute` | `POST /api/v2/envelope/redistribute` |
| `GET /api/v2/document/attachment` | `GET /api/v2/envelope/attachment` |
| `POST /api/v2/document/attachment/create` | `POST /api/v2/envelope/attachment/create` |
| `POST /api/v2/document/attachment/update` | `POST /api/v2/envelope/attachment/update` |
| `POST /api/v2/document/attachment/delete` | `POST /api/v2/envelope/attachment/delete` |
| `GET /api/v2/document/{documentId}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
| `GET /api/v2/document/{documentId}/download-beta` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
#### Templates
| Deprecated endpoint | Replacement |
| ------------------------------------- | ------------------------------------------------ |
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) |
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
| `POST /api/v2/template/duplicate` | `POST /api/v2/envelope/duplicate` |
| `POST /api/v2/template/delete` | `POST /api/v2/envelope/delete` |
| `POST /api/v2/template/use` | `POST /api/v2/envelope/use` |
| `POST /api/v2/template/direct/create` | **Pending replacement** |
| `POST /api/v2/template/direct/delete` | **Pending replacement** |
| `POST /api/v2/template/direct/toggle` | **Pending replacement** |
#### Document fields
| Deprecated endpoint | Replacement |
| ----------------------------------------- | ----------------------------------------- |
| `GET /api/v2/document/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
| `POST /api/v2/document/field/create` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/document/field/create-many` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/document/field/update` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/document/field/update-many` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/document/field/delete` | `POST /api/v2/envelope/field/delete` |
#### Template fields
| Deprecated endpoint | Replacement |
| ----------------------------------------- | ----------------------------------------- |
| `GET /api/v2/template/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
| `POST /api/v2/template/field/create` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/template/field/create-many` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/template/field/update` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/template/field/update-many` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/template/field/delete` | `POST /api/v2/envelope/field/delete` |
#### Document recipients
| Deprecated endpoint | Replacement |
| ---------------------------------------------- | ---------------------------------------------- |
| `GET /api/v2/document/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
| `POST /api/v2/document/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/document/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/document/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/document/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/document/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
#### Template recipients
| Deprecated endpoint | Replacement |
| ---------------------------------------------- | ---------------------------------------------- |
| `GET /api/v2/template/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
| `POST /api/v2/template/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/template/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/template/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/template/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/template/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
### Embedding components
| Deprecated component | Replacement |
| ----------------------- | --------------------- |
| `EmbedCreateDocumentV1` | `EmbedCreateEnvelope` |
| `EmbedCreateTemplateV1` | `EmbedCreateEnvelope` |
| `EmbedUpdateDocumentV1` | `EmbedUpdateEnvelope` |
| `EmbedUpdateTemplateV1` | `EmbedUpdateEnvelope` |
See the [embedding guide](/docs/developers/embedding) for the envelope components.
## FAQ
<Accordions>
<Accordion title="What happens on 1 March 2027?">
The deprecated V1 API, the V2 endpoints listed above, and the V1 embedding components are removed.
Requests to them will fail, so migrate to the envelope API before that date.
</Accordion>
<Accordion title="Will my existing documents and templates keep working?">
Yes. Documents and templates you already created remain in your account and continue to work. They will automatically be converted to envelopes. Only
the deprecated endpoints you call are going away. Your data is not deleted.
</Accordion>
<Accordion title="Do I need a new API token?">
No. Authentication is unchanged. The same API token works for the envelope endpoints under
`https://app.documenso.com/api/v2`.
</Accordion>
<Accordion title="What is the difference between a document and a template now?">
Both are envelopes, distinguished by a `type` field of `DOCUMENT` or `TEMPLATE`. They share the same
endpoints, recipients, fields, and attachments.
</Accordion>
<Accordion title="I use an official SDK, what should I do?">
The function calls to the legacy endpoints will break on the 1st of March 2027. Update to the latest SDK version and switch to its envelope methods.
The deprecated document and template methods map to the envelope endpoints in the tables above.
</Accordion>
<Accordion title="I need more time or help migrating">
Reach out to [support@documenso.com](mailto:support@documenso.com) with your use case and we will
help you plan the migration.
</Accordion>
</Accordions>
## Getting help
- [V2 OpenAPI reference](https://openapi.documenso.com): the up-to-date envelope API.
- [V1 OpenAPI reference](https://openapi-v1.documenso.com): the deprecated V1 API.
- [support@documenso.com](mailto:support@documenso.com): migration questions and extensions.
## See also
- [Documents API](/docs/developers/api/documents): create and manage envelopes
- [Templates API](/docs/developers/api/templates): work with templates and direct links
- [Fields API](/docs/developers/api/fields) and [Recipients API](/docs/developers/api/recipients)
- [API Versioning](/docs/developers/api/versioning): how Documenso versions the public API
@@ -11,21 +11,10 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
The rate limit applies to:
- `/api/v1/*`
- `/api/v2/*`
- `/api/v2-beta/*`
**Limit:** 1000 requests per minute per IP address
**Limit:** 100 requests per minute per IP address
**Response:** 429 Too Many Requests
<Callout type="info">
This is the global per-IP ceiling. Your organisation may have its own rate limits configured below
this value, in which case you can be rate-limited before reaching the global limit.
</Callout>
### Global per-IP 429 Response
### Rate Limit Response
```json
{
@@ -33,22 +22,10 @@ The rate limit applies to:
}
```
### Rate Limit Headers
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
and carry no rate limit headers:
| Header | Description |
| ----------------------- | ---------------------------------------------------------------------- |
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
<Callout type="warn">
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying.
</Callout>
## Resource Limits
@@ -62,55 +39,24 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
### Organisation Limit 429 Responses
### Error Response
Organisation windowed limits and organisation monthly quotas produce 429 responses whose body
shape depends on the API version, and neither matches the global per-IP limiter's
`{ "error": "..." }` body.
On `/api/v1/*`, the body contains only a message:
When you exceed a resource limit:
```json
{
"message": "Too many requests, please try again later. Contact support if you require higher limits."
"error": "You have reached your document limit for this month. Please upgrade your plan.",
"code": "LIMIT_EXCEEDED",
"statusCode": 400
}
```
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
```json
{
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
"code": "TOO_MANY_REQUESTS",
"data": {
"code": "TOO_MANY_REQUESTS",
"httpStatus": 429,
"appError": {
"code": "TOO_MANY_REQUESTS",
"message": "Too many requests, please try again later. Contact support if you require higher limits."
}
}
}
```
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
their own window. Monthly quota responses carry no quota-specific rate limit headers or
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
## Error Codes
| Code | Status | Description |
| ------------------- | ------ | ------------------------------------------------------------------ |
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
There are three sources of `TOO_MANY_REQUESTS` responses:
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
3. Organisation monthly quotas for the same three counters. Every authenticated API request
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
exhausted — not just envelope-related ones.
| Code | Status | Description |
| ------------------- | ------ | ----------------------------- |
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
---
@@ -119,4 +65,3 @@ There are three sources of `TOO_MANY_REQUESTS` responses:
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
- [API Versioning](/docs/developers/api/versioning) - API version management
- [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Admins: set per-organisation resource quotas and rate limits (the HTTP rate limit above is separate and not admin-settable)
@@ -6,8 +6,6 @@ description: Create documents from reusable templates via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -5,8 +5,6 @@ description: Versioning information for the Documenso public API.
import { Callout } from 'fumadocs-ui/components/callout';
<EnvelopeWarning />
## Overview
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
@@ -21,16 +19,7 @@ Also, we may deprecate certain features or endpoints in the API. When we depreca
---
## Documents, Templates, and Envelopes
Documenso has unified documents and templates into a single resource called an **envelope**. New integrations should create documents and templates through the `/envelope/*` endpoints. The `POST /document/create` and `POST /template/create` endpoints (including their `/beta` variants) are deprecated in favor of `POST /envelope/create`.
See [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) for the rationale and step-by-step migration examples.
---
## See Also
- [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) - Move from the document and template create endpoints
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
- [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details
@@ -8,8 +8,6 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
## Workflow 1: Send a Document for Signature
The most common workflow: upload a PDF, add recipients with signature fields, and send for signing.
@@ -474,7 +472,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
<code>distributeDocument: true</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
</Step>
</Steps>
@@ -640,8 +638,8 @@ done
</Tabs>
<Callout type="info">
The API allows 1000 requests per minute (your organisation may have its own lower limit). For large
batches, implement rate limiting with delays between requests to avoid hitting limits.
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
between requests to avoid hitting limits.
</Callout>
---
@@ -1000,12 +998,9 @@ async function fetchWithRetry(
// Retry on rate limit
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
const delay = retryAfter
? parseInt(retryAfter) * 1000
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
console.log(`Rate limited, waiting ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
continue;
}
@@ -3,8 +3,6 @@ title: Examples
description: Common integration patterns and end-to-end workflows.
---
<EnvelopeWarning />
<Cards>
<Card
title="Common Workflows"
@@ -7,8 +7,6 @@ import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
<EnvelopeWarning />
## Prerequisites
- A Documenso account (cloud or self-hosted)
@@ -7,8 +7,6 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
## Prerequisites
Before starting, you need:
@@ -78,10 +76,12 @@ A successful response returns a list of your documents (envelopes):
"createdAt": "2025-01-15T10:30:00.000Z"
}
],
"count": 1,
"currentPage": 1,
"perPage": 10,
"totalPages": 1
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 1,
"totalItems": 1
}
}
````
@@ -226,12 +226,9 @@ After creating a document, it's in `DRAFT` status. To send it to recipients, use
<Tabs items={['curl', 'JavaScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
-H "Authorization: YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123"
}'
-H "Content-Type: application/json"
````
</Tab>
@@ -239,14 +236,16 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
```javascript
const envelopeId = 'envelope_abc123';
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
const response = await fetch(
`https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`,
{
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
},
body: JSON.stringify({ envelopeId }),
});
);
const data = await response.json();
console.log('Document sent:', data);
@@ -336,14 +335,16 @@ async function createAndSendDocument(pdfPath, recipientEmail, recipientName) {
console.log('Created envelope:', envelope.id);
// Step 2: Send the document for signing
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: envelope.id }),
});
const distributeResponse = await fetch(
`${BASE_URL}/envelope/${envelope.id}/distribute`,
{
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
}
);
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
@@ -419,12 +420,9 @@ echo "Created envelope: ${ENVELOPE_ID}"
# Step 2: Send the document for signing
echo "Sending document..."
curl -s -X POST "${BASE_URL}/envelope/distribute" \
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"envelopeId\": \"${ENVELOPE_ID}\"
}"
-H "Content-Type: application/json"
echo "Document sent for signing!"
@@ -441,7 +439,7 @@ The API returns standard HTTP status codes and JSON error responses:
| `400` | Bad request - check your request payload |
| `401` | Unauthorized - invalid or missing API token |
| `404` | Not found - resource doesn't exist |
| `429` | Rate limited - wait for the duration in the `Retry-After` header |
| `429` | Rate limited - wait 60 seconds and retry |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,7 +483,7 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. Every response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). When you receive a `429` response, read the `Retry-After` header and wait for that many seconds before retrying. See [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for a more complete retry strategy.
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -493,9 +491,8 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfterSeconds = Number.parseInt(response.headers.get('Retry-After') ?? '1', 10);
console.log(`Rate limited, waiting ${retryAfterSeconds} seconds...`);
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
console.log('Rate limited, waiting 60 seconds...');
await new Promise((resolve) => setTimeout(resolve, 60000));
continue;
}
@@ -3,8 +3,6 @@ title: Getting Started
description: Get your API key and make your first API call.
---
<EnvelopeWarning />
<Cards>
<Card
title="Authentication"
@@ -3,8 +3,6 @@ title: Developer Guide
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
---
<EnvelopeWarning />
## Getting Started
<Cards>
@@ -33,14 +33,13 @@ All webhook events share a common structure:
| Field | Type | Description |
| ---------------- | --------- | ------------------------------------------------------ |
| `id` | number | Legacy numeric v1 document or template ID |
| `envelopeId` | string | Canonical v2 identifier (`envelope_` + 16 characters) |
| `id` | number | Document or template ID |
| `externalId` | string? | External identifier for integration |
| `userId` | number | Owner's user ID |
| `authOptions` | object? | Document-level authentication options |
| `formValues` | object? | PDF form values associated with the document |
| `title` | string | Document or template title |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
| `visibility` | string | Document visibility setting |
| `createdAt` | datetime | Document creation timestamp |
| `updatedAt` | datetime | Last modification timestamp |
@@ -48,8 +47,8 @@ All webhook events share a common structure:
| `deletedAt` | datetime? | Deletion timestamp |
| `teamId` | number? | Team ID if document belongs to a team |
| `templateId` | number? | Template ID if created from a template |
| `source` | string | Source: `DOCUMENT`, `TEMPLATE`, or `TEMPLATE_DIRECT_LINK` |
| `documentMeta` | object? | Nullable document metadata (subject, message, signing options) |
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
| `documentMeta` | object | Document metadata (subject, message, signing options) |
| `recipients` | array | List of recipient objects |
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
@@ -61,6 +60,7 @@ All webhook events share a common structure:
| `subject` | string? | Email subject line |
| `message` | string? | Email message body |
| `timezone` | string | Timezone for date display |
| `password` | string? | Document access password (if set) |
| `dateFormat` | string | Date format string |
| `redirectUrl` | string? | URL to redirect after signing |
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
@@ -77,9 +77,8 @@ All webhook events share a common structure:
| Field | Type | Description |
| ---------------------- | --------- | ------------------------------------------ |
| `id` | number | Recipient ID |
| `envelopeId` | string | Canonical parent envelope ID |
| `documentId` | number? | Legacy parent document ID; null for templates |
| `templateId` | number? | Legacy parent template ID; null for documents |
| `documentId` | number? | Parent document ID |
| `templateId` | number? | Template ID if created from a template |
| `email` | string | Recipient email address |
| `name` | string | Recipient name |
| `token` | string | Unique signing token |
@@ -95,8 +94,6 @@ All webhook events share a common structure:
| `sendStatus` | string | `NOT_SENT` or `SENT` |
| `rejectionReason` | string? | Reason if recipient rejected |
Use `recipient.envelopeId` as the reliable parent link. The legacy `documentId` and `templateId` fields depend on the parent envelope type, so one of them is always null.
---
## Document Lifecycle Events
@@ -114,7 +111,6 @@ Triggered when a new document is created.
"event": "DOCUMENT_CREATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -133,8 +129,9 @@ Triggered when a new document is created.
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -148,7 +145,6 @@ Triggered when a new document is created.
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -170,7 +166,6 @@ Triggered when a new document is created.
"Recipient": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -208,7 +203,6 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"event": "DOCUMENT_SENT",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -227,8 +221,9 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -242,7 +237,6 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -264,7 +258,6 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"Recipient": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -302,14 +295,12 @@ The recipient's `readStatus` changes to `OPENED`.
"event": "DOCUMENT_OPENED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -337,7 +328,6 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
"event": "DOCUMENT_SIGNED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "COMPLETED",
"title": "contract.pdf",
"source": "DOCUMENT",
@@ -345,7 +335,6 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
"recipients": [
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -372,14 +361,12 @@ Triggered when an individual recipient completes their required action (signing,
"event": "DOCUMENT_RECIPIENT_COMPLETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -408,7 +395,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"event": "DOCUMENT_COMPLETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -427,8 +413,9 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -442,7 +429,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"recipients": [
{
"id": 50,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "reviewer@example.com",
@@ -465,7 +451,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
},
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -490,7 +475,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"Recipient": [
{
"id": 50,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "reviewer@example.com",
@@ -513,7 +497,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
},
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -554,14 +537,12 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
"event": "DOCUMENT_REJECTED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -580,7 +561,7 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
### `document.cancelled`
Triggered when a pending document is explicitly cancelled with `POST /envelope/cancel`, or when a document owner or team member deletes a document. Deleting a draft or pending document hard-deletes it, while deleting a completed document soft-deletes it.
Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted.
This event is **not** triggered when a recipient hides a document from their inbox.
@@ -591,7 +572,6 @@ This event is **not** triggered when a recipient hides a document from their inb
"event": "DOCUMENT_CANCELLED",
"payload": {
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 3,
"authOptions": null,
@@ -611,6 +591,7 @@ This event is **not** triggered when a recipient hides a document from their inb
"subject": "",
"message": "",
"timezone": "Etc/UTC",
"password": null,
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "",
"signingOrder": "PARALLEL",
@@ -625,7 +606,6 @@ This event is **not** triggered when a recipient hides a document from their inb
"recipients": [
{
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 7,
"templateId": null,
"email": "signer@example.com",
@@ -647,7 +627,6 @@ This event is **not** triggered when a recipient hides a document from their inb
"Recipient": [
{
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 7,
"templateId": null,
"email": "signer@example.com",
@@ -672,45 +651,6 @@ This event is **not** triggered when a recipient hides a document from their inb
}
```
### `recipient.expired`
Triggered when a recipient's signing deadline passes on a pending document before they sign or reject it.
**Event name:** `RECIPIENT_EXPIRED`
The recipient's `expiresAt` contains the signing deadline, and `expirationNotifiedAt` is set when the expiration is processed.
```json
{
"event": "RECIPIENT_EXPIRED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
"expiresAt": "2024-04-22T11:51:00.000Z",
"expirationNotifiedAt": "2024-04-22T11:52:00.000Z",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:52:00.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `document.reminder.sent`
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
@@ -722,14 +662,12 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
"event": "DOCUMENT_REMINDER_SENT",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -748,7 +686,7 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
## Template Events
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads. For `TEMPLATE_CREATED`, `TEMPLATE_UPDATED`, and `TEMPLATE_DELETED` the template's own legacy numeric ID is in `id` and `templateId` is `null`. Only `TEMPLATE_USED` — whose payload describes the new document envelope created from the template — carries the originating template's legacy ID in `templateId`, with `source` set to `TEMPLATE`.
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated.
### `template.created`
@@ -761,10 +699,9 @@ Triggered when a new template is created.
"event": "TEMPLATE_CREATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "My Template",
"status": "DRAFT",
"templateId": null,
"templateId": 10,
"source": "TEMPLATE",
"recipients": []
},
@@ -784,10 +721,9 @@ Triggered when a template's settings, recipients, or fields are modified.
"event": "TEMPLATE_UPDATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "My Updated Template",
"status": "DRAFT",
"templateId": null,
"templateId": 10,
"source": "TEMPLATE",
"recipients": []
},
@@ -807,10 +743,9 @@ Triggered when a template is deleted.
"event": "TEMPLATE_DELETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "Deleted Template",
"status": "DRAFT",
"templateId": null,
"templateId": 10,
"source": "TEMPLATE",
"recipients": []
},
@@ -830,7 +765,6 @@ Triggered when a document is created from a template. This event fires alongside
"event": "TEMPLATE_USED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "Document from Template",
"status": "DRAFT",
"templateId": 10,
@@ -857,8 +791,7 @@ Triggered when a document is created from a template. This event fires alongside
| `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
| `DOCUMENT_CANCELLED` | Pending document explicitly cancelled, or document deleted | `status: "CANCELLED"` after explicit cancellation; deletion may remove or soft-delete the document |
| `RECIPIENT_EXPIRED` | Recipient signing deadline passes | Recipient `expiresAt` passed, `expirationNotifiedAt` set |
| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted |
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
### Template Events
@@ -888,7 +821,7 @@ When processing webhook events:
**Process idempotently** — Webhooks may be retried, so handle duplicate events
</Step>
<Step>
**Respond quickly** — Return a `2xx` status code within 10 seconds
**Respond quickly** — Return a 200 status code within 30 seconds
</Step>
</Steps>
@@ -42,14 +42,12 @@ Documenso supports webhook events for the full document lifecycle (created, sent
"event": "DOCUMENT_COMPLETED",
"payload": {
"id": 123,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "Contract",
"status": "COMPLETED",
"completedAt": "2024-01-15T10:30:00.000Z",
"recipients": [
{
"id": 1,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"signingStatus": "SIGNED"
}
@@ -60,8 +58,6 @@ Documenso supports webhook events for the full document lifecycle (created, sent
}
```
`payload.id` is the legacy numeric v1 ID. Use `payload.envelopeId` as the canonical v2 identifier. Each recipient repeats `envelopeId` as the reliable parent link because the legacy `documentId` and `templateId` fields depend on the parent envelope type, leaving one of them null.
---
## See Also
@@ -148,7 +148,7 @@ func main() {
</Tabs>
<Callout type="warn">
Always respond with a `2xx` status within 10 seconds. Documenso will retry failed deliveries according to the configured background-job provider.
Always respond with a `200 OK` status within 30 seconds. Documenso will retry failed deliveries.
</Callout>
## Configuring Webhooks in Documenso via the Dashboard
@@ -184,7 +184,7 @@ Fill in the following fields:
| Field | Description |
| ----- | ----------- |
| **Webhook URL** | The HTTP or HTTPS endpoint that will receive webhook events |
| **Webhook URL** | The HTTPS endpoint that will receive webhook events |
| **Events** | Select which events should trigger this webhook |
| **Secret** (optional) | A secret key used to sign the payload for verification |
</Step>
@@ -202,21 +202,12 @@ Your webhook endpoint must meet these requirements:
| Requirement | Details |
| ----------- | ------- |
| **Protocol** | HTTP and HTTPS are accepted; use HTTPS in production |
| **Response** | Must return a `2xx` status code within 10 seconds |
| **Protocol** | HTTPS required (HTTP not allowed in production) |
| **Response** | Must return `2xx` status code within 30 seconds |
| **Method** | Must accept HTTP POST requests |
| **Content-Type** | Must accept `application/json` payloads |
| **Availability** | Must be publicly accessible from the internet |
<Callout type="warn">
Documenso performs a best-effort check that rejects webhook URLs which use or resolve to private
or loopback addresses. This is not a complete SSRF mitigation — it does not cover DNS rebinding
and fails open on DNS lookup errors or timeouts — so self-hosted deployments should still enforce
network-level egress rules. Self-hosters that need to deliver to a hostname resolving to a
private address can add that hostname to the comma-separated
`NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` environment variable.
</Callout>
<Callout type="info">
For local development, use a tunneling service like [ngrok](https://ngrok.com) or [localtunnel](https://localtunnel.me) to expose your local server.
</Callout>
@@ -234,8 +225,7 @@ When creating a webhook, you can subscribe to one or more events:
| `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action |
| `DOCUMENT_COMPLETED` | All recipients have completed their actions |
| `DOCUMENT_REJECTED` | A recipient rejects the document |
| `DOCUMENT_CANCELLED` | A pending document is explicitly cancelled or a document owner deletes it |
| `RECIPIENT_EXPIRED` | A recipient's signing deadline passes before they sign or reject |
| `DOCUMENT_CANCELLED` | The document owner deletes the document |
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
| `TEMPLATE_CREATED` | A new template is created |
| `TEMPLATE_UPDATED` | A template is modified |
@@ -328,17 +318,17 @@ Documenso will attempt to deliver the same payload again
## Retry Policy
A delivery fails when the endpoint returns a non-`2xx` response, the 10-second timeout expires, or the request fails. Redirects are not followed, so `3xx` responses also fail. Network and SSRF-blocked requests are recorded with response code `0`.
When a webhook delivery fails (non-2xx response or timeout), Documenso automatically retries with exponential backoff:
For self-hosted deployments, retries are handled by the background-job provider selected with `NEXT_PRIVATE_JOBS_PROVIDER`:
| Attempt | Delay |
| ------- | ----- |
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| Provider | Total attempts | Retry timing |
| -------- | -------------- | ------------ |
| Local (default) | 4 | Back-to-back, with no backoff |
| BullMQ | 3 | Exponential backoff starting at 1 second |
| Inngest | 5 | Inngest platform backoff |
Only the individual delivery (`WebhookCall`) record is marked as failed. Documenso does not automatically disable the webhook or apply a circuit breaker, so future matching events continue to be delivered. After automatic attempts are exhausted, you can manually resend a failed delivery from the dashboard.
After 5 failed attempts, the webhook is marked as failed and no further automatic retries occur. You can manually resend failed webhooks from the dashboard.
<Callout type="warn">
If your endpoint consistently fails, consider reviewing your server logs and ensuring your endpoint meets all [URL requirements](#webhook-url-requirements).
@@ -255,7 +255,6 @@ const validEvents = [
'DOCUMENT_REJECTED',
'DOCUMENT_CANCELLED',
'DOCUMENT_REMINDER_SENT',
'RECIPIENT_EXPIRED',
'TEMPLATE_CREATED',
'TEMPLATE_UPDATED',
'TEMPLATE_DELETED',
@@ -76,8 +76,6 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
</Accordion>
</Accordions>
@@ -199,7 +197,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement
2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
4. Configure Enterprise features with support assistance
</Step>
<Step>
@@ -240,7 +238,6 @@ See [Support](/docs/policies/support) for complete support options.
## Related
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times
+1 -6
View File
@@ -41,17 +41,12 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
| Action | Limit | Window |
| --- | --- | --- |
| API requests (v1 and v2) | 1000 requests | 1 minute |
| API requests (v1 and v2) | 100 requests | 1 minute |
| File uploads | 20 requests | 1 minute |
| AI features | 3 requests | 1 minute |
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
<Callout type="info">
The API request limit above is the global per-IP ceiling. Individual organisations also have their
own rate limits, which may be configured below this value.
</Callout>
<Callout type="info">
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
[sales](https://documen.so/sales) for details.
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
@@ -510,5 +510,4 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Set per-organisation document, email, and API limits from the admin panel
- [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues
@@ -29,11 +29,6 @@ description: Configure your self-hosted Documenso instance with environment vari
description="Digital signature certificate setup."
href="/docs/self-hosting/configuration/signing-certificate"
/>
<Card
title="Organisation Limits"
description="Set per-organisation document, email, and API limits via the admin panel."
href="/docs/self-hosting/configuration/organisation-limits"
/>
</Cards>
## Required Configuration
@@ -1,107 +0,0 @@
---
title: Apply Your License Key
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
<Callout type="info">
The license key applies to your **whole instance**, not an individual user account. There's one
key per deployment.
</Callout>
## Prerequisites
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
- A running self-hosted Documenso instance that you're able to restart
## Step 1: Set the environment variable
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
<Tabs items={['Docker Compose', 'docker run', '.env']}>
<Tab value="Docker Compose">
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
Then apply it:
```bash
docker compose up -d
```
</Tab>
<Tab value="docker run">
```bash
docker run -d \
--name documenso \
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
documenso/documenso:latest
```
</Tab>
<Tab value=".env">
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
</Tab>
</Tabs>
## Step 2: Restart the instance
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
```bash
# Docker Compose
docker compose restart documenso
# Docker
docker restart documenso
```
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
## What the license enables
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
## Troubleshooting
<Accordions type="multiple">
<Accordion title="Enterprise features are still unavailable after applying the key">
- Confirm the key is present in the environment the running process actually reads — `docker
exec` into the container and check `env | grep LICENSE` if unsure.
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
- Re-copy the key to rule out truncation or accidental whitespace.
</Accordion>
<Accordion title="A specific feature still isn't working">
Instance-wide features (like CSC signing) also need their own configuration — an active license
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
Per-organisation features additionally need to be provisioned for the organisation that's using
them.
</Accordion>
</Accordions>
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
@@ -2,14 +2,12 @@
"title": "Configuration",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
"background-jobs",
"signing-certificate",
"telemetry",
"organisation-limits",
"advanced"
]
}
@@ -1,111 +0,0 @@
---
title: Organisation Limits
description: View and set per-organisation document, email, and API limits on a self-hosted Documenso instance using the admin panel's subscription claims.
---
import { Callout } from 'fumadocs-ui/components/callout';
Per-organisation limits — document, email, and API usage, plus feature toggles and team/member caps — are controlled by **subscription claims**. You configure them in the admin panel, not through environment variables.
There are three distinct kinds of limit:
| Limit | Caps | Admin-settable |
| ---------------------- | ------------------------------------------------- | ----------------------- |
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
| Global HTTP rate limit | API requests per IP (1000/min, hardcoded) | No — see [Limitations](#limitations) |
## Prerequisites
- A running self-hosted Documenso instance.
- An account with the **`ADMIN`** role — an account-level role, separate from organisation and team roles. New accounts are created with the `USER` role only. Grant the first admin by adding `ADMIN` to that user's `roles` directly in the database; after that, an existing admin can grant the role to others under **Admin Panel > Users > _(user)_ > Roles > Update user**.
Open the admin panel at `/admin`. The sidebar sections used below are **Claims**, **Organisations**, and **Organisation Stats**.
## Viewing usage
**One organisation:** open **Admin Panel > Organisations** and select it. The **Organisation usage** section shows the current period's document, email, and API usage against its quotas.
**All organisations:** open **Admin Panel > Organisation Stats** to sort and filter monthly usage. Filter by **claim** and by **period** (a UTC calendar month, shown as `YYYY-MM`), and switch between **Show usage**, **Show usage with quotas**, and **Show daily averages**.
<Callout type="warn">
Usage counts **attempts**, not only successful actions. A request that exceeds a quota is still counted before it is rejected, so displayed usage can read higher than the number of actions that succeeded.
</Callout>
## Subscription claims
A subscription claim is a named bundle of limits and feature flags (for example `Free`, `Individual`, `Teams`, `Platform`, or `Enterprise`). Claims are **templates**: when an organisation is created it receives a private copy of its claim and reads from that copy afterwards. Editing a claim template therefore affects organisations created later, not existing ones — to change an existing organisation, [edit it directly](#change-limits-for-one-organisation).
### Claim fields
Under **Admin Panel > Claims** (`/admin/claims`), each claim has:
| Field | Controls |
| ----------------------- | --------------------------------------------------------------------------------- |
| **Name** | The claim's display name. |
| **Team Count** | Teams allowed. `0` = unlimited. |
| **Member Count** | Members allowed. `0` = unlimited. |
| **Envelope Item Count** | Uploaded files allowed per envelope. Minimum `1`. |
| **Recipient Count** | Recipients allowed per document. `0` = unlimited. |
| **Feature Flags** | Feature toggles (see [Feature flags](#feature-flags)). |
| **Limits** | Monthly quota and rate-limit windows for Documents, Emails, and API. |
| **Email transport** | Transport the claim uses. *Default (system mailer)* uses the instance default. |
### Quotas and rate limits
The **Limits** section has a column for **Documents**, **Emails**, and **API**, each with two controls:
- **Monthly quota** — how many of that resource are allowed per calendar month. An **empty** field is unlimited; **`0`** blocks the resource entirely.
- **Rate limit windows** — optional short-window caps, each a duration and a maximum. A window is a number and a unit (`s`, `m`, `h`, `d`), such as `5m`, `1h`, or `24h`, and must be unique within the resource.
<Callout type="warn">
Quotas and counts use opposite conventions for "unlimited": an **empty** quota is unlimited (and `0` blocks the resource), whereas `0` in the **Team**, **Member**, and **Recipient Count** fields means unlimited.
</Callout>
### Feature flags
The **Feature Flags** section toggles capabilities such as Unlimited documents, Branding, Hide Documenso branding, Email domains, Embed authoring, Embed signing, White label for embed authoring/signing, 21 CFR, HIPAA, Authentication portal, Allow Legacy Envelopes, Signing reminders, QES signing, and Disable emails.
Some flags are Enterprise features. If your license does not include one, it is marked and cannot be enabled (you can still turn it off). See [Enterprise Edition](/docs/policies/enterprise-edition).
### Create or edit a claim template
1. Go to **Admin Panel > Claims**.
2. Select **New claim**, or select an existing claim to edit it.
3. Set the counts, feature flags, and the **Limits** section.
4. Save. Changes apply to organisations created afterwards, not existing ones.
### Change limits for one organisation
To change limits for an existing organisation, edit it directly rather than its claim template.
1. Go to **Admin Panel > Organisations** and open the organisation.
2. Adjust its quota, rate-limit, feature-flag, or email-transport fields.
3. Save. Changes take effect immediately.
The organisation also shows the **Inherited subscription claim** it was created from.
## Usage reset
Monthly quota usage is keyed to the **UTC calendar month**. There is no scheduled reset job — when the month rolls over, the new period's counter starts at `0`.
## Limitations
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **1000 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
## Troubleshooting
| Symptom | Cause and fix |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| An organisation hit its limit unexpectedly | Usage counts rejected over-quota attempts. Compare usage against the quota under **Organisation Stats > Show usage with quotas**. |
| A resource is blocked entirely, not just capped | The **Monthly quota** is `0`, which blocks the resource. Leave it empty for unlimited. |
| Emails are not sending for an organisation | Check whether the **Disable emails** flag is enabled on the organisation's claim — it blocks all emails regardless of quota. |
| A claim template edit had no effect | Template edits are not retroactive. Edit the organisation directly under **Admin Panel > Organisations**. |
---
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - All configuration options
- [Rate Limits](/docs/developers/api/rate-limits) - The global HTTP API rate limit (separate from claims)
- [Enterprise Edition](/docs/policies/enterprise-edition) - Features unlocked by license flags
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
---
+1 -1
View File
@@ -29,7 +29,7 @@
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.19",
"postcss": "^8.5.14",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
+1 -1
View File
@@ -83,7 +83,7 @@
--accent: hsl(0 0% 27.8431%);
--accent-foreground: hsl(95.0847 71.0843% 67.451%);
--destructive: hsl(0 86.5979% 61.9608%);
--destructive-foreground: hsl(0 0% 98.0392%);
--destructive-foreground: hsl(0 87.6289% 19.0196%);
--border: hsl(0 0% 27.8431%);
--input: hsl(0 0% 27.8431%);
--ring: hsl(95.0847 71.0843% 67.451%);
@@ -1,19 +0,0 @@
import { Callout } from 'fumadocs-ui/components/callout';
const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes';
/**
* Deprecation banner steering API consumers away from the legacy document and
* template create endpoints and towards the unified Envelope API.
*
* Registered globally in `mdx-components.tsx`, so it can be used in any MDX page
* as `<EnvelopeWarning />` without an explicit import.
*/
export function EnvelopeWarning() {
return (
<Callout type="error">
<strong>Documents and templates are being deprecated and replaced by envelopes.</strong>{' '}
<a href={MIGRATION_GUIDE_HREF}>Read the migration guide here.</a>
</Callout>
);
}
-2
View File
@@ -1,7 +1,6 @@
import * as TabsComponents from 'fumadocs-ui/components/tabs';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { EnvelopeWarning } from '@/components/mdx/envelope-warning';
import { Mermaid } from '@/components/mdx/mermaid';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -10,7 +9,6 @@ export function getMDXComponents(components?: MDXComponents): any {
...defaultMdxComponents,
...TabsComponents,
Mermaid,
EnvelopeWarning,
...components,
};
}
@@ -1,119 +0,0 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type BrandingPreferencesResetDialogProps = {
hasAdvancedBranding: boolean;
isSubmitting: boolean;
onReset: () => Promise<void>;
trigger?: React.ReactNode;
};
export const BrandingPreferencesResetDialog = ({
hasAdvancedBranding,
isSubmitting,
onReset,
trigger,
}: BrandingPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isLoading = isSubmitting || isResetting;
const handleResetToDefaults = async () => {
setIsResetting(true);
try {
await onReset();
setOpen(false);
} catch {
// The submit handler surfaces its own error toast. Keep the dialog open
// so the user can retry.
} finally {
setIsResetting(false);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
<Trans>Reset to defaults</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reset branding preferences</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will reset all branding preferences to their default values and save the changes immediately.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will be reset:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Custom branding enabled setting</Trans>
</li>
<li>
<Trans>Branding logo</Trans>
</li>
<li>
<Trans>Brand website and brand details</Trans>
</li>
<li>
<Trans>Brand colours, including background, foreground, primary, and border colours</Trans>
</li>
{hasAdvancedBranding && (
<>
<li>
<Trans>Border radius</Trans>
</li>
<li>
<Trans>Custom CSS</Trans>
</li>
</>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,141 +0,0 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type DocumentPreferencesResetDialogProps = {
isSubmitting: boolean;
onReset: () => Promise<void>;
showAiFeatures?: boolean;
showDocumentVisibility?: boolean;
showIncludeSenderDetails?: boolean;
};
export const DocumentPreferencesResetDialog = ({
isSubmitting,
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
showIncludeSenderDetails = false,
}: DocumentPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isLoading = isSubmitting || isResetting;
const handleResetToDefaults = async () => {
setIsResetting(true);
try {
await onReset();
setOpen(false);
} catch {
// The submit handler surfaces its own error toast. Keep the dialog open
// so the user can retry.
} finally {
setIsResetting(false);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reset document preferences</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will reset all document preferences to their default values and save the changes immediately.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will be reset:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
{showDocumentVisibility && (
<li>
<Trans>Default document visibility</Trans>
</li>
)}
<li>
<Trans>Default document language</Trans>
</li>
<li>
<Trans>Default date format</Trans>
</li>
<li>
<Trans>Default time zone</Trans>
</li>
<li>
<Trans>Default signature settings</Trans>
</li>
{showIncludeSenderDetails && (
<li>
<Trans>Send on behalf of team</Trans>
</li>
)}
<li>
<Trans>Include the signing certificate in the document</Trans>
</li>
<li>
<Trans>Include the audit logs in the document</Trans>
</li>
<li>
<Trans>Default recipients</Trans>
</li>
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Default envelope expiration</Trans>
</li>
<li>
<Trans>Default signing reminders</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
</li>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -122,7 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -336,7 +336,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
>
<div className="w-full text-left">
<div className="flex items-center justify-between">
<p className="font-medium">
<p className="text-medium">
<Trans context="Plan price">Free</Trans>
</p>
@@ -115,7 +115,7 @@ export const OrganisationEmailDomainDeleteDialog = ({
<FormLabel>
<Trans>
Confirm by typing{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -370,7 +370,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
<button
type="button"
className={cn(
'inline-flex h-10 w-10 items-center justify-start text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={organisationMemberInvites.length === 1}
@@ -155,7 +155,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create team</Trans>
<Trans>Create Team</Trans>
</Button>
)}
</DialogTrigger>
@@ -163,7 +163,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create team</Trans>
<Trans>Create Team</Trans>
</DialogTitle>
<DialogDescription>
@@ -79,7 +79,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
toast({
title: t`Success`,
description: t`Team members have been added.`,
description: t`Team groups have been added.`,
duration: 5000,
});
@@ -87,7 +87,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
} catch {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
description: t`We encountered an unknown error while attempting to add the groups. Please try again later.`,
variant: 'destructive',
});
}
@@ -134,7 +134,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
</DialogTitle>
<DialogDescription>
<Trans>Configure the team roles for each group</Trans>
<Trans>Configure the team roles for each group.</Trans>
</DialogDescription>
</DialogHeader>
))
@@ -260,7 +260,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create Groups</Trans>
<Trans>Add Groups</Trans>
</Button>
</DialogFooter>
</>
@@ -1,250 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
const NEVER_EXPIRE = 'NEVER' as const;
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`,
THREE_MONTHS: msg`3 months`,
SIX_MONTHS: msg`6 months`,
ONE_YEAR: msg`12 months`,
[NEVER_EXPIRE]: msg`Never`,
} as const;
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
tokenName: true,
expirationDate: true,
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
export type TokenCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TokenCreateDialog = ({ trigger, ...props }: TokenCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<string | null>(null);
const form = useForm<TCreateTokenFormSchema>({
resolver: zodResolver(ZCreateTokenFormSchema),
defaultValues: {
tokenName: '',
expirationDate: 'THREE_MONTHS',
},
});
const { mutateAsync: createToken } = trpc.apiToken.create.useMutation();
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
try {
const { token } = await createToken({
teamId: team.id,
tokenName,
expirationDate: expirationDate === NEVER_EXPIRE ? null : expirationDate,
});
setCreatedToken(token);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
.otherwise(() => msg`Something went wrong. Please try again later.`);
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
duration: 5000,
});
}
};
useEffect(() => {
if (open) {
form.reset();
setCreatedToken(null);
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="flex-shrink-0">
<Trans>Create token</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent
className="max-w-lg"
position="center"
onInteractOutside={(event) => {
// Prevent losing the created token by accidentally clicking outside the dialog.
if (createdToken) {
event.preventDefault();
}
}}
>
{createdToken ? (
<>
<DialogHeader>
<DialogTitle>
<Trans>Token created</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Copy your token now. For security reasons you will not be able to see it again.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Input
className="pr-12 font-mono text-sm"
aria-label={_(msg`Your new API token`)}
name="createdToken"
readOnly
value={createdToken}
/>
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
<CopyTextButton
value={createdToken}
onCopySuccess={() => toast({ title: _(msg`Token copied to clipboard`) })}
/>
</div>
</div>
<DialogFooter>
<Button type="button" onClick={() => setOpen(false)}>
<Trans>Done</Trans>
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>
<Trans>Create API token</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Use API tokens to authenticate with the Documenso API.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>A name to help you identify this token later.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Expires in</Trans>
</FormLabel>
<FormControl>
<Select value={field.value ?? NEVER_EXPIRE} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{_(date)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete token</Trans>
<Trans>Are you sure you want to delete this token?</Trans>
</DialogTitle>
<DialogDescription>
@@ -126,7 +126,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
@@ -139,18 +139,21 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" className="flex-1" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>I'm sure! Delete it</Trans>
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
@@ -106,7 +106,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
<Trans>Create webhook</Trans>
</DialogTitle>
<DialogDescription>
<Trans>On this page, you can create a new webhook.</Trans>
<Trans>Receive real-time notifications when document events occur.</Trans>
</DialogDescription>
</DialogHeader>
@@ -117,7 +117,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -503,7 +503,7 @@ export const ConfigureFieldsView = ({
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -7,7 +7,6 @@ import {
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,7 +23,6 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { BrandingPreferencesResetDialog } from '~/components/dialogs/branding-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
@@ -76,7 +74,6 @@ export function BrandingPreferencesForm({
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const [colorPickerKey, setColorPickerKey] = useState(0);
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
const initialColors = parsedColors.success ? parsedColors.data : {};
@@ -99,42 +96,6 @@ export function BrandingPreferencesForm({
const isBrandingEnabled = form.watch('brandingEnabled');
const hasResetBrandingColors =
settings.brandingColors === null ||
settings.brandingColors === undefined ||
(parsedColors.success && normalizeBrandingColors(parsedColors.data) === null);
// Only show the reset action when the saved settings actually differ from the
// defaults, so it never renders as a pointless disabled button.
const isResetToDefaultsVisible =
settings.brandingEnabled !== (canInherit ? null : false) ||
!!settings.brandingLogo ||
!!settings.brandingUrl ||
!!settings.brandingCompanyDetails ||
!!settings.brandingCss ||
!hasResetBrandingColors;
const handleResetToDefaults = async () => {
const data: TBrandingPreferencesFormSchema = {
brandingEnabled: canInherit ? null : false,
brandingLogo: null,
brandingUrl: '',
brandingCompanyDetails: '',
brandingColors: {},
brandingCss: '',
};
await onFormSubmit(data);
if (previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl('');
setColorPickerKey((key) => key + 1);
form.reset(data);
};
const getSavedLogoPreviewUrl = () => {
if (!settings.brandingLogo) {
return '';
@@ -436,7 +397,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`background-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.background}
@@ -460,7 +420,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`foreground-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.foreground}
@@ -484,7 +443,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`primary-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primary}
@@ -508,7 +466,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`primary-foreground-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
@@ -532,7 +489,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`border-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.border}
@@ -556,7 +512,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`ring-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.ring}
@@ -638,15 +593,6 @@ export function BrandingPreferencesForm({
isDirty={hasUnsavedChanges}
isSubmitting={form.formState.isSubmitting}
onReset={handleReset}
resetToDefaults={
isResetToDefaultsVisible ? (
<BrandingPreferencesResetDialog
hasAdvancedBranding={hasAdvancedBranding}
isSubmitting={form.formState.isSubmitting}
onReset={handleResetToDefaults}
/>
) : undefined
}
/>
</fieldset>
</form>
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
@@ -37,11 +37,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
@@ -93,26 +93,6 @@ export type DocumentPreferencesFormProps = {
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
return {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
};
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
@@ -133,7 +113,7 @@ export const DocumentPreferencesForm = ({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
@@ -147,33 +127,26 @@ export const DocumentPreferencesForm = ({
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const defaultValues = getDocumentPreferencesFormValues(settings);
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
const resetValues = {
...baseResetValues,
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
};
const form = useForm<TDocumentPreferencesFormSchema>({
defaultValues,
defaultValues: {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
// Parse both sides through the schema so we compare canonical representations
const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues);
const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues);
const isResetToDefaultsVisible =
!parsedCurrentValues.success ||
!parsedResetValues.success ||
JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data);
const handleResetToDefaults = async () => {
await onFormSubmit(resetValues);
form.reset(resetValues);
};
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
@@ -189,7 +162,7 @@ export const DocumentPreferencesForm = ({
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{!isPersonalLayoutMode && (
<FormField
control={form.control}
@@ -799,17 +772,6 @@ export const DocumentPreferencesForm = ({
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
resetToDefaults={
isResetToDefaultsVisible ? (
<DocumentPreferencesResetDialog
isSubmitting={form.formState.isSubmitting}
onReset={handleResetToDefaults}
showAiFeatures={isAiFeaturesConfigured}
showDocumentVisibility={!isPersonalLayoutMode}
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
) : undefined
}
/>
</fieldset>
</form>
@@ -75,7 +75,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
@@ -3,17 +3,12 @@ import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
export type FormStickySaveBarProps = {
isDirty: boolean;
isSubmitting: boolean;
onReset: () => void;
/**
* Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while
* the bar is floating so it never appears in the unsaved-changes island.
*/
resetToDefaults?: ReactNode;
};
/**
@@ -29,7 +24,7 @@ export type FormStickySaveBarProps = {
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
* the pill chrome.
*/
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => {
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
const { t } = useLingui();
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -105,8 +100,6 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefau
</AnimatePresence>
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
{!isFloating && resetToDefaults}
{isDirty && (
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
<Trans>Undo</Trans>
+1 -1
View File
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onChange',
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormSchema),
});
+254
View File
@@ -0,0 +1,254 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`,
THREE_MONTHS: msg`3 months`,
SIX_MONTHS: msg`6 months`,
ONE_YEAR: msg`12 months`,
} as const;
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
tokenName: true,
expirationDate: true,
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
type NewlyCreatedToken = {
id: number;
token: string;
};
export type ApiTokenFormProps = {
className?: string;
tokens?: Pick<ApiToken, 'id'>[];
};
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [, copy] = useCopyToClipboard();
const team = useCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
const [noExpirationDate, setNoExpirationDate] = useState(false);
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data);
},
});
const form = useForm<TCreateTokenFormSchema>({
resolver: zodResolver(ZCreateTokenFormSchema),
defaultValues: {
tokenName: '',
expirationDate: '',
},
});
const copyToken = async (token: string) => {
try {
const copied = await copy(token);
if (!copied) {
throw new Error('Unable to copy the token');
}
toast({
title: _(msg`Token copied to clipboard`),
description: _(msg`The token was copied to your clipboard.`),
});
} catch (error) {
toast({
title: _(msg`Unable to copy token`),
description: _(msg`We were unable to copy the token to your clipboard. Please try again.`),
variant: 'destructive',
});
}
};
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
try {
await createTokenMutation({
teamId: team.id,
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});
toast({
title: _(msg`Token created`),
description: _(msg`A new token was created successfully.`),
duration: 5000,
});
form.reset();
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
.otherwise(() => msg`Something went wrong. Please try again later.`);
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
duration: 5000,
});
}
};
return (
<div className={cn(className)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">
<Trans>Token name</Trans>
</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Input type="text" {...field} />
</FormControl>
</div>
<FormDescription className="text-xs italic">
<Trans>Please enter a meaningful name for your token. This will help you identify it later.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">
<Trans>Token expiration date</Trans>
</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
<SelectTrigger className="w-full">
<SelectValue placeholder={_(msg`Choose...`)} />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{_(date)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel className="mt-2 text-muted-foreground">
<Trans>Never expire</Trans>
</FormLabel>
<div className="block md:py-1.5">
<Switch
className="mt-2 bg-background"
checked={noExpirationDate}
onCheckedChange={setNoExpirationDate}
/>
</div>
</div>
</div>
<Button type="submit" className="hidden md:inline-flex" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
<div className="md:hidden">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
<AnimatePresence>
{newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: -40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 40 }}
>
<Card gradient>
<CardContent className="p-4">
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Your token was created successfully! Make sure to copy it because you won't be able to see it again!
</Trans>
</p>
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
{newlyCreatedToken.token}
</p>
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken.token)}>
<Trans>Copy token</Trans>
</Button>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -1,4 +1,3 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
@@ -10,7 +9,6 @@ import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRightIcon,
CheckCircle2Icon,
CopyIcon,
EyeIcon,
EyeOffIcon,
KeyRoundIcon,
@@ -31,8 +29,6 @@ type AdminLicenseCardProps = {
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const { t, i18n } = useLingui();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
const { license } = licenseData || {};
@@ -91,7 +87,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="mb-2 flex items-end font-medium text-foreground text-sm leading-tight">
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">
<Trans>Documenso License</Trans>
</h3>
@@ -151,24 +147,6 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
>
{isLicenseKeyVisible ? <EyeOffIcon className="h-3.5 w-3.5" /> : <EyeIcon className="h-3.5 w-3.5" />}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground"
aria-label={t`Copy license key`}
onClick={async () =>
copy(license.licenseKey).then(() => {
toast({
title: t`Copied to clipboard`,
description: t`The license key has been copied to your clipboard`,
});
})
}
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,36 +0,0 @@
import type { MessageDescriptor } from '@lingui/core';
import type { LucideIcon } from 'lucide-react';
export type PromptItem = {
id: string;
label: string | MessageDescriptor;
sublabel?: string;
path?: string;
onAction?: () => void;
icon?: LucideIcon;
initials?: string;
shortcut?: string;
isChecked?: boolean;
};
export type PromptCategory = {
id: string;
label: MessageDescriptor;
items: PromptItem[];
/**
* The number of actual results, excluding utility rows such as the
* "View all results" link.
*/
count: number;
/**
* The count shown on the category chip, or null to not show a chip at all.
* Categories which only contain hardcoded page links have no chip.
*/
chipCount: number | null;
isCapped: boolean;
/**
* Global admin categories are marked with a globe icon to distinguish them
* from the equally named personal categories.
*/
isGlobal: boolean;
};
@@ -1,23 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
export const DirectTemplateInvalidPageView = () => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
<h1 className="mt-4 font-semibold text-3xl">
<Trans>Invalid direct link template</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
</p>
</div>
</div>
);
};
@@ -4,7 +4,7 @@ import { cn } from '@documenso/ui/lib/utils';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File, TimerOff, XCircle } from 'lucide-react';
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { HTMLAttributes } from 'react';
@@ -46,12 +46,6 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
EXPIRED: {
label: msg`Expired`,
labelExtended: msg`Document expired`,
icon: TimerOff,
color: 'text-orange-500 dark:text-orange-300',
},
INBOX: {
label: msg`Inbox`,
labelExtended: msg`Document inbox`,
@@ -270,7 +270,7 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
@@ -54,7 +54,6 @@ import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
@@ -239,8 +238,6 @@ export const EnvelopeEditorFieldsPage = () => {
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
@@ -1,55 +0,0 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
className?: string;
};
/**
* Warns that a direct link template cannot be used because one or more signers
* are missing a signature field.
*/
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
className,
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
const signersMissingSignatureFields = useMemo(() => {
if (!isTemplate || !envelope.directLink?.enabled) {
return [];
}
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
if (signersMissingSignatureFields.length === 0) {
return null;
}
return (
<Alert
variant="destructive"
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
>
<AlertTitle>
<Trans>Invalid direct link template</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Recipients cannot use this direct link template because the following signers are missing a signature field
</Trans>
<ul className="list-disc pl-5">
{signersMissingSignatureFields.map((recipient, i) => (
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
};
@@ -22,7 +22,6 @@ import { match } from 'ts-pattern';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
export const EnvelopeEditorPreviewPage = () => {
@@ -229,8 +228,6 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
@@ -7,12 +7,7 @@ import { useOptionalSession } from '@documenso/lib/client-only/providers/session
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import {
isAssistantLastSigner,
isCcRecipient,
normalizeRecipientSigningOrders,
canRecipientBeModified as utilCanRecipientBeModified,
} from '@documenso/lib/utils/recipients';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
@@ -161,12 +156,16 @@ export const EnvelopeEditorRecipientForm = () => {
}, [watchedSigners]);
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
return signers
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
.map((signer, index) => ({ ...signer, signingOrder: index + 1 }));
};
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
const { fields: signers, remove: removeSigner } = useFieldArray({
const {
append: appendSigner,
fields: signers,
remove: removeSigner,
} = useFieldArray({
control,
name: 'signers',
keyName: 'nativeId',
@@ -209,31 +208,14 @@ export const EnvelopeEditorRecipientForm = () => {
return utilCanRecipientBeModified(recipient, fields);
};
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (shouldFocus) {
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
if (signerIndex !== -1) {
requestAnimationFrame(() => form.setFocus(`signers.${signerIndex}.email`));
}
}
};
const onAddSigner = () => {
appendNormalizedSigner({
appendSigner({
formId: nanoid(12),
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: activeRecipientCount + 1,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
@@ -341,16 +323,18 @@ export const EnvelopeEditorRecipientForm = () => {
form.setFocus(`signers.${emptySignerIndex}.email`);
} else {
appendNormalizedSigner(
appendSigner(
{
formId: nanoid(12),
name: currentEditorName ?? '',
email: currentEditorEmail ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: activeRecipientCount + 1,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
},
{
shouldFocus: true,
},
true,
);
void form.trigger('signers');
@@ -385,14 +369,18 @@ export const EnvelopeEditorRecipientForm = () => {
items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders(items);
const updatedSigners = items.map((signer, index) => ({
...signer,
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : index + 1,
}));
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (isAssistantLastSigner(updatedSigners)) {
const lastSigner = updatedSigners[updatedSigners.length - 1];
if (lastSigner.role === RecipientRole.ASSISTANT) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
@@ -423,19 +411,18 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const updatedSigners = normalizeSigningOrders(
currentSigners.map((signer, idx) => ({
...signer,
role: idx === index ? role : signer.role,
})),
);
const updatedSigners = currentSigners.map((signer, idx) => ({
...signer,
role: idx === index ? role : signer.role,
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : idx + 1,
}));
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
@@ -460,30 +447,22 @@ export const EnvelopeEditorRecipientForm = () => {
const currentSigners = form.getValues('signers');
const signer = currentSigners[index];
if (isCcRecipient(signer)) {
return;
}
// Remove signer from current position and insert at new position
const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
remainingSigners.splice(newPosition, 0, signer);
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
if (currentSigningOrderIndex === -1) {
return;
}
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
nonCcSigners.splice(newPosition, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
const updatedSigners = remainingSigners.map((s, idx) => ({
...s,
signingOrder: !canRecipientBeModified(s.id) ? s.signingOrder : idx + 1,
}));
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
@@ -497,12 +476,10 @@ export const EnvelopeEditorRecipientForm = () => {
setShowSigningOrderConfirmation(false);
const currentSigners = form.getValues('signers');
const updatedSigners = normalizeSigningOrders(
currentSigners.map((signer) => ({
...signer,
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
})),
);
const updatedSigners = currentSigners.map((signer) => ({
...signer,
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
}));
form.setValue('signers', updatedSigners, {
shouldValidate: true,
@@ -819,7 +796,6 @@ export const EnvelopeEditorRecipientForm = () => {
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
isCcRecipient(signer) ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
@@ -843,11 +819,7 @@ export const EnvelopeEditorRecipientForm = () => {
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
@@ -863,7 +835,7 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl>
<Input
type="number"
max={activeRecipientCount}
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
@@ -1004,6 +976,7 @@ export const EnvelopeEditorRecipientForm = () => {
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
@@ -26,7 +26,6 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@@ -450,9 +449,6 @@ export const EnvelopeEditorUploadPage = () => {
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@@ -174,7 +174,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
{type === EnvelopeType.DOCUMENT ? <Trans>Upload Document</Trans> : <Trans>Upload Template</Trans>}
</h2>
<p className="mt-4 text-base text-muted-foreground">
<p className="mt-4 text-md text-muted-foreground">
<Trans>Drag and drop your document here</Trans>
</p>
@@ -1,137 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { BracesIcon, CreditCardIcon, Globe2Icon, Lock, Settings2Icon, User, Users, WebhookIcon } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button variant="ghost" className={cn('w-full justify-start')}>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Trans>Document</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<Trans>Branding</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<Trans>Email</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -1,144 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
Lock,
MailIcon,
PaletteIcon,
Settings2Icon,
User,
Users,
WebhookIcon,
} from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Document Preferences</Trans>
</Button>
</Link>
<Link to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<PaletteIcon className="mr-2 h-5 w-5" />
<Trans>Branding Preferences</Trans>
</Button>
</Link>
<Link to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Preferences</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -0,0 +1,62 @@
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
export type SettingsNavRoute = {
path: string;
label: string;
icon?: LucideIcon;
/**
* Renders the route as a non-interactive group label, only visible on desktop.
*/
isSectionLabel?: boolean;
/**
* Indents the route under the preceding section label on desktop.
*/
isSubNav?: boolean;
/**
* Only mark the route as active on an exact path match.
*/
end?: boolean;
};
export type SettingsNavProps = {
routes: SettingsNavRoute[];
className?: string;
};
export const SettingsNav = ({ routes, className }: SettingsNavProps) => {
return (
<nav
className={cn(
'flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:w-full md:flex-col md:items-start md:gap-y-2',
className,
)}
>
{routes.map((route) =>
route.isSectionLabel ? (
<div
key={`${route.path}-${route.label}`}
className="flex h-10 w-full items-center px-4 font-medium text-sm max-md:hidden"
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
{route.label}
</div>
) : (
<NavLink
key={`${route.path}-${route.label}`}
to={route.path}
end={route.end}
className={cn('group justify-start md:w-full', route.isSubNav && 'md:pl-8')}
>
<Button variant="ghost" className="w-full justify-start group-aria-[current]:bg-secondary">
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
{route.label}
</Button>
</NavLink>
),
)}
</nav>
);
};
@@ -1,144 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
import type { TAdminSearchResultType } from '@documenso/trpc/server/admin-router/admin-search.types';
import { ADMIN_SEARCH_MAX_QUERY_LENGTH } from '@documenso/trpc/server/admin-router/admin-search.types';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { keepPreviousData } from '@tanstack/react-query';
import type { LucideIcon } from 'lucide-react';
import { ArrowRightIcon, Building2Icon, CreditCardIcon, FileTextIcon, UserIcon, UsersIcon } from 'lucide-react';
import { useMemo } from 'react';
import type { PromptCategory, PromptItem } from './app-command-menu.types';
/**
* The maximum number of results the admin search returns per resource type.
*/
const ADMIN_SEARCH_RESULTS_CAP = 5;
const ADMIN_GROUP_LABELS: Record<TAdminSearchResultType, MessageDescriptor> = {
document: msg`Documents`,
user: msg`Users`,
organisation: msg`Organisations`,
team: msg`Teams`,
recipient: msg`Recipients`,
subscription: msg`Subscriptions`,
};
const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
document: FileTextIcon,
user: UserIcon,
organisation: Building2Icon,
team: UsersIcon,
recipient: UserIcon,
subscription: CreditCardIcon,
};
/**
* Admin list pages which support prefilling their search from the URL, used
* for the "View all results" links on capped groups. Teams, recipients and
* subscriptions have no admin list pages.
*/
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
};
export type UseAdminSearchCategoriesOptions = {
/**
* The trimmed, debounced search query.
*/
query: string;
open: boolean;
};
/**
* The isolated admin portion of the command prompt: searches every admin
* resource and maps the results to prompt categories marked as global.
*
* Returns no categories and never queries for non admin users. The admin
* search endpoint is additionally guarded server side by the admin procedure.
*/
export const useAdminSearchCategories = ({ query, open }: UseAdminSearchCategoriesOptions) => {
const { user } = useSession();
const isUserAdmin = isAdmin(user);
// Admin searches hit every resource table, so require a longer query unless
// it is a number, which could be a resource ID of any length. Queries over
// the endpoint's length limit are skipped entirely instead of being sent
// and rejected.
const hasValidAdminSearch =
isUserAdmin && query.length <= ADMIN_SEARCH_MAX_QUERY_LENGTH && (query.length > 3 || /^\d+$/.test(query));
const {
data: adminSearchData,
isFetching,
isError,
} = trpcReact.admin.search.useQuery(
{
query,
},
{
enabled: open && hasValidAdminSearch,
placeholderData: keepPreviousData,
// Retyping is the retry in a search-as-you-type flow: fail fast so the
// prompt can surface an honest error state instead of retrying.
retry: false,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const categories = useMemo((): PromptCategory[] => {
if (!hasValidAdminSearch || !adminSearchData) {
return [];
}
return adminSearchData.groups.map((group) => {
const isCapped = group.results.length >= ADMIN_SEARCH_RESULTS_CAP;
const buildListPath = ADMIN_GROUP_LIST_PATHS[group.type];
const items: PromptItem[] = group.results.map((result) => ({
id: `admin-${group.type}-${result.value}`,
label: result.label,
sublabel: result.sublabel,
path: result.path,
icon: ADMIN_GROUP_ICONS[group.type],
initials: group.type === 'user' || group.type === 'recipient' ? extractInitials(result.label) : undefined,
}));
// Capped groups link to the full admin list page with the search
// prefilled so the cap is never a dead end.
if (isCapped && buildListPath) {
items.push({
id: `admin-${group.type}-view-all`,
label: msg`View all results`,
path: buildListPath(query),
icon: ArrowRightIcon,
});
}
return {
id: `admin-${group.type}`,
label: ADMIN_GROUP_LABELS[group.type],
items,
count: group.results.length,
chipCount: group.results.length,
isCapped,
isGlobal: true,
};
});
}, [hasValidAdminSearch, adminSearchData, query]);
return {
isUserAdmin,
categories,
isFetching,
isError,
};
};
@@ -1,7 +1,7 @@
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2, TimerOff, XCircle } from 'lucide-react';
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
@@ -29,16 +29,6 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
icon: XCircle,
}))
.with(ExtendedDocumentStatus.REJECTED, () => ({
title: msg`No rejected documents`,
message: msg`There are no rejected documents. Documents that a recipient declines to sign will appear here.`,
icon: XCircle,
}))
.with(ExtendedDocumentStatus.EXPIRED, () => ({
title: msg`No expired documents`,
message: msg`There are no documents with expired signing links. You can redistribute a document to renew its expiration.`,
icon: TimerOff,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: msg`We're all empty`,
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
@@ -102,7 +102,9 @@ export const OrganisationEmailDomainsDataTable = () => {
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>Manage</Link>
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>
<Trans>Manage</Trans>
</Link>
</Button>
<OrganisationEmailDomainDeleteDialog
+15 -20
View File
@@ -3,32 +3,27 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react';
import { StrictMode, startTransition } from 'react';
import { StrictMode, startTransition, useEffect } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import './utils/polyfills/promise-with-resolvers';
/**
* Initialised imperatively (not as a component inside `hydrateRoot`) because
* rendering extra client-only siblings changes the React tree structure
* relative to the server render in `entry.server.tsx`. That shifts every
* `useId` value (used by Radix for `id`/`htmlFor`/`aria-*`), causing hydration
* mismatches which can abort hydration entirely when the user interacts with
* the page early, leaving dead event handlers (broken dropdowns, native form
* submits).
*/
function initPosthog() {
function PosthogInit() {
const postHogConfig = extractPostHogConfig();
if (postHogConfig) {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
useEffect(() => {
if (postHogConfig) {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
});
});
}
}
}, []);
return null;
}
async function main() {
@@ -43,11 +38,11 @@ async function main() {
<I18nProvider i18n={i18n}>
<HydratedRouter />
</I18nProvider>
<PosthogInit />
</StrictMode>,
);
});
void initPosthog();
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
+2 -10
View File
@@ -119,11 +119,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
return (
// `suppressHydrationWarning` because `remix-themes` intentionally mutates
// `data-theme`/`class` on <html> before hydration (PreventFlashOnWrongTheme),
// so the server-rendered attributes never match the client render when the
// theme is resolved from the system preference. Attribute-only, one level deep.
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
<head>
<meta charSet="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@@ -177,11 +173,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<script
nonce={nonce(cspNonce)}
dangerouslySetInnerHTML={{
// `__webpack_nonce__` is read by `get-nonce` (used by
// react-remove-scroll / react-style-singleton inside Radix menus and
// dialogs) to stamp runtime-injected <style> elements. Without it the
// strict `style-src-elem` CSP blocks the scroll-lock styles.
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}; window.__webpack_nonce__ = ${JSON.stringify(cspNonce ?? '')}`,
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
}}
/>
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -13,11 +12,12 @@ import {
Settings2Icon,
ShieldCheckIcon,
Users2Icon,
UsersIcon,
} from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
import { Link, NavLink, Outlet } from 'react-router';
import { Link, Outlet } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsNav, type SettingsNavRoute } from '~/components/general/settings-nav';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@@ -30,7 +30,7 @@ export default function SettingsLayout() {
const isBillingEnabled = IS_BILLING_ENABLED();
const organisation = useCurrentOrganisation();
const organisationSettingRoutes = [
const organisationSettingRoutes: SettingsNavRoute[] = [
{
path: `/o/${organisation.url}/settings/general`,
label: t`General`,
@@ -40,7 +40,7 @@ export default function SettingsLayout() {
path: `/o/${organisation.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
hideHighlight: true,
isSectionLabel: true,
},
{
path: `/o/${organisation.url}/settings/document`,
@@ -65,7 +65,7 @@ export default function SettingsLayout() {
{
path: `/o/${organisation.url}/settings/teams`,
label: t`Teams`,
icon: FaUsers,
icon: UsersIcon,
},
{
path: `/o/${organisation.url}/settings/members`,
@@ -139,32 +139,9 @@ export default function SettingsLayout() {
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
{/* Navigation */}
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{organisationSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.hideHighlight,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<SettingsNav routes={organisationSettingRoutes} className="col-span-12 mb-8 md:col-span-3 md:mb-0" />
<div className="col-span-12 md:col-span-9">
<div className="col-span-12 max-w-3xl md:col-span-9">
<Outlet />
</div>
</div>
@@ -89,9 +89,9 @@ export default function TeamsSettingBillingPage() {
return (
<div>
<div className="flex flex-row items-end justify-between">
<div className="flex flex-row items-center justify-between">
<div>
<h3 className="font-semibold text-2xl">
<h3 className="font-medium text-lg">
<Trans>Billing</Trans>
</h3>
@@ -123,13 +123,13 @@ export default function OrganisationSettingsBrandingPage() {
const settingsHeaderText = t`Branding Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general branding preferences.`
? t`Set the default branding for documents you send.`
: team
? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
? t`Set the default branding for documents sent by your team.`
: t`Set the default branding for your organisation. Teams inherit these settings by default.`;
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
@@ -88,7 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership,
delegateDocumentOwnership: delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
@@ -120,11 +120,11 @@ export default function OrganisationSettingsDocumentPage() {
const settingsHeaderText = t`Document Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general document preferences.`
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
? t`Set your default document settings.`
: t`Set the default document settings for your organisation. Teams inherit these settings by default.`;
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
<section>
@@ -132,7 +132,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
return (
<div>
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage the email addresses for this domain.`}>
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
</SettingsHeader>
@@ -33,7 +33,7 @@ export default function OrganisationSettingsEmailDomains() {
return (
<div>
<SettingsHeader title={t`Email Domains`} subtitle={t`Here you can add email domains to your organisation.`}>
<SettingsHeader title={t`Email Domains`} subtitle={t`Add and verify email domains for your organisation.`}>
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
</SettingsHeader>
@@ -1,4 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -13,12 +15,16 @@ export function meta() {
return appMetaTags(msg`Email Preferences`);
}
export default function OrganisationSettingsGeneral() {
export default function OrganisationSettingsEmailPage() {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
@@ -59,8 +65,15 @@ export default function OrganisationSettingsGeneral() {
}
return (
<div className="max-w-2xl">
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
<div>
<SettingsHeader
title={t`Email Preferences`}
subtitle={
isPersonalLayoutMode
? t`Manage your default email settings.`
: t`Manage the default email settings for your organisation. Teams inherit these settings by default.`
}
/>
<section>
<EmailPreferencesForm
@@ -21,8 +21,8 @@ export default function OrganisationSettingsGeneral() {
const organisation = useCurrentOrganisation();
return (
<div className="max-w-2xl">
<SettingsHeader title={_(msg`General`)} subtitle={_(msg`Here you can edit your organisation details.`)} />
<div>
<SettingsHeader title={_(msg`General`)} subtitle={_(msg`Update your organisation's details.`)} />
<div className="space-y-8">
<AvatarImageForm organisation={organisation} />
@@ -94,7 +94,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
return (
<div>
<SettingsHeader title={t`Organisation Group Settings`} subtitle={t`Manage your organisation group settings.`}>
<SettingsHeader title={t`Group Settings`} subtitle={t`Update the group's name, role and members.`}>
<OrganisationGroupDeleteDialog
organisationGroupId={groupId}
organisationGroupName={group.name || ''}
@@ -10,7 +10,7 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader
title={t`Custom Organisation Groups`}
title={t`Organisation Groups`}
subtitle={t`Manage the custom groups of members for your organisation.`}
>
<OrganisationGroupCreateDialog />
@@ -46,7 +46,10 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader title={_(msg`Organisation Members`)} subtitle={_(msg`Manage the members or invite new members.`)}>
<SettingsHeader
title={_(msg`Organisation Members`)}
subtitle={_(msg`Manage the members and invitations for your organisation.`)}
>
<OrganisationMemberInviteDialog />
</SettingsHeader>
@@ -73,7 +73,7 @@ export default function OrganisationSettingSSOLoginPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
@@ -1,9 +1,21 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
LockIcon,
Settings2Icon,
UserIcon,
UsersIcon,
WebhookIcon,
} from 'lucide-react';
import { Outlet } from 'react-router';
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
import { SettingsNav, type SettingsNavRoute } from '~/components/general/settings-nav';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@@ -11,6 +23,83 @@ export function meta() {
}
export default function SettingsLayout() {
const { t } = useLingui();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
const settingRoutes: SettingsNavRoute[] = [
{
path: '/settings/profile',
label: t`Profile`,
icon: UserIcon,
},
...(isPersonalLayoutMode
? [
{
path: '/settings/document',
label: t`Preferences`,
icon: Settings2Icon,
isSectionLabel: true,
},
{
path: '/settings/document',
label: t`Document`,
isSubNav: true,
},
{
path: '/settings/branding',
label: t`Branding`,
isSubNav: true,
},
{
path: '/settings/email',
label: t`Email`,
isSubNav: true,
},
{
path: '/settings/public-profile',
label: t`Public Profile`,
icon: Globe2Icon,
},
{
path: '/settings/tokens',
label: t`API Tokens`,
icon: BracesIcon,
},
{
path: '/settings/webhooks',
label: t`Webhooks`,
icon: WebhookIcon,
},
]
: []),
{
path: '/settings/organisations',
label: t`Organisations`,
icon: UsersIcon,
},
...(IS_BILLING_ENABLED() && hasManageableBillingOrgs
? [
{
path: isPersonalLayoutMode ? '/settings/billing-personal' : '/settings/billing',
label: t`Billing`,
icon: CreditCardIcon,
},
]
: []),
{
path: '/settings/security',
label: t`Security`,
icon: LockIcon,
},
];
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="font-semibold text-4xl">
@@ -18,10 +107,9 @@ export default function SettingsLayout() {
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
<SettingsNav routes={settingRoutes} className="col-span-12 mb-8 md:col-span-3 md:mb-0" />
<div className="col-span-12 md:col-span-9">
<div className="col-span-12 max-w-3xl md:col-span-9">
<Outlet />
</div>
</div>
@@ -11,10 +11,7 @@ export default function TeamsSettingsPage() {
return (
<div>
<SettingsHeader
title={_(msg`Organisations`)}
subtitle={_(msg`Manage all organisations you are currently associated with.`)}
>
<SettingsHeader title={_(msg`Organisations`)} subtitle={_(msg`Manage the organisations you belong to.`)}>
<OrganisationCreateDialog />
</SettingsHeader>
@@ -27,14 +27,14 @@ export default function SettingsProfile() {
return (
<div>
<SettingsHeader title={_(msg`Profile`)} subtitle={_(msg`Here you can edit your personal details.`)} />
<SettingsHeader title={_(msg`Profile`)} subtitle={_(msg`Update your personal details.`)} />
<AvatarImageForm className="mb-8 max-w-xl" />
<ProfileForm className="mb-8 max-w-xl" />
<AvatarImageForm className="mb-8" />
<ProfileForm className="mb-8" />
<hr className="my-4 max-w-xl" />
<hr className="my-4" />
<div className="max-w-xl space-y-8">
<div className="space-y-8">
<AnimatePresence>
{(!isPersonalLayoutMode || user.email !== teamEmail?.email) && teamEmail && (
<AnimateGenericFadeInOut>
@@ -62,10 +62,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
return (
<div>
<SettingsHeader
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
<SettingsHeader title={_(msg`Security`)} subtitle={_(msg`Manage your password and security settings.`)} />
{hasEmailPasswordAccount && (
<>
<PasswordForm user={user} />
@@ -15,7 +15,7 @@ export default function SettingsSecurityActivity() {
return (
<div>
<SettingsHeader
title={_(msg`Security activity`)}
title={_(msg`Security Activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
/>
@@ -94,7 +94,7 @@ export default function SettingsSecuritySessions() {
return (
<div>
<SettingsHeader title={t`Active sessions`} subtitle={t`View and manage all active sessions for your account.`}>
<SettingsHeader title={t`Active Sessions`} subtitle={t`View and manage all active sessions for your account.`}>
<SessionLogoutAllDialog onSuccess={refetch} disabled={results.length === 1 || isLoading} />
</SettingsHeader>
@@ -76,7 +76,6 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.EXPIRED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
@@ -158,8 +157,6 @@ export default function DocumentsPage() {
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.EXPIRED,
ExtendedDocumentStatus.ALL,
]
.filter((value) => {
@@ -5,7 +5,7 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { CheckCircle2, Clock } from 'lucide-react';
import { match, P } from 'ts-pattern';
@@ -33,13 +33,15 @@ export async function loader({ request, params }: Route.LoaderArgs) {
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
const { team } = loaderData;
const currentTeam = useCurrentTeam();
return (
<div className="max-w-2xl">
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
<div>
<SettingsHeader title={t`General`} subtitle={t`Update your team's details.`} />
<AvatarImageForm team={currentTeam} className="mb-8" />
@@ -1,14 +1,14 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { BracesIcon, Globe2Icon, GroupIcon, Settings2Icon, SettingsIcon, Users2Icon, WebhookIcon } from 'lucide-react';
import { Link, NavLink, Outlet, redirect } from 'react-router';
import { Link, Outlet, redirect } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsNav, type SettingsNavRoute } from '~/components/general/settings-nav';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -40,17 +40,18 @@ export default function TeamsSettingsLayout() {
const team = useCurrentTeam();
const teamSettingRoutes = [
const teamSettingRoutes: SettingsNavRoute[] = [
{
path: `/t/${team.url}/settings`,
label: t`General`,
icon: SettingsIcon,
end: true,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
isSubNavParent: true,
isSectionLabel: true,
},
{
path: `/t/${team.url}/settings/document`,
@@ -124,31 +125,9 @@ export default function TeamsSettingsLayout() {
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{teamSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.isSubNavParent,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<SettingsNav routes={teamSettingRoutes} className="col-span-12 mb-8 md:col-span-3 md:mb-0" />
<div className="col-span-12 md:col-span-9">
<div className="col-span-12 max-w-3xl md:col-span-9">
<Outlet />
</div>
</div>
@@ -115,10 +115,10 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Branding Preferences`}
subtitle={t`Here you can set preferences and defaults for branding.`}
subtitle={t`Set the default branding for documents sent by your team.`}
/>
{canConfigureBranding ? (
@@ -82,7 +82,7 @@ export default function TeamsSettingsPage() {
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}),
delegateDocumentOwnership,
delegateDocumentOwnership: delegateDocumentOwnership,
},
});
@@ -110,11 +110,8 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Document Preferences`}
subtitle={t`Here you can set preferences and defaults for your team.`}
/>
<div>
<SettingsHeader title={t`Document Preferences`} subtitle={t`Set the default document settings for your team.`} />
<section>
<DocumentPreferencesForm
@@ -10,10 +10,10 @@ import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Settings`);
return appMetaTags(msg`Email Preferences`);
}
export default function TeamEmailSettingsGeneral() {
export default function TeamEmailSettingsPage() {
const { t } = useLingui();
const { toast } = useToast();
@@ -59,8 +59,8 @@ export default function TeamEmailSettingsGeneral() {
}
return (
<div className="max-w-2xl">
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
<div>
<SettingsHeader title={t`Email Preferences`} subtitle={t`Manage the default email settings for your team.`} />
<section>
<EmailPreferencesForm
@@ -128,11 +128,8 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
}, [profile.enabled]);
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Public Profile`}
subtitle={t`You can choose to enable or disable the profile for public view.`}
>
<div>
<SettingsHeader title={t`Public Profile`} subtitle={t`Manage your public profile and the templates shown on it.`}>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<div
@@ -190,7 +187,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
<div className="mt-4">
<SettingsHeader
title={t`Templates`}
subtitle={t`Show templates in your public profile for your audience to sign and get started quickly`}
subtitle={t`Show templates in your public profile for your audience to sign and get started quickly.`}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
@@ -1,18 +1,14 @@
import { trpc } from '@documenso/trpc/react';
import type { TGetApiTokensResponse } from '@documenso/trpc/server/api-token-router/get-api-tokens.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { useMemo } from 'react';
import { DateTime } from 'luxon';
import { TokenCreateDialog } from '~/components/dialogs/token-create-dialog';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -22,65 +18,12 @@ export function meta() {
}
export default function ApiTokensPage() {
const { t, i18n } = useLingui();
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const team = useOptionalCurrentTeam();
const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN;
const {
data: tokens,
isLoading,
isError,
} = trpc.apiToken.getMany.useQuery(undefined, {
enabled: !isUnauthorized,
});
const columns = useMemo(() => {
return [
{
header: t`Name`,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span>,
},
{
header: t`Created`,
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: t`Expires`,
cell: ({ row }) => {
if (!row.original.expires) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
if (row.original.expires < new Date()) {
return (
<Badge variant="destructive" size="small">
<Trans>Expired</Trans>
</Badge>
);
}
return i18n.date(row.original.expires);
},
},
{
header: t`Actions`,
cell: ({ row }) => (
<TokenDeleteDialog token={row.original}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
),
},
] satisfies DataTableColumnDef<TGetApiTokensResponse[number]>[];
}, []);
return (
<div>
<SettingsHeader
@@ -94,16 +37,14 @@ export default function ApiTokensPage() {
target="_blank"
rel="noopener"
>
documentation
Documentation
</a>{' '}
for more information.
</Trans>
}
>
{!isUnauthorized && <TokenCreateDialog />}
</SettingsHeader>
/>
{isUnauthorized ? (
{team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? (
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
<div>
<AlertTitle>
@@ -115,43 +56,58 @@ export default function ApiTokensPage() {
</div>
</Alert>
) : (
<DataTable
columns={columns}
data={tokens ?? []}
perPage={0}
currentPage={0}
totalPages={0}
error={{
enable: isError,
}}
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
<Trans>You have no API tokens yet. Your tokens will be shown here once you create them.</Trans>
<>
<ApiTokenForm tokens={tokens} />
<hr className="mt-8 mb-4" />
<h4 className="font-medium text-xl">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="mt-2 text-muted-foreground text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
/>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="mt-2 text-muted-foreground text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
@@ -88,10 +88,7 @@ export default function WebhookPage() {
return (
<div>
<SettingsHeader
title={t`Webhooks`}
subtitle={t`On this page, you can create new Webhooks and manage the existing ones.`}
>
<SettingsHeader title={t`Webhooks`} subtitle={t`Create and manage webhooks for document events.`}>
<WebhookCreateDialog />
</SettingsHeader>
{isLoading && (

Some files were not shown because too many files have changed in this diff Show More