diff --git a/.agents/skills/create-justification/SKILL.md b/.agents/skills/create-justification/SKILL.md
deleted file mode 100644
index 78a2aaea9..000000000
--- a/.agents/skills/create-justification/SKILL.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-name: create-justification
-description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title
-license: MIT
-compatibility: opencode
-metadata:
- audience: agents
- workflow: decision-making
----
-
-## What I do
-
-I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets:
-
-- A unique three-word identifier (e.g., `swift-emerald-river`)
-- Frontmatter with the current date and formatted title
-- Content you provide
-
-## How to use
-
-Run the script with a slug and content:
-
-```bash
-npx tsx scripts/create-justification.ts "decision-name" "Justification content here"
-```
-
-Or use heredoc for multi-line content:
-
-```bash
-npx tsx scripts/create-justification.ts "decision-name" << HEREDOC
-Multi-line
-justification content
-goes here
-HEREDOC
-```
-
-## File format
-
-Files are created as: `{three-word-id}-{slug}.md`
-
-Example: `swift-emerald-river-decision-name.md`
-
-The file includes frontmatter:
-
-```markdown
----
-date: 2026-01-13
-title: Decision Name
----
-
-Your content here
-```
-
-## When to use me
-
-Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
diff --git a/.agents/skills/create-plan/SKILL.md b/.agents/skills/create-plan/SKILL.md
deleted file mode 100644
index 8ceb2ef8c..000000000
--- a/.agents/skills/create-plan/SKILL.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-name: create-plan
-description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title
-license: MIT
-compatibility: opencode
-metadata:
- audience: agents
- workflow: planning
----
-
-## What I do
-
-I help you create new plan files in the `.agents/plans/` directory. Each plan file gets:
-
-- A unique three-word identifier (e.g., `happy-blue-moon`)
-- Frontmatter with the current date and formatted title
-- Content you provide
-
-## How to use
-
-Run the script with a slug and content:
-
-```bash
-npx tsx scripts/create-plan.ts "feature-name" "Plan content here"
-```
-
-Or use heredoc for multi-line content:
-
-```bash
-npx tsx scripts/create-plan.ts "feature-name" << HEREDOC
-Multi-line
-plan content
-goes here
-HEREDOC
-```
-
-## File format
-
-Files are created as: `{three-word-id}-{slug}.md`
-
-Example: `happy-blue-moon-feature-name.md`
-
-The file includes frontmatter:
-
-```markdown
----
-date: 2026-01-13
-title: Feature Name
----
-
-Your content here
-```
-
-## When to use me
-
-Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
diff --git a/.agents/skills/create-scratch/SKILL.md b/.agents/skills/create-scratch/SKILL.md
deleted file mode 100644
index e44e4779d..000000000
--- a/.agents/skills/create-scratch/SKILL.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-name: create-scratch
-description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title
-license: MIT
-compatibility: opencode
-metadata:
- audience: agents
- workflow: exploration
----
-
-## What I do
-
-I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets:
-
-- A unique three-word identifier (e.g., `calm-teal-cloud`)
-- Frontmatter with the current date and formatted title
-- Content you provide
-
-## How to use
-
-Run the script with a slug and content:
-
-```bash
-npx tsx scripts/create-scratch.ts "note-name" "Scratch content here"
-```
-
-Or use heredoc for multi-line content:
-
-```bash
-npx tsx scripts/create-scratch.ts "note-name" << HEREDOC
-Multi-line
-scratch content
-goes here
-HEREDOC
-```
-
-## File format
-
-Files are created as: `{three-word-id}-{slug}.md`
-
-Example: `calm-teal-cloud-note-name.md`
-
-The file includes frontmatter:
-
-```markdown
----
-date: 2026-01-13
-title: Note Name
----
-
-Your content here
-```
-
-## When to use me
-
-Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 55ed7f27d..e3b1007da 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,7 @@ jobs:
build_app:
name: Build App
runs-on: ubuntu-latest
+ timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -32,6 +33,7 @@ jobs:
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
+ timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index d74f30387..b5ac9017a 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -11,6 +11,7 @@ jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
+ timeout-minutes: 60
permissions:
actions: read
contents: read
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 80d188964..00132070d 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -8,6 +8,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
+ timeout-minutes: 60
steps:
- name: Checkout code
diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml
index 34d7a478f..d8589bf1b 100644
--- a/.github/workflows/issue-labeler.yml
+++ b/.github/workflows/issue-labeler.yml
@@ -7,6 +7,7 @@ on:
jobs:
label-when-assigned:
runs-on: ubuntu-latest
+ timeout-minutes: 10
steps:
- name: Label issue
uses: actions/github-script@v6
diff --git a/.github/workflows/issue-opened.yml b/.github/workflows/issue-opened.yml
index 92b559d11..fd4a60151 100644
--- a/.github/workflows/issue-opened.yml
+++ b/.github/workflows/issue-opened.yml
@@ -7,6 +7,7 @@ on:
jobs:
label_issues:
runs-on: ubuntu-latest
+ timeout-minutes: 10
permissions:
issues: write
steps:
diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml
index 15fe7cbfa..5c3eeaac2 100644
--- a/.github/workflows/pr-labeler.yml
+++ b/.github/workflows/pr-labeler.yml
@@ -13,6 +13,7 @@ jobs:
contents: read
pull-requests: write
runs-on: ubuntu-latest
+ timeout-minutes: 10
steps:
- uses: actions/labeler@v4
with:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 50137d2e1..f28fc4e1f 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -14,6 +14,7 @@ jobs:
build_and_publish_platform_containers:
name: Build and publish platform containers
runs-on: ${{ matrix.os }}
+ timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -78,6 +79,7 @@ jobs:
create_and_publish_manifest:
name: Create and publish manifest
runs-on: ubuntu-latest
+ timeout-minutes: 60
needs: build_and_publish_platform_containers
steps:
- name: Checkout
diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml
index 0dab3392d..34e731436 100644
--- a/.github/workflows/semantic-pull-requests.yml
+++ b/.github/workflows/semantic-pull-requests.yml
@@ -15,6 +15,7 @@ jobs:
validate-pr:
name: Validate PR title
runs-on: ubuntu-latest
+ timeout-minutes: 10
steps:
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index c9c12ce59..aed53da8c 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -7,6 +7,7 @@ on:
jobs:
stale:
runs-on: ubuntu-latest
+ timeout-minutes: 10
permissions:
issues: write
pull-requests: write
diff --git a/.github/workflows/translations-force-pull.yml b/.github/workflows/translations-force-pull.yml
index 5d804df63..b0893a2aa 100644
--- a/.github/workflows/translations-force-pull.yml
+++ b/.github/workflows/translations-force-pull.yml
@@ -18,6 +18,7 @@ jobs:
pull_translations:
name: Force pull translations
runs-on: ubuntu-latest
+ timeout-minutes: 10
environment: Translations
permissions:
contents: write
diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml
index 0e3703438..ea65548f2 100644
--- a/.github/workflows/translations-pull.yml
+++ b/.github/workflows/translations-pull.yml
@@ -16,6 +16,7 @@ jobs:
pull_translations:
name: Pull translations
runs-on: ubuntu-latest
+ timeout-minutes: 10
environment: Translations
permissions:
contents: write
diff --git a/.github/workflows/translations-upload.yml b/.github/workflows/translations-upload.yml
index 0e80def07..b81b6375f 100644
--- a/.github/workflows/translations-upload.yml
+++ b/.github/workflows/translations-upload.yml
@@ -14,6 +14,7 @@ jobs:
extract_translations:
name: Extract and upload translations
runs-on: ubuntu-latest
+ timeout-minutes: 30
environment: Translations
permissions:
contents: write
diff --git a/.npmrc b/.npmrc
index 75baad7f0..cbc6b6537 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
-# min-release-age = 7
+min-release-age = 7
diff --git a/apps/docs/content/docs/developers/api/documents.mdx b/apps/docs/content/docs/developers/api/documents.mdx
index 293182057..bd526e828 100644
--- a/apps/docs/content/docs/developers/api/documents.mdx
+++ b/apps/docs/content/docs/developers/api/documents.mdx
@@ -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:
-| Property | Type | Description |
-| --------------- | -------------- | -------------------------------------------------------------- |
-| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
-| `type` | string | `DOCUMENT` or `TEMPLATE` |
-| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
-| `title` | string | Document title |
-| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
-| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
-| `externalId` | string \| null | Your custom identifier for the document |
-| `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 |
-| `documentMeta` | object | Email settings, redirect URL, signing options |
+| Property | Type | Description |
+| ------------------- | -------------- | -------------------------------------------------------------------------------------------- |
+| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
+| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) |
+| `internalVersion` | number | Internal envelope schema version |
+| `type` | string | `DOCUMENT` or `TEMPLATE` |
+| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
+| `title` | string | Document title |
+| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
+| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
+| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) |
+| `externalId` | string \| null | Your custom identifier for the document |
+| `userId` | number | ID of the user who owns the document |
+| `teamId` | number | ID of the team the document belongs to |
+| `folderId` | string \| null | ID of the folder containing the document |
+| `templateId` | number \| null | Legacy ID of the template this document was created from |
+| `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
```json
{
"id": "envelope_abc123xyz",
+ "secondaryId": "document_123",
+ "internalVersion": 2,
"type": "DOCUMENT",
"status": "PENDING",
"source": "DOCUMENT",
"visibility": "EVERYONE",
+ "templateType": "PRIVATE",
"title": "Service Agreement",
"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",
"updatedAt": "2025-01-15T10:35:00.000Z",
"completedAt": null,
@@ -74,22 +101,40 @@ A document object contains the following properties:
"fields": [
{
"id": 123,
+ "secondaryId": "field_abc123",
"type": "SIGNATURE",
+ "recipientId": 1,
+ "envelopeId": "envelope_abc123xyz",
+ "envelopeItemId": "envelope_item_xyz",
"page": 1,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
- "recipientId": 1
+ "customText": "",
+ "inserted": false,
+ "fieldMeta": null
}
],
"envelopeItems": [
{
"id": "envelope_item_xyz",
+ "envelopeId": "envelope_abc123xyz",
+ "documentDataId": "doc_data_abc123",
"title": "contract.pdf",
"order": 1
}
],
+ "directLink": null,
+ "team": {
+ "id": 1,
+ "url": "your-team"
+ },
+ "user": {
+ "id": 1,
+ "name": "Jane Smith",
+ "email": "jane@example.com"
+ },
"documentMeta": {
"subject": "Please sign this document",
"message": "Hi, please review and sign this agreement.",
diff --git a/apps/docs/content/docs/developers/api/fields.mdx b/apps/docs/content/docs/developers/api/fields.mdx
index ce31d1588..0cec72339 100644
--- a/apps/docs/content/docs/developers/api/fields.mdx
+++ b/apps/docs/content/docs/developers/api/fields.mdx
@@ -6,6 +6,8 @@ description: Add signature and form fields to documents via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
+
+
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).
@@ -62,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| Type | Description | Auto-filled |
| ---------------- | ----------------------------------------- | ----------- |
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
-| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
+| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No |
| `INITIALS` | Recipient's initials | No |
| `NAME` | Recipient's full name | Yes |
| `EMAIL` | Recipient's email address | Yes |
@@ -140,6 +142,8 @@ POST /envelope/field/create-many
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field configurations |
+Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`.
+
### Code Examples
@@ -335,6 +339,8 @@ POST /envelope/field/update-many
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field update objects |
+Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates.
+
### Code Examples
@@ -551,6 +557,33 @@ This approach is useful when generating PDFs programmatically or using templates
See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options.
+### Placeholder Positioning via the API
+
+`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass:
+
+| Field | Type | Required | Description |
+| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ |
+| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. |
+| `width` | number | No | Override the field width. Defaults to the width of the matched text. |
+| `height` | number | No | Override the field height. Defaults to the height of the matched text. |
+| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. |
+
+```json
+{
+ "envelopeId": "envelope_abcdefhiklmnorst",
+ "data": [
+ {
+ "type": "SIGNATURE",
+ "recipientId": 456,
+ "placeholder": "{{signature}}",
+ "matchAll": true
+ }
+ ]
+}
+```
+
+`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only.
+
---
## Field Meta Options
diff --git a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx
index d1cc4f46a..3f9083959 100644
--- a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx
+++ b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx
@@ -441,7 +441,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 limit or plan quota. If `Retry-After` is present, retry the request. |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,7 +485,9 @@ 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 has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically.
+
+Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information.
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -493,8 +495,14 @@ 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...`);
+ const retryAfter = response.headers.get('Retry-After');
+
+ if (!retryAfter) {
+ return response;
+ }
+
+ const retryAfterSeconds = Number.parseInt(retryAfter, 10);
+ console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`);
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
continue;
}
diff --git a/apps/docs/content/docs/developers/webhooks/index.mdx b/apps/docs/content/docs/developers/webhooks/index.mdx
index 93ed7f0d5..8c27eccbd 100644
--- a/apps/docs/content/docs/developers/webhooks/index.mdx
+++ b/apps/docs/content/docs/developers/webhooks/index.mdx
@@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events.
2. When an event occurs, Documenso sends an HTTP POST to your URL
3. Your application processes the event and responds with 200 OK
-Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
+Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used).
---
diff --git a/apps/docs/content/docs/developers/webhooks/setup.mdx b/apps/docs/content/docs/developers/webhooks/setup.mdx
index a11cc72f7..88fda57ea 100644
--- a/apps/docs/content/docs/developers/webhooks/setup.mdx
+++ b/apps/docs/content/docs/developers/webhooks/setup.mdx
@@ -304,6 +304,7 @@ Each webhook call shows the following details:
- Timestamp
- Response code
- Request and response bodies
+- Response headers
Click any call to see full details including headers and response data.
diff --git a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx
index 84e228115..a5ac58807 100644
--- a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx
+++ b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx
@@ -81,7 +81,7 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
- test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
+ test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 9f345d14c..9539148d0 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -10,13 +10,12 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
- "@radix-ui/react-tabs": "^1.1.13",
- "fumadocs-core": "16.5.0",
- "fumadocs-mdx": "14.2.6",
- "fumadocs-ui": "16.5.0",
+ "fumadocs-core": "16.14.3",
+ "fumadocs-mdx": "15.2.3",
+ "fumadocs-ui": "16.14.3",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
- "next": "16.2.6",
+ "next": "16.3.0",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json
index bcc93e039..1b7c14350 100644
--- a/apps/openpage-api/package.json
+++ b/apps/openpage-api/package.json
@@ -12,11 +12,11 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
- "next": "16.2.6"
+ "next": "16.3.0"
},
"devDependencies": {
"@types/node": "^20",
- "@types/react": "18.3.27",
+ "@types/react": "^19.2.17",
"typescript": "5.6.2"
}
}
diff --git a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx
index 9adebb36e..ed7184ef7 100644
--- a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx
+++ b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx
@@ -18,7 +18,6 @@ export type DocumentPreferencesResetDialogProps = {
onReset: () => Promise;
showAiFeatures?: boolean;
showDocumentVisibility?: boolean;
- showIncludeSenderDetails?: boolean;
};
export const DocumentPreferencesResetDialog = ({
@@ -26,7 +25,6 @@ export const DocumentPreferencesResetDialog = ({
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
- showIncludeSenderDetails = false,
}: DocumentPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
@@ -92,29 +90,12 @@ export const DocumentPreferencesResetDialog = ({
Default signature settings
- {showIncludeSenderDetails && (
-
- Send on behalf of team
-
- )}
-
- Include the signing certificate in the document
-
-
- Include the audit logs in the document
-
Default recipients
Delegate document ownership
-
- Default envelope expiration
-
-
- Default signing reminders
-
{showAiFeatures && (
AI features
diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx
new file mode 100644
index 000000000..940055854
--- /dev/null
+++ b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx
@@ -0,0 +1,377 @@
+import {
+ createZipWriter,
+ sanitizeZipPathSegment,
+ type ZipFileEntry,
+} from '@documenso/lib/client-only/create-zip-writer';
+import { downloadFile } from '@documenso/lib/client-only/download-file';
+import { fetchPDF } from '@documenso/lib/client-only/download-pdf';
+import { trpc } from '@documenso/trpc/react';
+import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@documenso/ui/primitives/dialog';
+import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+import { plural } from '@lingui/core/macro';
+import { Plural, Trans, useLingui } from '@lingui/react/macro';
+import { DocumentStatus } from '@prisma/client';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { useEffect, useRef, useState } from 'react';
+import { match } from 'ts-pattern';
+
+/**
+ * The maximum number of documents that can be downloaded in a single bulk
+ * download. Each document requires fetching its full PDFs into the browser,
+ * so this bounds both request volume and blob storage usage. Matches the
+ * spirit of the server-side 100 cap on bulk move/delete/cancel.
+ */
+export const MAX_BULK_DOWNLOAD_ENVELOPES = 50;
+
+type BulkDownloadVersion = 'signed' | 'original' | 'pending';
+
+export type EnvelopeBulkDownloadItem = {
+ id: string;
+ title: string;
+ status: DocumentStatus;
+
+ /**
+ * Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a
+ * different field-rendering pipeline that the partial PDF helper does not
+ * implement, so the Partial option is hidden for them.
+ */
+ isLegacy: boolean;
+};
+
+const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
+ envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original';
+
+export type EnvelopesBulkDownloadDialogProps = {
+ envelopes: EnvelopeBulkDownloadItem[];
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: (successfulEnvelopeIds: string[]) => void;
+} & Omit;
+
+export const EnvelopesBulkDownloadDialog = ({
+ envelopes,
+ open,
+ onOpenChange,
+ onSuccess,
+ ...props
+}: EnvelopesBulkDownloadDialogProps) => {
+ const { t } = useLingui();
+ const { toast } = useToast();
+
+ const [versionMap, setVersionMap] = useState>({});
+ const [progress, setProgress] = useState(0);
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const abortRef = useRef(false);
+
+ const trpcUtils = trpc.useUtils();
+
+ const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES;
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)])));
+ setProgress(0);
+ }, [open]);
+
+ const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
+ versionMap[envelope.id] ?? getDefaultVersion(envelope);
+
+ /**
+ * The version options selectable for an envelope, mirroring the gating used
+ * by the single envelope download dialog:
+ * - COMPLETED: signed or original.
+ * - PENDING (non-legacy): partial or original. Legacy envelopes use a
+ * field-rendering pipeline the partial PDF helper does not implement.
+ * - Anything else: original only, so no choice is shown.
+ */
+ const getVersionOptions = (
+ envelope: EnvelopeBulkDownloadItem,
+ ): { value: BulkDownloadVersion; label: string }[] | null => {
+ if (envelope.status === DocumentStatus.COMPLETED) {
+ return [
+ { value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) },
+ { value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
+ ];
+ }
+
+ if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) {
+ return [
+ { value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) },
+ { value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
+ ];
+ }
+
+ return null;
+ };
+
+ const getStatusLabel = (status: DocumentStatus) =>
+ match(status)
+ .with(DocumentStatus.COMPLETED, () => t`Completed`)
+ .with(DocumentStatus.PENDING, () => t`Pending`)
+ .with(DocumentStatus.DRAFT, () => t`Draft`)
+ .with(DocumentStatus.REJECTED, () => t`Rejected`)
+ .with(DocumentStatus.CANCELLED, () => t`Cancelled`)
+ .exhaustive();
+
+ const onDownload = async () => {
+ if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) {
+ return;
+ }
+
+ abortRef.current = false;
+ setIsDownloading(true);
+ setProgress(0);
+
+ const zipWriter = createZipWriter();
+
+ const successfulEnvelopeIds: string[] = [];
+ let failedDownloads = 0;
+
+ try {
+ for (const envelope of envelopes) {
+ if (abortRef.current) {
+ break;
+ }
+
+ try {
+ const downloadVersion = getDownloadVersion(envelope);
+
+ const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
+ envelopeId: envelope.id,
+ access: {
+ type: 'user',
+ },
+ });
+
+ // Each envelope's items are grouped in their own folder. The id
+ // prefix guarantees uniqueness, the truncated title keeps it
+ // readable without risking overly long extraction paths.
+ const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96));
+
+ // Buffer this envelope's files before writing so a failed envelope
+ // is either fully in the zip or not at all. Files from previous
+ // envelopes have already been written to the zip stream and freed.
+ const envelopeFiles: ZipFileEntry[] = [];
+
+ for (const envelopeItem of envelopeItems) {
+ const { filename, blob } = await fetchPDF({
+ envelopeItem,
+ token: undefined,
+ fileName: envelopeItem.title,
+ version: downloadVersion,
+ });
+
+ envelopeFiles.push({
+ filename: `${folderName}/${sanitizeZipPathSegment(filename)}`,
+ data: blob,
+ });
+ }
+
+ for (const file of envelopeFiles) {
+ await zipWriter.addFile(file);
+ }
+
+ successfulEnvelopeIds.push(envelope.id);
+ } catch (error) {
+ console.error(error);
+ failedDownloads++;
+ }
+
+ setProgress((p) => p + 1);
+ }
+
+ // The user intentionally stopped the download, discard anything fetched
+ // so far without toasting an error.
+ if (abortRef.current) {
+ zipWriter.abort();
+ return;
+ }
+
+ if (successfulEnvelopeIds.length === 0) {
+ zipWriter.abort();
+
+ toast({
+ title: t`Error`,
+ description: t`An error occurred while downloading the documents.`,
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ try {
+ downloadFile({
+ filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`,
+ data: zipWriter.finalize(),
+ });
+ } catch (error) {
+ console.error(error);
+
+ zipWriter.abort();
+
+ toast({
+ title: t`Error`,
+ description: t`An error occurred while downloading the documents.`,
+ variant: 'destructive',
+ });
+
+ return;
+ }
+
+ if (failedDownloads > 0) {
+ toast({
+ title: t`Documents partially downloaded`,
+ description: t`${plural(successfulEnvelopeIds.length, {
+ one: '# document downloaded.',
+ other: '# documents downloaded.',
+ })} ${plural(failedDownloads, {
+ one: '# document could not be downloaded.',
+ other: '# documents could not be downloaded.',
+ })}`,
+ variant: 'destructive',
+ });
+ onSuccess?.(successfulEnvelopeIds);
+ return;
+ }
+
+ toast({
+ title: t`Documents downloaded`,
+ description: plural(successfulEnvelopeIds.length, {
+ one: '# document has been downloaded.',
+ other: '# documents have been downloaded.',
+ }),
+ });
+
+ onSuccess?.(successfulEnvelopeIds);
+ onOpenChange(false);
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx
index c76bc00a2..9bbec2636 100644
--- a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx
+++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx
@@ -1,7 +1,7 @@
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
-import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
))}
diff --git a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
index 7c08cf7d3..147cf3b40 100644
--- a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
+++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
@@ -17,28 +17,25 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Prisma } from '@prisma/client';
+import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client';
import { useState } from 'react';
import { useRevalidator } from 'react-router';
export type TeamEmailDeleteDialogProps = {
trigger?: React.ReactNode;
teamName: string;
- team: Prisma.TeamGetPayload<{
- include: {
- teamEmail: true;
- emailVerification: {
- select: {
- expiresAt: true;
- name: true;
- email: true;
- };
- };
- };
- }>;
+ team: Pick;
+ teamEmail: Pick | null;
+ emailVerification: Pick | null;
};
-export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => {
+export const TeamEmailDeleteDialog = ({
+ trigger,
+ teamName,
+ team,
+ teamEmail,
+ emailVerification,
+}: TeamEmailDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
@@ -83,11 +80,11 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
});
const onRemove = async () => {
- if (team.teamEmail) {
+ if (teamEmail) {
await deleteTeamEmail({ teamId: team.id });
}
- if (team.emailVerification) {
+ if (emailVerification) {
await deleteTeamEmailVerification({ teamId: team.id });
}
@@ -121,13 +118,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
- {team.teamEmail?.name || team.emailVerification?.name}
+ {teamEmail?.name || emailVerification?.name}
}
- secondaryText={{team.teamEmail?.email || team.emailVerification?.email}}
+ secondaryText={{teamEmail?.email || emailVerification?.email}}
/>
diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx
index 3fbddc3c2..449d5ec36 100644
--- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx
+++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx
@@ -23,7 +23,8 @@ import { useRevalidator } from 'react-router';
import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
- teamEmail: TeamEmail;
+ teamId: number;
+ teamEmail: Pick;
trigger?: React.ReactNode;
} & Omit;
@@ -33,7 +34,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
type TUpdateTeamEmailFormSchema = z.infer;
-export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
+export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
@@ -53,7 +54,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
try {
await updateTeamEmail({
- teamId: teamEmail.teamId,
+ teamId,
data: {
name,
},
diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
index 669f5274c..c8726ae22 100644
--- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
+++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
@@ -1,6 +1,7 @@
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
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 { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
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 PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
+import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
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({
- title: _(msg`Something went wrong`),
- description: _(msg`We were unable to submit this document at this time. Please try again later.`),
+ title: _(toastMessage.title),
+ description: _(toastMessage.description),
variant: 'destructive',
});
}
diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
index b144d1c46..15902715d 100644
--- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
+++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@@ -26,6 +26,7 @@ import { useState } from 'react';
import { match, P } from 'ts-pattern';
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 { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
@@ -141,9 +142,12 @@ export const MultiSignDocumentSigningView = ({
} catch (err) {
onDocumentError?.();
+ const error = AppError.parseError(err);
+ const toastMessage = getSigningCompletionErrorMessage(error.code);
+
toast({
- title: _(msg`Error`),
- description: _(msg`Failed to complete the document. Please try again.`),
+ title: _(toastMessage.title),
+ description: _(toastMessage.description),
variant: 'destructive',
});
} finally {
diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx
index e556ff6cf..1cb5be35d 100644
--- a/apps/remix/app/components/forms/branding-preferences-form.tsx
+++ b/apps/remix/app/components/forms/branding-preferences-form.tsx
@@ -29,6 +29,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { FormStickySaveBar } from './form-sticky-save-bar';
+import { InheritableField } from './inheritable-field';
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
@@ -210,11 +211,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingEnabled"
render={({ field }) => (
-
-
- Enable Custom Branding
-
-
+ Enable Custom Branding}
+ testId="branding-enabled"
+ >
+
)}
/>
@@ -263,11 +266,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingLogo"
render={({ field: { value: _value, onChange, ...field } }) => (
-
-
- Branding Logo
-
-
+ Branding Logo}
+ testId="branding-logo"
+ >
diff --git a/apps/remix/app/components/forms/certificate-preferences-form.tsx b/apps/remix/app/components/forms/certificate-preferences-form.tsx
new file mode 100644
index 000000000..a6897c8be
--- /dev/null
+++ b/apps/remix/app/components/forms/certificate-preferences-form.tsx
@@ -0,0 +1,169 @@
+import { Form, FormControl, FormDescription, FormField } from '@documenso/ui/primitives/form/form';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Trans } from '@lingui/react/macro';
+import type { TeamGlobalSettings } from '@prisma/client';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { FormStickySaveBar } from './form-sticky-save-bar';
+import { InheritableField } from './inheritable-field';
+
+const ZCertificatePreferencesFormSchema = z.object({
+ includeSigningCertificate: z.boolean().nullable(),
+ includeAuditLog: z.boolean().nullable(),
+});
+
+export type TCertificatePreferencesFormSchema = z.infer;
+
+type SettingsSubset = Pick;
+
+export type CertificatePreferencesFormProps = {
+ settings: SettingsSubset;
+ canInherit: boolean;
+ onFormSubmit: (data: TCertificatePreferencesFormSchema) => Promise;
+};
+
+export const CertificatePreferencesForm = ({ settings, canInherit, onFormSubmit }: CertificatePreferencesFormProps) => {
+ const form = useForm({
+ defaultValues: {
+ includeSigningCertificate: settings.includeSigningCertificate,
+ includeAuditLog: settings.includeAuditLog,
+ },
+ resolver: zodResolver(ZCertificatePreferencesFormSchema),
+ });
+
+ const handleFormSubmit = form.handleSubmit(async (data) => {
+ try {
+ await onFormSubmit(data);
+ } catch {
+ // The page handler surfaces its own error toast. Keep the form dirty so
+ // the save bar stays visible and the user can retry.
+ return;
+ }
+
+ form.reset(data);
+ });
+
+ return (
+
+
+ );
+};
diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx
index cdf39eb51..29e566c79 100644
--- a/apps/remix/app/components/forms/document-preferences-form.tsx
+++ b/apps/remix/app/components/forms/document-preferences-form.tsx
@@ -1,12 +1,8 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
+import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
-import {
- type TEnvelopeExpirationPeriod,
- ZEnvelopeExpirationPeriod,
-} from '@documenso/lib/constants/envelope-expiration';
-import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
@@ -16,36 +12,24 @@ import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documens
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings, generateDefaultTeamSettings } 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';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
-import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Combobox } from '@documenso/ui/primitives/combobox';
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from '@documenso/ui/primitives/form/form';
+import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
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 { DocumentVisibility, type RecipientRole, type TeamGlobalSettings } 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';
import { FormStickySaveBar } from './form-sticky-save-bar';
+import { InheritableField } from './inheritable-field';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
@@ -56,15 +40,10 @@ export type TDocumentPreferencesFormSchema = {
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
documentTimezone: string | null;
documentDateFormat: TDocumentMetaDateFormat | null;
- includeSenderDetails: boolean | null;
- includeSigningCertificate: boolean | null;
- includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
- envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
- reminderSettings: TEnvelopeReminderSettings | null;
};
type SettingsSubset = Pick<
@@ -73,23 +52,17 @@ type SettingsSubset = Pick<
| 'documentLanguage'
| 'documentTimezone'
| 'documentDateFormat'
- | 'includeSenderDetails'
- | 'includeSigningCertificate'
- | 'includeAuditLog'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
- | 'envelopeExpirationPeriod'
- | 'reminderSettings'
>;
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
- isAiFeaturesConfigured?: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise;
};
@@ -101,50 +74,34 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
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,
- canInherit,
- isAiFeaturesConfigured = false,
-}: DocumentPreferencesFormProps) => {
+export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }: DocumentPreferencesFormProps) => {
const { _ } = useLingui();
- const { user, organisations } = useSession();
+ const { organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam();
- const isPersonalLayoutMode = isPersonalLayout(organisations);
- const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
+ const isAiFeaturesConfigured = IS_AI_FEATURES_CONFIGURED();
- const placeholderEmail = user.email ?? 'user@example.com';
+ const isPersonalLayoutMode = isPersonalLayout(organisations);
const ZDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
- includeSenderDetails: z.boolean().nullable(),
- includeSigningCertificate: z.boolean().nullable(),
- includeAuditLog: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
- envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
- reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const defaultValues = getDocumentPreferencesFormValues(settings);
@@ -189,17 +146,19 @@ export const DocumentPreferencesForm = ({
return (
)}
/>
)}
@@ -245,11 +204,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentLanguage"
render={({ field }) => (
-
-
- Default Document Language
-
-
+ Default Document Language}
+ testId="document-language"
+ >
+
)}
/>
@@ -291,11 +252,12 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentDateFormat"
render={({ field }) => (
-
-
- Default Date Format
-
-
+ Default Date Format}
+ testId="document-date-format"
+ >
+
)}
/>
@@ -330,11 +292,12 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentTimezone"
render={({ field }) => (
-
-
- Default Time Zone
-
-
+ Default Time Zone}
+ testId="document-timezone"
+ >
-
+
)}
/>
@@ -355,12 +318,18 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="signatureTypes"
render={({ field }) => (
-
-
- Default Signature Settings
-
-
-
+
+ Default Signature Settings
+
+
+ }
+ testId="signature-types"
+ >
({
@@ -383,179 +352,7 @@ export const DocumentPreferencesForm = ({
Controls which signatures are allowed to be used when signing a document.
)}
-
- )}
- />
-
- {!isPersonalLayoutMode && !isPersonalOrganisation && (
- (
-
-
- Send on Behalf of Team
-
-
-
-
-
-
-
-
- Preview
-
-
-
- {field.value ? (
-
- "{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
-
- ) : (
- "Team Name" has invited you to sign "example document".
- )}
-
-
-
-
-
- Controls the formatting of the message that will be sent when inviting a recipient to sign a
- document. If a custom message has been provided while configuring the document, it will be used
- instead.
-
-
-
- )}
- />
- )}
-
- (
-
-
- Include the Signing Certificate in the Document
-
-
-
-
-
-
-
-
- Controls whether the signing certificate will be included in the document when it is downloaded. The
- signing certificate can still be downloaded from the logs page separately.
-
-
-
- )}
- />
-
- (
-
-
- Include the Audit Logs in the Document
-
-
-
-
-
-
-
-
- Controls whether the audit logs will be included in the document when it is downloaded. The audit
- logs can still be downloaded from the logs page separately.
-
-
-
+
)}
/>
@@ -566,11 +363,13 @@ export const DocumentPreferencesForm = ({
const recipients = field.value ?? [];
return (
-
-
- Default Recipients
-
-
+ Default Recipients}
+ testId="default-recipients"
+ >
{canInherit && (
+
);
}}
/>
@@ -647,11 +446,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="delegateDocumentOwnership"
render={({ field }) => (
-
-
- Delegate Document Ownership
-
-
+ Delegate Document Ownership}
+ testId="delegate-document-ownership"
+ >
- )}
- />
-
- (
-
-
- Default Envelope Expiration
-
-
-
-
-
-
-
-
- Controls how long recipients have to complete signing before the document expires. After expiration,
- recipients can no longer sign the document.
-
-
-
-
-
- )}
- />
-
- (
-
-
- Default Signing Reminders
-
-
-
-
-
-
-
-
- Controls when and how often reminder emails are sent to recipients who have not yet completed
- signing.
-
-
-
-
-
+
)}
/>
@@ -748,11 +491,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="aiFeaturesEnabled"
render={({ field }) => (
-
-
- AI Features
-
-
+ AI Features}
+ testId="ai-features-enabled"
+ >
+
)}
/>
)}
@@ -806,7 +551,6 @@ export const DocumentPreferencesForm = ({
onReset={handleResetToDefaults}
showAiFeatures={isAiFeaturesConfigured}
showDocumentVisibility={!isPersonalLayoutMode}
- showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
) : undefined
}
diff --git a/apps/remix/app/components/forms/email-preferences-form.tsx b/apps/remix/app/components/forms/email-preferences-form.tsx
index 818868005..97c597623 100644
--- a/apps/remix/app/components/forms/email-preferences-form.tsx
+++ b/apps/remix/app/components/forms/email-preferences-form.tsx
@@ -1,9 +1,11 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
+import { useSession } from '@documenso/lib/client-only/providers/session';
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
+import { Alert } from '@documenso/ui/primitives/alert';
import {
Form,
FormControl,
@@ -17,22 +19,27 @@ import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
-import type { TeamGlobalSettings } from '@prisma/client';
+import { OrganisationType, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
+import { InheritableField } from './inheritable-field';
const ZEmailPreferencesFormSchema = z.object({
emailId: z.string().nullable(),
emailReplyTo: zEmail().nullable(),
// emailReplyToName: z.string(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
+ includeSenderDetails: z.boolean().nullable(),
});
export type TEmailPreferencesFormSchema = z.infer;
-type SettingsSubset = Pick;
+type SettingsSubset = Pick<
+ TeamGlobalSettings,
+ 'emailId' | 'emailReplyTo' | 'emailDocumentSettings' | 'includeSenderDetails'
+>;
export type EmailPreferencesFormProps = {
settings: SettingsSubset;
@@ -41,14 +48,20 @@ export type EmailPreferencesFormProps = {
};
export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: EmailPreferencesFormProps) => {
+ const { user } = useSession();
const organisation = useCurrentOrganisation();
+ const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
+
+ const placeholderEmail = user.email ?? 'user@example.com';
+
const form = useForm({
defaultValues: {
emailId: settings.emailId,
emailReplyTo: settings.emailReplyTo,
// emailReplyToName: settings.emailReplyToName,
emailDocumentSettings: settings.emailDocumentSettings,
+ includeSenderDetails: settings.includeSenderDetails,
},
resolver: zodResolver(ZEmailPreferencesFormSchema),
});
@@ -75,7 +88,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
return (
+ );
+};
diff --git a/apps/remix/app/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx
index 16b9943ca..6270bc948 100644
--- a/apps/remix/app/components/forms/signin.tsx
+++ b/apps/remix/app/components/forms/signin.tsx
@@ -1,5 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
+import { formatPath } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => {
return message;
};
-const LOGIN_REDIRECT_PATH = '/';
+const LOGIN_REDIRECT_PATH = formatPath('/');
export const ZSignInFormSchema = z.object({
email: zEmail().min(1),
diff --git a/apps/remix/app/components/forms/team-update-form.tsx b/apps/remix/app/components/forms/team-update-form.tsx
index 8dddaee2b..cfe82fc22 100644
--- a/apps/remix/app/components/forms/team-update-form.tsx
+++ b/apps/remix/app/components/forms/team-update-form.tsx
@@ -65,7 +65,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
});
if (url !== teamUrl) {
- await navigate(`/t/${url}/settings`);
+ await navigate(`/t/${url}/settings/general`);
}
} catch (err) {
const error = AppError.parseError(err);
diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx
index 9746929b0..32b6a7865 100644
--- a/apps/remix/app/components/general/app-command-menu.tsx
+++ b/apps/remix/app/components/general/app-command-menu.tsx
@@ -1,5 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useSession } from '@documenso/lib/client-only/providers/session';
+import { formatPath } from '@documenso/lib/constants/app';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
DOCUMENTS_PAGE_SHORTCUT,
@@ -18,7 +19,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
-import { commandScore } from 'cmdk/dist/command-score';
+import { defaultFilter as commandScore } from 'cmdk';
import {
ArrowLeftIcon,
CheckIcon,
@@ -287,7 +288,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
{
id: 'settings-main',
label: msg`Settings`,
- path: '/settings',
+ path: '/settings/profile',
icon: SettingsIcon,
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
},
@@ -389,7 +390,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
const formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `≥${count}` : `${count}`);
- const goToSettings = useCallback(() => push('/settings'), [push]);
+ const goToSettings = useCallback(() => push('/settings/profile'), [push]);
const goToDocuments = useCallback(() => {
if (teamUrl) {
push(`/t/${teamUrl}/documents?status=ALL`);
@@ -862,7 +863,7 @@ const PromptLanguageCommands = ({
formData.append('lang', lang);
- const response = await fetch('/api/locale', {
+ const response = await fetch(formatPath('/api/locale'), {
method: 'post',
body: formData,
});
diff --git a/apps/remix/app/components/general/app-header.tsx b/apps/remix/app/components/general/app-header.tsx
index 22f2278a2..2717b8036 100644
--- a/apps/remix/app/components/general/app-header.tsx
+++ b/apps/remix/app/components/general/app-header.tsx
@@ -1,5 +1,3 @@
-import { useSession } from '@documenso/lib/client-only/providers/session';
-import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { getRootHref } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -14,16 +12,16 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { AppCommandMenu } from './app-command-menu';
import { AppNavDesktop } from './app-nav-desktop';
import { AppNavMobile } from './app-nav-mobile';
-import { MenuSwitcher } from './menu-switcher';
import { OrgMenuSwitcher } from './org-menu-switcher';
-export type HeaderProps = HTMLAttributes;
+export type HeaderProps = HTMLAttributes & {
+ /** Span the full viewport width instead of the centered max-w-screen-xl container. */
+ fullWidth?: boolean;
+};
-export const Header = ({ className, ...props }: HeaderProps) => {
+export const Header = ({ className, fullWidth = false, ...props }: HeaderProps) => {
const params = useParams();
- const { organisations } = useSession();
-
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
@@ -51,12 +49,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
5 && 'border-b-border',
+ (scrollY > 5 || fullWidth) && 'border-b-border',
className,
)}
{...props}
>
-