Merge branch 'main' into feat/allow-admin-recipient-search

This commit is contained in:
David Nguyen
2026-08-20 15:05:45 +10:00
committed by GitHub
37 changed files with 3994 additions and 4543 deletions
+2
View File
@@ -15,6 +15,7 @@ jobs:
build_app: build_app:
name: Build App name: Build App
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -32,6 +33,7 @@ jobs:
build_docker: build_docker:
name: Build Docker Image name: Build Docker Image
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+1
View File
@@ -11,6 +11,7 @@ jobs:
analyze: analyze:
name: Analyze name: Analyze
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
permissions: permissions:
actions: read actions: read
contents: read contents: read
+1
View File
@@ -8,6 +8,7 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- name: Checkout code - name: Checkout code
+1
View File
@@ -7,6 +7,7 @@ on:
jobs: jobs:
label-when-assigned: label-when-assigned:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- name: Label issue - name: Label issue
uses: actions/github-script@v6 uses: actions/github-script@v6
+1
View File
@@ -7,6 +7,7 @@ on:
jobs: jobs:
label_issues: label_issues:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
permissions: permissions:
issues: write issues: write
steps: steps:
+1
View File
@@ -13,6 +13,7 @@ jobs:
contents: read contents: read
pull-requests: write pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/labeler@v4 - uses: actions/labeler@v4
with: with:
+2
View File
@@ -14,6 +14,7 @@ jobs:
build_and_publish_platform_containers: build_and_publish_platform_containers:
name: Build and publish platform containers name: Build and publish platform containers
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -78,6 +79,7 @@ jobs:
create_and_publish_manifest: create_and_publish_manifest:
name: Create and publish manifest name: Create and publish manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
needs: build_and_publish_platform_containers needs: build_and_publish_platform_containers
steps: steps:
- name: Checkout - name: Checkout
@@ -15,6 +15,7 @@ jobs:
validate-pr: validate-pr:
name: Validate PR title name: Validate PR title
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: amannn/action-semantic-pull-request@v5 - uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title id: lint_pr_title
+1
View File
@@ -7,6 +7,7 @@ on:
jobs: jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
@@ -18,6 +18,7 @@ jobs:
pull_translations: pull_translations:
name: Force pull translations name: Force pull translations
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
environment: Translations environment: Translations
permissions: permissions:
contents: write contents: write
+1
View File
@@ -16,6 +16,7 @@ jobs:
pull_translations: pull_translations:
name: Pull translations name: Pull translations
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
environment: Translations environment: Translations
permissions: permissions:
contents: write contents: write
@@ -14,6 +14,7 @@ jobs:
extract_translations: extract_translations:
name: Extract and upload translations name: Extract and upload translations
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
environment: Translations environment: Translations
permissions: permissions:
contents: write contents: write
@@ -28,35 +28,62 @@ Each document contains one or more PDF files, a list of recipients, and the fiel
A document object contains the following properties: A document object contains the following properties:
| Property | Type | Description | | Property | Type | Description |
| --------------- | -------------- | -------------------------------------------------------------- | | ------------------- | -------------- | -------------------------------------------------------------------------------------------- |
| `id` | string | Unique identifier (e.g., `envelope_abc123`) | | `id` | string | Unique identifier (e.g., `envelope_abc123`) |
| `type` | string | `DOCUMENT` or `TEMPLATE` | | `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` | | `internalVersion` | number | Internal envelope schema version |
| `title` | string | Document title | | `type` | string | `DOCUMENT` or `TEMPLATE` |
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` | | `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` | | `title` | string | Document title |
| `externalId` | string \| null | Your custom identifier for the document | | `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
| `createdAt` | string | ISO 8601 timestamp | | `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
| `updatedAt` | string | ISO 8601 timestamp | | `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) |
| `completedAt` | string \| null | Timestamp when all recipients completed signing | | `externalId` | string \| null | Your custom identifier for the document |
| `deletedAt` | string \| null | Timestamp if soft-deleted | | `userId` | number | ID of the user who owns the document |
| `recipients` | array | List of recipients and their signing status | | `teamId` | number | ID of the team the document belongs to |
| `fields` | array | Signature and form fields on the document | | `folderId` | string \| null | ID of the folder containing the document |
| `envelopeItems` | array | PDF files attached to the document | | `templateId` | number \| null | Legacy ID of the template this document was created from |
| `documentMeta` | object | Email settings, redirect URL, signing options | | `authOptions` | object \| null | Access and action authentication requirements |
| `formValues` | object \| null | Pre-filled form values |
| `publicTitle` | string | Public title shown on profile and direct-link pages |
| `publicDescription` | string | Public description shown on profile and direct-link pages |
| `createdAt` | string | ISO 8601 timestamp |
| `updatedAt` | string | ISO 8601 timestamp |
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
| `deletedAt` | string \| null | Timestamp if soft-deleted |
| `recipients` | array | List of recipients and their signing status |
| `fields` | array | Signature and form fields on the document |
| `envelopeItems` | array | PDF files attached to the document |
| `directLink` | object \| null | Direct-link signing configuration (`id`, `token`, `enabled`, `directTemplateRecipientId`) |
| `team` | object | Owning team (`id`, `url`) |
| `user` | object | Document owner (`id`, `name`, `email`) |
| `documentMeta` | object | Email settings, redirect URL, signing options |
Documents created through the API have `source: "DOCUMENT"` — there is no separate `API` source value. To tag documents created by your integration, set `externalId` when creating them.
### Example Document Object ### Example Document Object
```json ```json
{ {
"id": "envelope_abc123xyz", "id": "envelope_abc123xyz",
"secondaryId": "document_123",
"internalVersion": 2,
"type": "DOCUMENT", "type": "DOCUMENT",
"status": "PENDING", "status": "PENDING",
"source": "API", "source": "DOCUMENT",
"visibility": "EVERYONE", "visibility": "EVERYONE",
"templateType": "PRIVATE",
"title": "Service Agreement", "title": "Service Agreement",
"externalId": "contract-2025-001", "externalId": "contract-2025-001",
"userId": 1,
"teamId": 1,
"folderId": null,
"templateId": null,
"authOptions": null,
"formValues": null,
"publicTitle": "",
"publicDescription": "",
"createdAt": "2025-01-15T10:30:00.000Z", "createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:35:00.000Z", "updatedAt": "2025-01-15T10:35:00.000Z",
"completedAt": null, "completedAt": null,
@@ -73,23 +100,41 @@ A document object contains the following properties:
], ],
"fields": [ "fields": [
{ {
"id": "field_123", "id": 123,
"secondaryId": "field_abc123",
"type": "SIGNATURE", "type": "SIGNATURE",
"recipientId": 1,
"envelopeId": "envelope_abc123xyz",
"envelopeItemId": "envelope_item_xyz",
"page": 1, "page": 1,
"positionX": 10, "positionX": "10",
"positionY": 80, "positionY": "80",
"width": 30, "width": "30",
"height": 5, "height": "5",
"recipientId": 1 "customText": "",
"inserted": false,
"fieldMeta": null
} }
], ],
"envelopeItems": [ "envelopeItems": [
{ {
"id": "envelope_item_xyz", "id": "envelope_item_xyz",
"envelopeId": "envelope_abc123xyz",
"documentDataId": "doc_data_abc123",
"title": "contract.pdf", "title": "contract.pdf",
"order": 1 "order": 1
} }
], ],
"directLink": null,
"team": {
"id": 1,
"url": "your-team"
},
"user": {
"id": 1,
"name": "Jane Smith",
"email": "jane@example.com"
},
"documentMeta": { "documentMeta": {
"subject": "Please sign this document", "subject": "Please sign this document",
"message": "Hi, please review and sign this agreement.", "message": "Hi, please review and sign this agreement.",
@@ -99,6 +144,8 @@ 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 ## List Documents
Retrieve a paginated list of documents. Retrieve a paginated list of documents.
@@ -114,7 +161,7 @@ GET /envelope
| `page` | integer | Page number (default: 1) | | `page` | integer | Page number (default: 1) |
| `perPage` | integer | Results per page (default: 10, max: 100) | | `perPage` | integer | Results per page (default: 10, max: 100) |
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` | | `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | | `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
| `source` | string | Filter by creation source | | `source` | string | Filter by creation source |
| `folderId` | string | Filter by folder ID | | `folderId` | string | Filter by folder ID |
| `orderByColumn` | string | Sort field (only `createdAt` supported) | | `orderByColumn` | string | Sort field (only `createdAt` supported) |
@@ -154,8 +201,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
}, },
}); });
const { data, pagination } = await response.json(); const { data, count } = await response.json();
console.log(`Found ${pagination.totalItems} documents`); console.log(`Found ${count} documents`);
// Filter by status // Filter by status
const pendingResponse = await fetch( const pendingResponse = await fetch(
@@ -197,12 +244,10 @@ const pendingDocs = await pendingResponse.json();
] ]
} }
], ],
"pagination": { "count": 42,
"page": 1, "currentPage": 1,
"perPage": 10, "perPage": 10,
"totalPages": 5, "totalPages": 5
"totalItems": 42
}
} }
``` ```
@@ -628,6 +673,72 @@ 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 Document
Delete a document. Completed documents cannot be deleted. Delete a document. Completed documents cannot be deleted.
@@ -670,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
const { success } = await response.json(); const { success } = await response.json();
```` ```
</Tab> </Tab>
</Tabs> </Tabs>
@@ -680,7 +791,7 @@ const { success } = await response.json();
{ {
"success": true "success": true
} }
```` ```
--- ---
@@ -694,9 +805,11 @@ POST /envelope/get-many
### Request Body ### Request Body
| Field | Type | Required | Description | | Field | Type | Required | Description |
| ------------- | ----- | -------- | --------------------- | | ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
| `envelopeIds` | array | Yes | Array of document IDs | | `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` |
### Code Examples ### Code Examples
@@ -707,12 +820,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"] "ids": {
"type": "envelopeId",
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
}
}' }'
``` ```
</Tab> </Tab>
<Tab value="TypeScript"> <Tab value="TypeScript">
```typescript ```typescript
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', { const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -720,16 +838,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'], ids: {
type: 'envelopeId',
ids: requestedIds,
},
}), }),
}); });
const documents = await response.json(); const { data } = await response.json();
```` ```
</Tab> </Tab>
</Tabs> </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 ## Document Statuses
@@ -740,6 +878,7 @@ const documents = await response.json();
| `PENDING` | Document has been sent. Waiting for recipients to sign. | | `PENDING` | Document has been sent. Waiting for recipients to sign. |
| `COMPLETED` | All recipients have signed. Document is sealed. | | `COMPLETED` | All recipients have signed. Document is sealed. |
| `REJECTED` | A recipient rejected the document. | | `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 ### Status Transitions
@@ -747,11 +886,13 @@ const documents = await response.json();
flowchart LR flowchart LR
DRAFT --> PENDING --> COMPLETED DRAFT --> PENDING --> COMPLETED
PENDING --> REJECTED PENDING --> REJECTED
PENDING --> CANCELLED
``` ```
- **DRAFT to PENDING**: Call the distribute endpoint - **DRAFT to PENDING**: Call the distribute endpoint
- **PENDING to COMPLETED**: All recipients complete their signing - **PENDING to COMPLETED**: All recipients complete their signing
- **PENDING to REJECTED**: A recipient rejects the document - **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"> <Callout type="warn">
You cannot modify recipients or fields after a document moves to `PENDING` status. You cannot modify recipients or fields after a document moves to `PENDING` status.
@@ -773,8 +914,8 @@ flowchart LR
| Parameter | Values | Description | | Parameter | Values | Description |
| ---------- | ------------------------------------------- | ------------------------- | | ---------- | ------------------------------------------- | ------------------------- |
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type | | `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status | | `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source | | `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
| `folderId` | string | Filter by folder | | `folderId` | string | Filter by folder |
### Sorting ### Sorting
@@ -800,10 +941,10 @@ async function getAllPendingDocuments() {
}, },
); );
const { data, pagination } = await response.json(); const { data, currentPage, totalPages } = await response.json();
documents.push(...data); documents.push(...data);
hasMore = page < pagination.totalPages; hasMore = currentPage < totalPages;
page++; page++;
} }
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
| ------------------------------------------------- | ----------------------------------------------------- | | ------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v2/document` | `GET /api/v2/envelope` | | `GET /api/v2/document` | `GET /api/v2/envelope` |
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` | | `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` | | `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` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/create/beta` | `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/update` | `POST /api/v2/envelope/update` |
@@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
| ------------------------------------- | ------------------------------------------------ | | ------------------------------------- | ------------------------------------------------ |
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) | | `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` | | `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` | | `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` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/create/beta` | `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/update` | `POST /api/v2/envelope/update` |
@@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits ## HTTP Rate Limits
The rate limit applies to:
- `/api/v1/*`
- `/api/v2/*`
- `/api/v2-beta/*`
**Limit:** 1000 requests per minute per IP address **Limit:** 1000 requests per minute per IP address
**Response:** 429 Too Many Requests **Response:** 429 Too Many Requests
@@ -19,7 +25,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
this value, in which case you can be rate-limited before reaching the global limit. this value, in which case you can be rate-limited before reaching the global limit.
</Callout> </Callout>
### Rate Limit Response ### Global per-IP 429 Response
```json ```json
{ {
@@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
} }
``` ```
<Callout type="warn"> ### Rate Limit Headers
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying. Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
</Callout> 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).
## Resource Limits ## Resource Limits
@@ -44,24 +62,55 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited | | Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited | | Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
### Error Response ### Organisation Limit 429 Responses
When you exceed a resource limit: 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:
```json ```json
{ {
"error": "You have reached your document limit for this month. Please upgrade your plan.", "message": "Too many requests, please try again later. Contact support if you require higher limits."
"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 ## Error Codes
| Code | Status | Description | | Code | Status | Description |
| ------------------- | ------ | ----------------------------- | | ------------------- | ------ | ------------------------------------------------------------------ |
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded | | `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit 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.
--- ---
@@ -1000,9 +1000,12 @@ async function fetchWithRetry(
// Retry on rate limit // Retry on rate limit
if (response.status === 429) { if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After'); const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt); // 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);
console.log(`Rate limited, waiting ${delay}ms...`); console.log(`Rate limited, waiting ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs))); await new Promise((resolve) => setTimeout(resolve, delay));
continue; continue;
} }
+4 -4
View File
@@ -10,12 +10,12 @@
"postinstall": "fumadocs-mdx" "postinstall": "fumadocs-mdx"
}, },
"dependencies": { "dependencies": {
"fumadocs-core": "16.5.0", "fumadocs-core": "16.14.3",
"fumadocs-mdx": "14.2.6", "fumadocs-mdx": "15.2.3",
"fumadocs-ui": "16.5.0", "fumadocs-ui": "16.14.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"next": "16.2.6", "next": "16.3.0",
"next-plausible": "^3.12.5", "next-plausible": "^3.12.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4", "react": "^19.2.4",
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@documenso/prisma": "*", "@documenso/prisma": "*",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"next": "16.2.6" "next": "16.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
@@ -1,6 +1,7 @@
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -32,6 +33,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars'; import { injectCss } from '~/utils/css-vars';
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover'; import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
@@ -162,9 +164,12 @@ export const EmbedSignDocumentV1ClientPage = ({
); );
} }
const error = AppError.parseError(err);
const toastMessage = getSigningCompletionErrorMessage(error.code);
toast({ toast({
title: _(msg`Something went wrong`), title: _(toastMessage.title),
description: _(msg`We were unable to submit this document at this time. Please try again later.`), description: _(toastMessage.description),
variant: 'destructive', variant: 'destructive',
}); });
} }
@@ -26,6 +26,7 @@ import { useState } from 'react';
import { match, P } from 'ts-pattern'; import { match, P } from 'ts-pattern';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
@@ -141,9 +142,12 @@ export const MultiSignDocumentSigningView = ({
} catch (err) { } catch (err) {
onDocumentError?.(); onDocumentError?.();
const error = AppError.parseError(err);
const toastMessage = getSigningCompletionErrorMessage(error.code);
toast({ toast({
title: _(msg`Error`), title: _(toastMessage.title),
description: _(msg`Failed to complete the document. Please try again.`), description: _(toastMessage.description),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
@@ -14,6 +14,7 @@ import {
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client';
@@ -27,6 +28,8 @@ import { useEmbedSigningContext } from '~/components/embed/embed-signing-context
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form'; import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningCompleteDialogProps = { export type DocumentSigningCompleteDialogProps = {
@@ -85,7 +88,8 @@ export const DocumentSigningCompleteDialog = ({
position, position,
disableNameInput = false, disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => { }: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui(); const { t, i18n } = useLingui();
const { toast } = useToast();
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
@@ -174,6 +178,18 @@ export const DocumentSigningCompleteDialog = ({
return; return;
} }
// This dialog owns the completion error toast for every signing surface
// so the user gets a specific, actionable message. Callers should run
// their own side effects (e.g. embeds posting document-error) and
// rethrow rather than toasting themselves.
const toastMessage = getSigningCompletionErrorMessage(err.code);
toast({
title: i18n._(toastMessage.title),
description: i18n._(toastMessage.description),
variant: 'destructive',
});
} }
}; };
@@ -1,3 +1,4 @@
import { AppError } from '@documenso/lib/errors/app-error';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -19,6 +20,8 @@ import { useId, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog'; import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useRequiredDocumentSigningContext } from './document-signing-provider';
@@ -100,9 +103,12 @@ export const DocumentSigningForm = ({
try { try {
await completeDocument({ nextSigner }); await completeDocument({ nextSigner });
} catch (err) { } catch (err) {
const error = AppError.parseError(err);
const toastMessage = getSigningCompletionErrorMessage(error.code);
toast({ toast({
title: _(msg`Error`), title: _(toastMessage.title),
description: _(msg`An error occurred while completing the document. Please try again.`), description: _(toastMessage.description),
variant: 'destructive', variant: 'destructive',
}); });
@@ -148,15 +148,11 @@ export const EnvelopeSignerCompleteDialog = () => {
const error = AppError.parseError(err); const error = AppError.parseError(err);
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) { if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.(); onDocumentError?.();
} }
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
// toast a specific completion error message.
throw err; throw err;
} }
}; };
@@ -224,14 +220,11 @@ export const EnvelopeSignerCompleteDialog = () => {
} }
} catch (err) { } catch (err) {
console.log('err', err); console.log('err', err);
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.(); onDocumentError?.();
// Rethrow so DocumentSigningCompleteDialog can toast a specific
// completion error message.
throw err; throw err;
} }
}; };
@@ -42,6 +42,51 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
})); }));
}; };
/**
* Toast messages for errors thrown while a recipient attempts to complete
* (sign) a document, so the user knows whether retrying can help and what to
* do next.
*/
export const getSigningCompletionErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with(AppErrorCode.NOT_FOUND, () => ({
title: msg`Document no longer available`,
description: msg`This document can no longer be signed. It may have been removed by the sender, or your signing access may have been revoked. Please contact the sender for a new signing link.`,
}))
.with(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, () => ({
title: msg`Some fields were not saved`,
description: msg`One or more of your required fields have not been saved. Please refresh the page, complete any empty required fields, and try again.`,
}))
.with(AppErrorCode.RECIPIENT_OUT_OF_TURN, () => ({
title: msg`It's not your turn to sign yet`,
description: msg`This document is signed in a set order and other recipients must sign before you. You will receive an email when it is your turn.`,
}))
.with(AppErrorCode.RECIPIENT_EXPIRED, () => ({
title: msg`Signing link expired`,
description: msg`Your signing link has expired. Please contact the sender to request a new one.`,
}))
.with(AppErrorCode.ENVELOPE_COMPLETED, () => ({
title: msg`Document already completed`,
description: msg`This document has already been completed and no further signatures can be added.`,
}))
.with(AppErrorCode.ENVELOPE_REJECTED, () => ({
title: msg`Document rejected`,
description: msg`This document has been rejected by a recipient and can no longer be signed.`,
}))
.with(AppErrorCode.ENVELOPE_CANCELLED, () => ({
title: msg`Document cancelled`,
description: msg`This document has been cancelled by the sender and can no longer be signed. Please contact the sender if you believe this is a mistake.`,
}))
.with(AppErrorCode.ENVELOPE_DRAFT, () => ({
title: msg`Document not ready`,
description: msg`This document has not been sent for signing yet. Please wait for the sender to send it before signing.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
}));
};
export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => { export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code) return match(code)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE) .with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
+2 -2
View File
@@ -97,7 +97,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"esbuild": "^0.27.0", "esbuild": "^0.28.1",
"remix-flat-routes": "^0.8.5", "remix-flat-routes": "^0.8.5",
"rollup": "^4.53.3", "rollup": "^4.53.3",
"tsx": "^4.23.1", "tsx": "^4.23.1",
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"version": "2.16.0" "version": "2.17.0"
} }
-3
View File
@@ -76,7 +76,6 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/package-lock.json ./package-lock.json COPY --from=builder /app/out/package-lock.json ./package-lock.json
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
COPY --from=builder /app/patches ./patches
RUN npm ci RUN npm ci
@@ -115,8 +114,6 @@ WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ . COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
# Copy the tailwind config files across # Copy the tailwind config files across
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --only=production RUN npm ci --only=production
+3455 -3489
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -5,7 +5,7 @@
"apps/*", "apps/*",
"packages/*" "packages/*"
], ],
"version": "2.16.0", "version": "2.17.0",
"scripts": { "scripts": {
"postinstall": "patch-package", "postinstall": "patch-package",
"build": "turbo run build", "build": "turbo run build",
@@ -64,7 +64,7 @@
"@ts-rest/serverless": "^3.52.1", "@ts-rest/serverless": "^3.52.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"esbuild": "^0.27.0", "esbuild": "^0.28.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"inngest": "^3.54.0", "inngest": "^3.54.0",
"inngest-cli": "^1.17.9", "inngest-cli": "^1.17.9",
@@ -89,7 +89,7 @@
"zod-prisma-types": "3.3.5" "zod-prisma-types": "3.3.5"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@libpdf/core": "^0.4.1", "@libpdf/core": "^0.4.1",
"@lingui/conf": "^5.6.0", "@lingui/conf": "^5.6.0",
@@ -122,7 +122,7 @@
"@radix-ui/react-toggle": "^1.1.14", "@radix-ui/react-toggle": "^1.1.14",
"@radix-ui/react-toggle-group": "^1.1.15", "@radix-ui/react-toggle-group": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.12", "@radix-ui/react-tooltip": "^1.2.12",
"ai": "^5.0.104", "ai": "^7.0.58",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"luxon": "^3.7.2", "luxon": "^3.7.2",
@@ -130,12 +130,14 @@
"posthog-node": "4.18.0", "posthog-node": "4.18.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"sharp": "0.35.3",
"typescript": "5.6.2", "typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0", "@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"overrides": { "overrides": {
"lodash": "4.18.1", "lodash": "4.18.1",
"brace-expansion@1": "^1.1.18",
"pdfjs-dist": "5.4.296", "pdfjs-dist": "5.4.296",
"postcss": "^8.5.19", "postcss": "^8.5.19",
"react": "$react", "react": "$react",
+18
View File
@@ -43,6 +43,20 @@ export enum AppErrorCode {
*/ */
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED', RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
/**
* A completion request was made for a recipient that still has required
* fields which have not been inserted. Usually indicates the client's field
* state is out of sync with the server (e.g. a field insert failed to
* persist before submission).
*/
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
/**
* A completion request was made by a recipient in a sequential signing flow
* before the preceding recipients have signed.
*/
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
/** /**
* A signer recipient does not have a signature field assigned. Thrown when * A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one * distributing an envelope or using a direct template where at least one
@@ -99,6 +113,8 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 }, [AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 }, [AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -307,6 +323,8 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_LEGACY, AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED, AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD, AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
AppErrorCode.RECIPIENT_OUT_OF_TURN,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH, AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY, AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID, AppErrorCode.CSC_CERT_INVALID,
+3 -3
View File
@@ -15,7 +15,7 @@
"clean": "rimraf node_modules" "clean": "rimraf node_modules"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "5.0.48",
"@aws-sdk/client-s3": "^3.998.0", "@aws-sdk/client-s3": "^3.998.0",
"@aws-sdk/client-sesv2": "^3.998.0", "@aws-sdk/client-sesv2": "^3.998.0",
"@aws-sdk/cloudfront-signer": "^3.998.0", "@aws-sdk/cloudfront-signer": "^3.998.0",
@@ -43,7 +43,7 @@
"@sindresorhus/slugify": "^3.0.0", "@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0", "@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0", "@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104", "ai": "^7.0.58",
"bullmq": "^5.71.1", "bullmq": "^5.71.1",
"colord": "^2.9.3", "colord": "^2.9.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
@@ -67,7 +67,7 @@
"posthog-node": "4.18.0", "posthog-node": "4.18.0",
"react": "^19.2.7", "react": "^19.2.7",
"remeda": "^2.32.0", "remeda": "^2.32.0",
"sharp": "0.34.5", "sharp": "0.35.3",
"stripe": "^12.18.0", "stripe": "^12.18.0",
"ts-pattern": "^5.9.0", "ts-pattern": "^5.9.0",
"zod": "^3.25.76" "zod": "^3.25.76"
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
nextSigner, nextSigner,
recipientOverride, recipientOverride,
}: CompleteDocumentWithTokenOptions) => { }: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({ const envelope = await prisma.envelope.findFirst({
where: { where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), ...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
recipients: { recipients: {
@@ -78,10 +78,23 @@ export const completeDocumentWithToken = async ({
}, },
}); });
// The most common cause is a stale signing page: the document was deleted,
// or the recipient was removed, after the link was opened. Surface a
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.recipients.length === 0) { if (envelope.recipients.length === 0) {
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`); throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document ${envelope.id} has no recipient with the provided token`,
statusCode: 404,
});
} }
const [recipient] = envelope.recipients; const [recipient] = envelope.recipients;
@@ -98,7 +111,19 @@ export const completeDocumentWithToken = async ({
} }
if (envelope.status !== DocumentStatus.PENDING) { if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`); const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
// Unreachable: guarded by the status check above.
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
};
throw new AppError(envelopeStatusErrorCode[envelope.status], {
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
statusCode: 400,
});
} }
assertRecipientNotExpired(recipient); assertRecipientNotExpired(recipient);
@@ -116,7 +141,10 @@ export const completeDocumentWithToken = async ({
}); });
if (!isRecipientsTurn) { if (!isRecipientsTurn) {
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`); throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
statusCode: 400,
});
} }
} }
@@ -279,7 +307,10 @@ export const completeDocumentWithToken = async ({
} }
if (fieldsContainUnsignedRequiredField(fields)) { if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`); throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
message: `Recipient ${recipient.id} has unsigned fields`,
statusCode: 400,
});
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
@@ -8,13 +8,14 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
method: 'POST', method: 'POST',
path: '/envelope/cancel', path: '/envelope/cancel',
summary: 'Cancel envelope', summary: 'Cancel envelope',
description: 'Cancel a pending envelope',
tags: ['Envelope'], tags: ['Envelope'],
}, },
}; };
export const ZCancelEnvelopeRequestSchema = z.object({ export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string(), envelopeId: z.string().describe('The ID of the envelope to cancel.'),
reason: z.string().optional(), reason: z.string().describe('The reason for cancelling the envelope.').optional(),
}); });
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema; export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -8,12 +8,13 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
method: 'POST', method: 'POST',
path: '/envelope/delete', path: '/envelope/delete',
summary: 'Delete envelope', summary: 'Delete envelope',
description: 'Delete an envelope',
tags: ['Envelope'], tags: ['Envelope'],
}, },
}; };
export const ZDeleteEnvelopeRequestSchema = z.object({ export const ZDeleteEnvelopeRequestSchema = z.object({
envelopeId: z.string(), envelopeId: z.string().describe('The ID of the envelope to delete.'),
}); });
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema; export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -12,24 +12,32 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
method: 'POST', method: 'POST',
path: '/envelope/update', path: '/envelope/update',
summary: 'Update envelope', summary: 'Update envelope',
description: 'Update envelope properties and settings',
tags: ['Envelope'], tags: ['Envelope'],
}, },
}; };
export const ZUpdateEnvelopeRequestSchema = z.object({ export const ZUpdateEnvelopeRequestSchema = z.object({
envelopeId: z.string(), envelopeId: z.string().describe('The ID of the envelope to update.'),
data: z data: z
.object({ .object({
title: ZDocumentTitleSchema.optional(), title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(), externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(), visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), globalAccessAuth: z
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), .array(ZDocumentAccessAuthTypesSchema)
folderId: z.string().nullish(), .describe('The authentication methods required to access the envelope.')
templateType: z.nativeEnum(TemplateType).optional(), .optional(),
globalActionAuth: z
.array(ZDocumentActionAuthTypesSchema)
.describe('The authentication methods required to sign the envelope.')
.optional(),
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
}) })
.describe('The envelope properties to update.')
.optional(), .optional(),
meta: ZDocumentMetaUpdateSchema.optional(), meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
}); });
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema; export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
@@ -603,7 +603,7 @@ export const recipientRouter = router({
// can't complete via this route — they go through the CSC sync sign // can't complete via this route — they go through the CSC sync sign
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL // flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
// for the credential-scope OAuth round-trip. // for the credential-scope OAuth round-trip.
const envelope = await prisma.envelope.findFirstOrThrow({ const envelope = await prisma.envelope.findFirst({
where: { where: {
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT), ...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
recipients: { some: { token } }, recipients: { some: { token } },
@@ -611,6 +611,16 @@ export const recipientRouter = router({
select: { signatureLevel: true, internalVersion: true }, select: { signatureLevel: true, internalVersion: true },
}); });
// The most common cause is a stale signing page: the document was
// deleted, or the recipient was removed, after the link was opened.
// Surface a NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
if (isTspEnvelope(envelope)) { if (isTspEnvelope(envelope)) {
return await prepareCscRecipientSigning({ return await prepareCscRecipientSigning({
recipientToken: token, recipientToken: token,
@@ -2,16 +2,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client'; import { FieldType, SigningStatus } from '@prisma/client';
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react'; import {
import { useCallback, useEffect, useState } from 'react'; CalendarDaysIcon,
CheckSquareIcon,
ChevronDownIcon,
ContactIcon,
DiscIcon,
EyeOffIcon,
HashIcon,
LockIcon,
MailIcon,
TypeIcon,
UserIcon,
} from 'lucide-react';
import { type ElementType, useCallback, useEffect, useState } from 'react';
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template'; import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
import { extractInitials } from '../../../lib/utils/recipient-formatter'; import { extractInitials } from '../../../lib/utils/recipient-formatter';
import { SignatureIcon } from '../../icons/signature'; import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { Avatar, AvatarFallback } from '../../primitives/avatar'; import { Avatar, AvatarFallback } from '../../primitives/avatar';
import { Badge } from '../../primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types'; import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
import { PopoverHover } from '../../primitives/popover'; import { PopoverHover } from '../../primitives/popover';
@@ -27,16 +38,18 @@ interface EnvelopeRecipientFieldTooltipProps {
showRecipientColors?: boolean; showRecipientColors?: boolean;
} }
const getRecipientDisplayText = (recipient: { name: string; email: string }) => { const FIELD_TYPE_ICONS: Record<FieldType, ElementType> = {
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) { [FieldType.SIGNATURE]: SignatureIcon,
return `${recipient.name} (${recipient.email})`; [FieldType.FREE_SIGNATURE]: SignatureIcon,
} [FieldType.INITIALS]: ContactIcon,
[FieldType.TEXT]: TypeIcon,
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) { [FieldType.DATE]: CalendarDaysIcon,
return recipient.name; [FieldType.EMAIL]: MailIcon,
} [FieldType.NAME]: UserIcon,
[FieldType.NUMBER]: HashIcon,
return recipient.email; [FieldType.RADIO]: DiscIcon,
[FieldType.CHECKBOX]: CheckSquareIcon,
[FieldType.DROPDOWN]: ChevronDownIcon,
}; };
/** /**
@@ -50,6 +63,8 @@ export function EnvelopeRecipientFieldTooltip({
}: EnvelopeRecipientFieldTooltipProps) { }: EnvelopeRecipientFieldTooltipProps) {
const { t } = useLingui(); const { t } = useLingui();
const FieldIcon = FIELD_TYPE_ICONS[field.type];
const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip); const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip);
const [coords, setCoords] = useState({ const [coords, setCoords] = useState({
@@ -138,54 +153,64 @@ export function EnvelopeRecipientFieldTooltip({
</Avatar> </Avatar>
} }
contentProps={{ contentProps={{
className: 'relative flex mb-4 w-fit flex-col p-4 text-sm', className: 'flex w-64 flex-col overflow-hidden p-0 text-sm',
sideOffset: 20,
onOpenAutoFocus: (event) => event.preventDefault(),
}} }}
> >
{showFieldStatus && ( <div className="flex items-center gap-2 p-3">
<Badge <FieldIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
className="mx-auto mb-1 py-0.5"
variant={
field?.fieldMeta?.readOnly
? 'neutral'
: field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
{field?.fieldMeta?.readOnly ? (
<>
<LockIcon className="mr-1 h-3 w-3" />
<Trans>Read Only</Trans>
</>
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<ClockIcon className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<p className="text-center font-semibold"> <p className="min-w-0 flex-1 truncate font-medium">
<span>
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans> <Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
</span> </p>
</p>
<p className="mt-1 text-center text-muted-foreground text-xs">{getRecipientDisplayText(field.recipient)}</p> {showFieldStatus && (
<div className="flex shrink-0 items-center gap-1.5 text-xs">
{field?.fieldMeta?.readOnly ? (
<>
<LockIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">
<Trans>Read Only</Trans>
</span>
</>
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
<span className="text-green-600 dark:text-green-400">
<Trans>Signed</Trans>
</span>
</>
) : (
<>
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
<span className="text-amber-600 dark:text-amber-400">
<Trans>Pending</Trans>
</span>
</>
)}
</div>
)}
</div>
<button <div className="flex items-center gap-3 border-border/50 border-t bg-muted/50 px-3 py-2.5">
className="absolute top-0 right-0 my-1 p-2 focus:outline-none focus-visible:ring-0" <div className="min-w-0 flex-1">
onClick={() => setHideField(true)} <p className="truncate font-medium text-xs">{field.recipient.name || field.recipient.email}</p>
title="Hide field"
> {!isTemplateRecipientEmailPlaceholder(field.recipient.email) && field.recipient.name && (
<EyeOffIcon className="h-3 w-3" /> <p className="truncate text-muted-foreground text-xs">{field.recipient.email}</p>
</button> )}
</div>
<button
type="button"
className="-m-1 shrink-0 rounded-sm p-1 text-muted-foreground hover:bg-background hover:text-foreground"
onClick={() => setHideField(true)}
title={t`Hide field`}
>
<EyeOffIcon className="h-3.5 w-3.5" />
</button>
</div>
</PopoverHover> </PopoverHover>
</div> </div>
); );
File diff suppressed because one or more lines are too long