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/ISSUE_TEMPLATE/deploy-provider-request.yml b/.github/ISSUE_TEMPLATE/deploy-provider-request.yml new file mode 100644 index 000000000..c59154391 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/deploy-provider-request.yml @@ -0,0 +1,61 @@ +name: 'One-Click Deploy Provider Request' +description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README +title: 'One-Click Deploy Provider Request: [Provider Name]' +labels: ['deploy-provider-request'] +body: + - type: markdown + attributes: + value: | + Thanks for your interest in adding a one-click deploy option for Documenso! + + Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest. + + **How this works:** + + - 👍 this issue if you'd like to see Documenso deployable on this provider. + - If community interest is high enough, we'll consider adding it to the README. + - Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed. + - type: input + attributes: + label: Provider Name + placeholder: e.g. Railway + validations: + required: true + - type: input + attributes: + label: Provider Website + placeholder: e.g. https://railway.com + validations: + required: true + - type: input + attributes: + label: Deploy/Template URL + description: A link to an existing deployment template or deploy button URL, if one exists. + - type: dropdown + attributes: + label: Who creates and maintains the deployment template? + options: + - The provider + - Me / the community + - Nobody yet + validations: + required: true + - type: textarea + attributes: + label: Testing & Maintenance + description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)? + validations: + required: true + - type: textarea + attributes: + label: Why this provider? + description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc. + validations: + required: true + - type: checkboxes + attributes: + label: Please check the boxes that apply to this request. + options: + - label: I have searched existing issues to make sure this provider has not already been requested. + - label: I understand that inclusion depends on community interest and is not guaranteed. + - label: I understand that PRs adding deploy badges without a prior issue will be closed. diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index b01a28740..fb208e916 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -2,7 +2,7 @@ name: 'Setup node' inputs: node_version: required: false - default: v22.x + default: v24.x runs: using: 'composite' 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/README.md b/README.md index 642a285eb..ad4d7c5b9 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations To run Documenso locally, you will need -- Node.js (v22 or above) +- Node.js (v24 or above) - Postgres SQL Database - Docker (optional) @@ -186,21 +186,37 @@ For full instructions, requirements, and configuration details, see the [Self Ho ### One-Click Deploys -#### Railway +> [!NOTE] +> Want to see another provider listed here? Please [open a provider request](https://github.com/documenso/documenso/issues/new?template=deploy-provider-request.yml) instead of a PR so the community can signal interest. PRs adding deploy badges without a prior issue will be closed. -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) - -#### Render - -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso) - -#### Koyeb - -[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile) - -#### Elestio - -[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso) + + + + + + + + + + + +
+ + Deploy on Railway + + + + Deploy to Render + + + + Deploy to Koyeb + +
+ + Deploy on Elestio + +
## Security diff --git a/apps/docs/content/docs/developers/api/documents.mdx b/apps/docs/content/docs/developers/api/documents.mdx index dbe2e6a85..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`, or `REJECTED` | -| `title` | string | Document title | -| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` | -| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` | -| `externalId` | string \| null | Your custom identifier for the document | -| `createdAt` | string | ISO 8601 timestamp | -| `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": "API", + "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, @@ -73,23 +100,41 @@ A document object contains the following properties: ], "fields": [ { - "id": "field_123", + "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 + "positionX": "10", + "positionY": "80", + "width": "30", + "height": "5", + "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.", @@ -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 Retrieve a paginated list of documents. @@ -114,7 +161,7 @@ GET /envelope | `page` | integer | Page number (default: 1) | | `perPage` | integer | Results per page (default: 10, max: 100) | | `type` | string | Filter by `DOCUMENT` or `TEMPLATE` | -| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | +| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | | `source` | string | Filter by creation source | | `folderId` | string | Filter by folder ID | | `orderByColumn` | string | Sort field (only `createdAt` supported) | @@ -154,8 +201,8 @@ const response = await fetch(`${BASE_URL}/envelope`, { }, }); -const { data, pagination } = await response.json(); -console.log(`Found ${pagination.totalItems} documents`); +const { data, count } = await response.json(); +console.log(`Found ${count} documents`); // Filter by status const pendingResponse = await fetch( @@ -197,12 +244,10 @@ const pendingDocs = await pendingResponse.json(); ] } ], - "pagination": { - "page": 1, - "perPage": 10, - "totalPages": 5, - "totalItems": 42 - } + "count": 42, + "currentPage": 1, + "perPage": 10, + "totalPages": 5 } ``` @@ -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 + + + +```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." + }' +``` + + +```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(); +``` + + + +### Response + +```json +{ + "success": true +} +``` + +### Behavior + +- Only documents in `PENDING` status can be cancelled. Other statuses return `400`. +- Cancellation is not idempotent. Cancelling the same document again returns `400`. +- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`. +- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook. +- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document. + +--- + ## Delete Document Delete a document. Completed documents cannot be deleted. @@ -670,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete', const { success } = await response.json(); -```` +``` @@ -680,7 +791,7 @@ const { success } = await response.json(); { "success": true } -```` +``` --- @@ -694,9 +805,11 @@ POST /envelope/get-many ### Request Body -| Field | Type | Required | Description | -| ------------- | ----- | -------- | --------------------- | -| `envelopeIds` | array | Yes | Array of document IDs | +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ---------------------------------------------------------------------------- | +| `ids` | object | Yes | ID selector containing `type` and `ids` | +| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` | +| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` | ### Code Examples @@ -707,12 +820,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \ -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ - "envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"] + "ids": { + "type": "envelopeId", + "ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"] + } }' ``` ```typescript +const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789']; + const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', { method: 'POST', headers: { @@ -720,16 +838,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many 'Content-Type': 'application/json', }, 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(); -```` +``` +### 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 @@ -740,6 +878,7 @@ const documents = await response.json(); | `PENDING` | Document has been sent. Waiting for recipients to sign. | | `COMPLETED` | All recipients have signed. Document is sealed. | | `REJECTED` | A recipient rejected the document. | +| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. | ### Status Transitions @@ -747,11 +886,13 @@ const documents = await response.json(); flowchart LR DRAFT --> PENDING --> COMPLETED PENDING --> REJECTED + PENDING --> CANCELLED ``` - **DRAFT to PENDING**: Call the distribute endpoint - **PENDING to COMPLETED**: All recipients complete their signing - **PENDING to REJECTED**: A recipient rejects the document +- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document You cannot modify recipients or fields after a document moves to `PENDING` status. @@ -773,8 +914,8 @@ flowchart LR | Parameter | Values | Description | | ---------- | ------------------------------------------- | ------------------------- | | `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type | -| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status | -| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source | +| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status | +| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source | | `folderId` | string | Filter by folder | ### Sorting @@ -800,10 +941,10 @@ async function getAllPendingDocuments() { }, ); - const { data, pagination } = await response.json(); + const { data, currentPage, totalPages } = await response.json(); documents.push(...data); - hasMore = page < pagination.totalPages; + hasMore = currentPage < totalPages; page++; } diff --git a/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx b/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx index 2bd5c8568..5a719e90c 100644 --- a/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx +++ b/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx @@ -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/{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/beta` | `POST /api/v2/envelope/create` | | `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/{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/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | | `POST /api/v2/template/update` | `POST /api/v2/envelope/update` | diff --git a/apps/docs/content/docs/developers/api/rate-limits.mdx b/apps/docs/content/docs/developers/api/rate-limits.mdx index 95b0a68fe..878db97b6 100644 --- a/apps/docs/content/docs/developers/api/rate-limits.mdx +++ b/apps/docs/content/docs/developers/api/rate-limits.mdx @@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability. ## HTTP Rate Limits +The rate limit applies to: + +- `/api/v1/*` +- `/api/v2/*` +- `/api/v2-beta/*` + **Limit:** 1000 requests per minute per IP address **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. -### Rate Limit Response +### Global per-IP 429 Response ```json { @@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability. } ``` - - No rate limit headers are currently provided. When you receive a 429 response, wait at least 60 - seconds before retrying. - +### Rate Limit Headers + +Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only +exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs +and carry no rate limit headers: + +| Header | Description | +| ----------------------- | ---------------------------------------------------------------------- | +| `X-RateLimit-Limit` | Maximum requests allowed in the current global window | +| `X-RateLimit-Remaining` | Requests remaining in the current global window | +| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds | + +A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum +value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait +until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping +for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns). ## 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 | | 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 { - "error": "You have reached your document limit for this month. Please upgrade your plan.", - "code": "LIMIT_EXCEEDED", - "statusCode": 400 + "message": "Too many requests, please try again later. Contact support if you require higher limits." } ``` +On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object: + +```json +{ + "message": "Too many requests, please try again later. Contact support if you require higher limits.", + "code": "TOO_MANY_REQUESTS", + "data": { + "code": "TOO_MANY_REQUESTS", + "httpStatus": 429, + "appError": { + "code": "TOO_MANY_REQUESTS", + "message": "Too many requests, please try again later. Contact support if you require higher limits." + } + } +} +``` + +Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for +their own window. Monthly quota responses carry no quota-specific rate limit headers or +`Retry-After` because the quota is not a time window; rely on the status code and message instead. + ## Error Codes -| Code | Status | Description | -| ------------------- | ------ | ----------------------------- | -| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded | -| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded | +| Code | Status | Description | +| ------------------- | ------ | ------------------------------------------------------------------ | +| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded | +| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded | + +There are three sources of `TOO_MANY_REQUESTS` responses: + +1. The global per-IP limit, returning the `{ "error": "..." }` body shown above. +2. Organisation windowed rate limits for the `api`, `document`, and `email` counters. +3. Organisation monthly quotas for the same three counters. Every authenticated API request + consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is + exhausted — not just envelope-related ones. --- diff --git a/apps/docs/content/docs/developers/api/teams.mdx b/apps/docs/content/docs/developers/api/teams.mdx index 99d708b41..0d869c56c 100644 --- a/apps/docs/content/docs/developers/api/teams.mdx +++ b/apps/docs/content/docs/developers/api/teams.mdx @@ -95,7 +95,7 @@ Documents created with a team token belong to that team: ```bash curl -X POST "https://app.documenso.com/api/v2/envelope/create" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: multipart/form-data" \ -F 'payload={ "type": "DOCUMENT", @@ -157,11 +157,11 @@ Retrieve all documents belonging to the team: ```bash # List all team documents curl -X GET "https://app.documenso.com/api/v2/envelope" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" # Filter by status curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" ```` @@ -191,7 +191,7 @@ Templates created with a team token are shared across the team. ```bash curl -X POST "https://app.documenso.com/api/v2/template/create" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: multipart/form-data" \ -F 'payload={ "title": "NDA Template", @@ -269,7 +269,7 @@ console.log('Created team template:', template.id); ```bash curl -X GET "https://app.documenso.com/api/v2/template" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" ```` diff --git a/apps/docs/content/docs/developers/examples/common-workflows.mdx b/apps/docs/content/docs/developers/examples/common-workflows.mdx index 704bf415f..5bdf32cdf 100644 --- a/apps/docs/content/docs/developers/examples/common-workflows.mdx +++ b/apps/docs/content/docs/developers/examples/common-workflows.mdx @@ -1000,9 +1000,12 @@ async function fetchWithRetry( // Retry on rate limit if (response.status === 429) { 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...`); - await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs))); + await new Promise((resolve) => setTimeout(resolve, delay)); continue; } 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/content/docs/self-hosting/deployment/docker.mdx b/apps/docs/content/docs/self-hosting/deployment/docker.mdx index 68508e767..d8ba9c70a 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker.mdx @@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran | Variable | Description | Default | | ------------------------------------------- | -------------------------------------------------------------- | ------------------------- | | `PORT` | Port the application listens on | `3000` | -| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — set to the volume-mount path (e.g. `/opt/documenso/cert.p12`). Only Docker Compose defaults this; plain `docker run` must set it explicitly | - | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` | @@ -136,6 +136,7 @@ docker run -d \ -e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \ -e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \ -e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \ + -e NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH="/opt/documenso/cert.p12" \ -e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \ -e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \ -e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \ @@ -154,6 +155,12 @@ A signing certificate is required for document signing. You have two options for - **Volume mount** — mount a `.p12` file from the host into the container at `/opt/documenso/cert.p12` (shown above). This is the simplest approach for small to moderate deployments. - **Base64-encoded contents** — set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` with the base64-encoded certificate string. Use this when file mounting is not available (e.g., Railway, Vercel). + + Plain `docker run` deployments must set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` explicitly. This + prevents production deployments from accidentally using the insecure example certificate. + Docker Compose sets the file path for you. + + For production deployments that require Adobe Approved Trust List recognition, consider using a [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) or another external HSM. @@ -178,6 +185,7 @@ NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000 NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/documenso +NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12 NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth NEXT_PRIVATE_SMTP_HOST=smtp.example.com @@ -203,6 +211,12 @@ docker run -d \ Documenso provides health check endpoints for monitoring: + + If a certificate is mounted but signing fails, ensure `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` + explicitly points to its path inside the container. Production does not use the development + example certificate as a fallback. + + | Endpoint | Purpose | | ------------------------- | -------------------------------------------------------------- | | `/api/health` | Checks database connectivity and certificate status | diff --git a/apps/docs/content/docs/self-hosting/deployment/manual.mdx b/apps/docs/content/docs/self-hosting/deployment/manual.mdx index d6dc4fda5..70f7da640 100644 --- a/apps/docs/content/docs/self-hosting/deployment/manual.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/manual.mdx @@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; ## Prerequisites -- Node.js 22 or later -- npm 11 or later +- Node.js 24 or later +- npm 11.17 or later - PostgreSQL 14 or later - A Linux server (for systemd service setup) diff --git a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx index c64bd081e..b75069906 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx @@ -141,8 +141,8 @@ If building from source (not using Docker images): | Requirement | Version | | ----------- | ------- | -| Node.js | 22+ | -| npm | 11+ | +| Node.js | 24+ | +| npm | 11.17+ | --- @@ -169,7 +169,7 @@ Documenso runs on: | MySQL/MariaDB | PostgreSQL-specific features required | | SQLite | Not suitable for production workloads | | MongoDB | Relational database required | -| Node.js < 22 | Modern JavaScript features required | +| Node.js < 24 | Modern JavaScript features required | --- diff --git a/apps/docs/content/docs/users/organisations/preferences/document.mdx b/apps/docs/content/docs/users/organisations/preferences/document.mdx index 1f4e82c08..d81252325 100644 --- a/apps/docs/content/docs/users/organisations/preferences/document.mdx +++ b/apps/docs/content/docs/users/organisations/preferences/document.mdx @@ -34,7 +34,7 @@ To access the preferences, navigate to either the organisation or teams settings | **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. | | **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). | | **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). | -| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. | +| **Delegate Document Ownership** | By default, documents created with a team API token are owned by the user who created the token. Enable this setting to let supported API requests assign ownership to another team member. | | **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. | Document visibility, language, and signature settings can be overridden per document. 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/Dockerfile.bun b/apps/remix/Dockerfile.bun deleted file mode 100644 index 973038e8a..000000000 --- a/apps/remix/Dockerfile.bun +++ /dev/null @@ -1,25 +0,0 @@ -FROM oven/bun:1 AS dependencies-env -COPY . /app - -FROM dependencies-env AS development-dependencies-env -COPY ./package.json bun.lockb /app/ -WORKDIR /app -RUN bun i --frozen-lockfile - -FROM dependencies-env AS production-dependencies-env -COPY ./package.json bun.lockb /app/ -WORKDIR /app -RUN bun i --production - -FROM dependencies-env AS build-env -COPY ./package.json bun.lockb /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN bun run build - -FROM dependencies-env -COPY ./package.json bun.lockb /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/apps/remix/Dockerfile.pnpm b/apps/remix/Dockerfile.pnpm deleted file mode 100644 index 57916afc2..000000000 --- a/apps/remix/Dockerfile.pnpm +++ /dev/null @@ -1,26 +0,0 @@ -FROM node:20-alpine AS dependencies-env -RUN npm i -g pnpm -COPY . /app - -FROM dependencies-env AS development-dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -WORKDIR /app -RUN pnpm i --frozen-lockfile - -FROM dependencies-env AS production-dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -WORKDIR /app -RUN pnpm i --prod --frozen-lockfile - -FROM dependencies-env AS build-env -COPY ./package.json pnpm-lock.yaml /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN pnpm build - -FROM dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["pnpm", "start"] \ No newline at end of file diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index ed495f83a..0cd5e3c4c 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; @@ -71,6 +72,7 @@ export const EnvelopeDistributeDialog = ({ const { toast } = useToast(); const { t, i18n } = useLingui(); const navigate = useNavigate(); + const analytics = useAnalytics(); const [isOpen, setIsOpen] = useState(false); const [isSyncing, setIsSyncing] = useState(false); @@ -200,6 +202,12 @@ export const EnvelopeDistributeDialog = ({ } catch (err) { const error = AppError.parseError(err); + analytics.captureException(err, { + source: 'editor', + location: 'distribute_document', + envelopeId: envelope.id, + }); + const errorMessage = getDistributeErrorMessage(error.code); toast({ diff --git a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx index 1a54c7893..b2d5da538 100644 --- a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { AppError } from '@documenso/lib/errors/app-error'; import type { TEnvelope } from '@documenso/lib/types/envelope'; @@ -51,6 +52,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: const { toast } = useToast(); const { t, i18n } = useLingui(); + const analytics = useAnalytics(); const [isOpen, setIsOpen] = useState(false); @@ -95,6 +97,13 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: setIsOpen(false); } catch (err) { const error = AppError.parseError(err); + + analytics.captureException(err, { + source: 'editor', + location: 'redistribute_document', + envelopeId: envelope.id, + }); + const errorMessage = getDistributeErrorMessage(error.code); toast({ diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx index 940055854..cc609fabb 100644 --- a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx @@ -290,10 +290,11 @@ export const EnvelopesBulkDownloadDialog = ({ {isOverDownloadLimit && ( - - You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to - continue. - + )} 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/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx index 7e381c82f..7e610f469 100644 --- a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -1,4 +1,7 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv'; import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { @@ -17,7 +20,9 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { File as FileIcon, Upload, X } from 'lucide-react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; import { z } from 'zod'; import { useCurrentTeam } from '~/providers/team'; @@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({ type TBulkSendFormSchema = z.infer; +type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string }; + export type TemplateBulkSendDialogProps = { templateId: number; recipients: Array<{ email: string; name?: string | null }>; @@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc const team = useCurrentTeam(); + const [open, setOpen] = useState(false); + const [validationError, setValidationError] = useState(null); + const form = useForm({ resolver: zodResolver(ZBulkSendFormSchema), defaultValues: { @@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation(); + const onOpenChange = (value: boolean) => { + if (form.formState.isSubmitting) { + return; + } + + setOpen(value); + + if (!value) { + setValidationError(null); + + form.reset(); + } + }; + const onDownloadTemplate = () => { const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]); @@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc }; const onSubmit = async (values: TBulkSendFormSchema) => { + setValidationError(null); + try { const csv = await values.file.text(); - await uploadBulkSend({ + const result = await uploadBulkSend({ templateId, teamId: team?.id, csv: csv, sendImmediately: values.sendImmediately, }); + if (!result.success) { + setValidationError(result.error); + + return; + } + toast({ title: _(msg`Success`), description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`), }); + setOpen(false); form.reset(); + onSuccess?.(); } catch (err) { console.error(err); - toast({ - title: _(msg`Error`), - description: _(msg`Failed to upload CSV. Please check the file format and try again.`), - variant: 'destructive', - }); + const error = AppError.parseError(err); + + setValidationError({ type: 'UPLOAD_ERROR', code: error.code }); } }; return ( - + {trigger ?? ( diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 20a0a23fa..3e2ca1d38 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; @@ -77,6 +78,7 @@ export const EmbedDirectTemplateClientPage = ({ }: EmbedDirectTemplateClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const [searchParams] = useSearchParams(); @@ -264,6 +266,13 @@ export const EmbedDirectTemplateClientPage = ({ const error = AppError.parseError(err); const errorMessage = getDirectTemplateErrorMessage(error.code); + analytics.captureException(err, { + source: 'embed', + location: 'direct_template', + recipientId: recipient.id, + envelopeId, + }); + toast({ title: _(errorMessage.title), description: _(errorMessage.description), @@ -308,6 +317,14 @@ export const EmbedDirectTemplateClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + envelopeId, + }); + setHasFinishedInit(true); } 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..739a5c8a6 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,8 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; 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 +34,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'; @@ -73,6 +76,7 @@ export const EmbedSignDocumentV1ClientPage = ({ }: EmbedSignDocumentV1ClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext(); @@ -152,6 +156,14 @@ export const EmbedSignDocumentV1ClientPage = ({ setHasCompletedDocument(true); } catch (err) { + analytics.captureException(err, { + source: 'embed', + location: 'complete_document', + recipientId: recipient.id, + documentId, + envelopeId, + }); + if (window.parent) { window.parent.postMessage( { @@ -162,9 +174,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', }); } @@ -231,6 +246,15 @@ export const EmbedSignDocumentV1ClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + documentId, + envelopeId, + }); + setHasFinishedInit(true); } diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx index 969511713..55e7cb6d1 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; @@ -25,6 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({ allowWhitelabelling = false, }: EmbedSignDocumentV2ClientPageProps) => { const { _ } = useLingui(); + const analytics = useAnalytics(); const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } = useRequiredEnvelopeSigningContext(); @@ -170,6 +172,14 @@ export const EmbedSignDocumentV2ClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + envelopeId: envelope.id, + }); + setHasFinishedInit(true); } 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..8964033a6 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 @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -26,6 +27,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'; @@ -56,6 +58,7 @@ export const MultiSignDocumentSigningView = ({ }: MultiSignDocumentSigningViewProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); @@ -100,6 +103,13 @@ export const MultiSignDocumentSigningView = ({ console.error(err); + analytics.captureException(err, { + source: 'embed', + location: 'sign_field', + recipientId, + documentId: document?.id, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while signing the document.`), @@ -119,6 +129,13 @@ export const MultiSignDocumentSigningView = ({ } console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'remove_field', + recipientId, + documentId: document?.id, + }); } }; @@ -139,11 +156,21 @@ export const MultiSignDocumentSigningView = ({ recipientId, }); } catch (err) { + analytics.captureException(err, { + source: 'embed', + location: 'complete_document', + recipientId, + documentId: document?.id, + }); + 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/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/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index 3a704445e..2e42b5c4e 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, @@ -16,9 +17,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; +import { Plural, 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, @@ -595,9 +596,21 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => { {hasValidSearch ? ( - {formatChipCount(totalVisibleCount, isVisibleCountCapped)} results + isVisibleCountCapped ? ( + {formatChipCount(totalVisibleCount, isVisibleCountCapped)} results + ) : ( + + ) ) : ( - {totalVisibleCount} items + )} @@ -862,7 +875,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/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 9f6ea47dc..58ea7d50f 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { AppError } from '@documenso/lib/errors/app-error'; import type { TTemplate } from '@documenso/lib/types/template'; @@ -41,6 +42,7 @@ export const DirectTemplatePageView = ({ const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const { email, fullName, setEmail } = useRequiredDocumentSigningContext(); const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext(); @@ -124,6 +126,13 @@ export const DirectTemplatePageView = ({ const error = AppError.parseError(err); const errorMessage = getDirectTemplateErrorMessage(error.code); + analytics.captureException(err, { + source: 'signing', + location: 'direct_template', + recipientId: directTemplateRecipient.id, + envelopeId: template.envelopeId, + }); + toast({ title: _(errorMessage.title), description: _(errorMessage.description), diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx index 473166f7a..509cddced 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx @@ -1,4 +1,5 @@ import { authClient } from '@documenso/auth/client'; +import { formatPath } from '@documenso/lib/constants/app'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; @@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({ const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; await authClient.signOut({ - redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`, + redirectPath: formatPath( + `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`, + ), }); } catch { setIsSigningOut(false); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx index be9e409a0..a762eef57 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx @@ -1,4 +1,5 @@ import { authClient } from '@documenso/auth/client'; +import { formatPath } from '@documenso/lib/constants/app'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { msg } from '@lingui/core/macro'; @@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document try { setIsSigningOut(true); - let redirectPath = '/signin'; + let redirectPath = formatPath('/signin'); if (email) { - redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`; + redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`); } await authClient.signOut({ diff --git a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx index 7e9a7adb6..dcf2e2d81 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -39,6 +40,7 @@ export const DocumentSigningCheckboxField = ({ const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -126,6 +128,13 @@ export const DocumentSigningCheckboxField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -157,6 +166,13 @@ export const DocumentSigningCheckboxField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), @@ -216,6 +232,13 @@ export const DocumentSigningCheckboxField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while updating the signature.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index ee4b0b626..f5508f727 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; @@ -14,6 +15,7 @@ import { } from '@documenso/ui/primitives/dialog'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import type { Field, Recipient } from '@prisma/client'; @@ -27,6 +29,8 @@ import { useEmbedSigningContext } from '~/components/embed/embed-signing-context import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; + import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; export type DocumentSigningCompleteDialogProps = { @@ -85,7 +89,9 @@ export const DocumentSigningCompleteDialog = ({ position, disableNameInput = false, }: DocumentSigningCompleteDialogProps) => { - const { t } = useLingui(); + const analytics = useAnalytics(); + const { t, i18n } = useLingui(); + const { toast } = useToast(); const [showDialog, setShowDialog] = useState(false); @@ -174,6 +180,23 @@ export const DocumentSigningCompleteDialog = ({ return; } + + analytics.captureException(error, { + source: 'signing', + location: 'complete_document', + }); + + // 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', + }); } }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx index 1d05d3017..51135630c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; @@ -39,6 +40,7 @@ export const DocumentSigningDateField = ({ const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -85,6 +87,13 @@ export const DocumentSigningDateField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -113,6 +122,13 @@ export const DocumentSigningDateField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx index 2ac26f87c..549eb6128 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -35,6 +36,7 @@ export const DocumentSigningDropdownField = ({ const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -86,6 +88,13 @@ export const DocumentSigningDropdownField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -120,6 +129,13 @@ export const DocumentSigningDropdownField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx index 408914336..8e88d99be 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -33,6 +34,7 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }: const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { email: providedEmail } = useRequiredDocumentSigningContext(); @@ -78,6 +80,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }: console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -106,6 +115,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }: } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index cbe467f36..2d3b70540 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -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 { TRecipientAccessAuth } from '@documenso/lib/types/document-auth'; 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 { useNavigate } from 'react-router'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; + import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; @@ -100,9 +103,12 @@ export const DocumentSigningForm = ({ try { await completeDocument({ nextSigner }); } catch (err) { + const error = AppError.parseError(err); + const toastMessage = getSigningCompletionErrorMessage(error.code); + toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while completing the document. Please try again.`), + title: _(toastMessage.title), + description: _(toastMessage.description), variant: 'destructive', }); diff --git a/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx index 6a9b74a3d..b79bf40fe 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -38,6 +39,7 @@ export const DocumentSigningInitialsField = ({ const { toast } = useToast(); const { _ } = useLingui(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { fullName } = useRequiredDocumentSigningContext(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -84,6 +86,13 @@ export const DocumentSigningInitialsField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -112,6 +121,13 @@ export const DocumentSigningInitialsField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx index 71ebe6995..0b4660e5d 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -39,6 +40,7 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }: const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext(); @@ -116,6 +118,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }: console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -144,6 +153,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }: } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx index 01f0e9714..0dd92c0ed 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx @@ -1,4 +1,5 @@ import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -47,6 +48,7 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField } const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -142,6 +144,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField } console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -193,6 +202,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField } } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 1979c63a2..4fa8077a0 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -1,4 +1,3 @@ -import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; @@ -83,8 +82,6 @@ export const DocumentSigningPageViewV1 = ({ ? authUser.twoFactorEnabled && authUser.email === recipient.email : false; - const analytics = useAnalytics(); - const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id); const [isExpanded, setIsExpanded] = useState(false); @@ -118,12 +115,6 @@ export const DocumentSigningPageViewV1 = ({ await completeDocumentWithToken(payload); - analytics.capture('App: Recipient has completed signing', { - signerId: recipient.id, - documentId: document.id, - timestamp: new Date().toISOString(), - }); - if (documentMeta?.redirectUrl) { window.location.href = documentMeta.redirectUrl; } else { diff --git a/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx index 71a0cfa38..b155344db 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -31,6 +32,7 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -91,6 +93,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -120,6 +129,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the selection.`), @@ -146,14 +162,17 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: {isLoading && } {!field.inserted && ( - handleSelectItem(value)} className="z-10 my-0.5 gap-y-1"> + handleSelectItem(value)} + className="z-10 my-0.5 gap-y-1" + > {values?.map((item, index) => (
{!item.value.includes('empty-value-') && item.value && ( @@ -167,14 +186,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: )} {field.inserted && ( - + {values?.map((item, index) => (
{!item.value.includes('empty-value-') && item.value && ( diff --git a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx index ba70ad048..f0d07eac8 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -41,6 +42,7 @@ export function DocumentSigningRejectDialog({ }: DocumentSigningRejectDialogProps) { const { t } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const [searchParams] = useSearchParams(); const [isOpen, setIsOpen] = useState(false); @@ -76,6 +78,12 @@ export function DocumentSigningRejectDialog({ window.location.href = `/sign/${token}/rejected`; } } catch (err) { + analytics.captureException(err, { + source: 'signing', + location: 'reject_document', + documentId, + }); + toast({ title: t`Error`, description: t`An error occurred while rejecting the document. Please try again.`, diff --git a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx index 3ff8b1d68..ecab0b1bc 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -47,6 +48,7 @@ export const DocumentSigningSignatureField = ({ const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient } = useDocumentSigningRecipientContext(); @@ -157,6 +159,13 @@ export const DocumentSigningSignatureField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while signing the document.`), @@ -183,6 +192,13 @@ export const DocumentSigningSignatureField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the signature.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx index f3f12063a..b99b7337c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx @@ -1,4 +1,5 @@ import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -50,6 +51,7 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }: const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -170,6 +172,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }: console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -200,6 +209,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }: } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx index c53cd93c0..5be052dfc 100644 --- a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx +++ b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -1,5 +1,4 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; -import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; @@ -38,7 +37,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu const team = useCurrentTeam(); const navigate = useNavigate(); - const analytics = useAnalytics(); const organisation = useCurrentOrganisation(); const userTimezone = @@ -103,12 +101,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu description: _(msg`Your document has been uploaded successfully.`), duration: 5000, }); - - analytics.capture('App: Document Uploaded', { - userId: user.id, - documentId: id, - timestamp: new Date().toISOString(), - }); } // Handle legacy template creation. diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index 698f83651..e82232311 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; @@ -37,8 +38,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector'; +/** How far past a resize handle you can still grab it, in screen pixels. */ +const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24; + export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => { const { t, i18n } = useLingui(); + const analytics = useAnalytics(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); @@ -276,6 +281,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR unsafeRenderFieldOnLayer(field); } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'editor', + location: 'envelope_page_render', + envelopeId: envelope.id, + }); + setRenderError(true); } }; @@ -350,6 +362,9 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR shouldOverdrawWholeArea: true, ignoreStroke: true, flipEnabled: false, + anchorStyleFunc: (anchor) => { + anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / scale); + }, boundBoxFunc: (oldBox, newBox) => { // Enforce minimum size if (newBox.width < 30 || newBox.height < 20) { diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index c6fb38938..73493c385 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -1,4 +1,5 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; @@ -45,6 +46,7 @@ export const EnvelopeEditorUploadPage = () => { const { t, i18n } = useLingui(); const { maximumEnvelopeItemCount, remaining } = useLimits(); const { toast } = useToast(); + const analytics = useAnalytics(); const { envelope, @@ -213,6 +215,12 @@ export const EnvelopeEditorUploadPage = () => { const { data } = await createPromise.catch((error) => { console.error(error); + analytics.captureException(error, { + source: isEmbedded ? 'embed' : 'editor', + location: 'create_envelope_items', + envelopeId: envelope.id, + }); + // Set error state on files in batch upload. setLocalFiles((prev) => prev.map((uploadingFile) => @@ -290,6 +298,12 @@ export const EnvelopeEditorUploadPage = () => { } catch (error) { console.error(error); + analytics.captureException(error, { + source: isEmbedded ? 'embed' : 'editor', + location: 'replace_pdf', + envelopeId: envelope.id, + }); + toast({ title: t`Replace failed`, description: t`Something went wrong while replacing the PDF`, diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index 7fd72c703..015012a73 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { type PageRenderData, @@ -18,6 +19,7 @@ type GenericLocalField = TEnvelope['fields'][number] & { export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => { const { i18n } = useLingui(); + const analytics = useAnalytics(); const { envelopeStatus, @@ -114,6 +116,13 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender unsafeRenderFieldOnLayer(field); } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'editor', + location: 'envelope_page_render', + envelopeId: currentEnvelopeItem?.envelopeId, + }); + setRenderError(true); } }; diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx index 8caab7ac0..1c5c54ca0 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { type PageRenderData, @@ -53,6 +54,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { toast } = useToast(); + const analytics = useAnalytics(); const { envelopeData, @@ -426,6 +428,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache); } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'signing', + location: 'page_render', + recipientId: recipient.id, + envelopeId: envelope.id, + }); + setRenderError(true); } }; @@ -507,6 +517,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: payload.type, + recipientId: recipient.id, + envelopeId: envelope.id, + }); + toast({ title: t`Error`, description: t`An error occurred while signing the field.`, diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx index 53f125c7f..c522419ca 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx @@ -118,12 +118,6 @@ export const EnvelopeSignerCompleteDialog = () => { title: t`Document already signed`, description: t`This document was already signed and no further action was taken.`, }); - } else { - analytics.capture('App: Recipient has completed signing', { - signerId: recipient.id, - documentId: envelope.id, - timestamp: new Date().toISOString(), - }); } if (onDocumentCompleted) { @@ -148,15 +142,18 @@ export const EnvelopeSignerCompleteDialog = () => { const error = AppError.parseError(err); 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', + analytics.captureException(err, { + source: 'signing', + location: 'complete_document', + recipientId: recipient.id, + envelopeId: envelope.id, }); onDocumentError?.(); } + // Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and + // toast a specific completion error message. throw err; } }; @@ -224,14 +221,18 @@ export const EnvelopeSignerCompleteDialog = () => { } } catch (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', + + analytics.captureException(err, { + source: 'signing', + location: 'complete_document_next_signer', + recipientId: recipient.id, + envelopeId: envelope.id, }); onDocumentError?.(); + // Rethrow so DocumentSigningCompleteDialog can toast a specific + // completion error message. throw err; } }; diff --git a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx index 3b1a0b188..cf7bfb67c 100644 --- a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx @@ -93,14 +93,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD duration: 5000, }); - if (type === EnvelopeType.DOCUMENT) { - analytics.capture('App: Document Uploaded', { - userId: user.id, - documentId: id, - timestamp: new Date().toISOString(), - }); - } - const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url); const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : ''; @@ -109,6 +101,11 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD } catch (err) { const error = AppError.parseError(err); + analytics.captureException(err, { + source: 'editor', + location: 'upload_document', + }); + const errorMessage = getUploadErrorMessage(error.code); toast({ diff --git a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx index 25611fd40..c177eb8fb 100644 --- a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx @@ -1,4 +1,5 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; @@ -34,6 +35,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo const { t, i18n } = useLingui(); const { toast } = useToast(); const { user } = useSession(); + const analytics = useAnalytics(); const team = useCurrentTeam(); @@ -112,6 +114,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo console.error(err); + analytics.captureException(err, { + source: 'editor', + location: 'upload_document', + }); + const errorMessage = getUploadErrorMessage(error.code); toast({ diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx index 56c5308d6..3fa39a232 100644 --- a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx +++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer'; import { cn } from '@documenso/ui/lib/utils'; @@ -68,6 +69,7 @@ export default function PDFViewer({ }: PDFViewerProps) { const { t } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const $el = useRef(null); @@ -150,6 +152,11 @@ export default function PDFViewer({ console.error(err); setLoadingState('error'); + analytics.captureException(err, { + source: 'pdf_viewer', + location: 'pdf_load', + }); + toast({ title: t`Error`, description: t`An error occurred while loading the document.`, @@ -215,7 +222,7 @@ export default function PDFViewer({ type VirtualizedPageListProps = { scrollParentRef: ScrollTarget; - constraintRef: React.RefObject; + constraintRef: React.RefObject; pages: PageMeta[]; numPages: number; pdf: pdfjsLib.PDFDocumentProxy; @@ -366,6 +373,8 @@ const PdfViewerPage = ({ * Manages rendering a page from a pdf. */ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => { + const analytics = useAnalytics(); + const [imageLoadingState, setImageLoadingState] = useState('loading'); const [imageUrl, setImageUrl] = useState(''); @@ -457,6 +466,12 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: if (!isCancelled) { console.error(err); + + analytics.captureException(err, { + source: 'pdf_viewer', + location: 'pdf_page_render', + }); + setImageLoadingState('error'); } } finally { diff --git a/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx b/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx new file mode 100644 index 000000000..f74432cf7 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx @@ -0,0 +1,195 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { Trans } from '@lingui/react/macro'; +import { motion, useReducedMotion } from 'framer-motion'; + +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +const DEMO_BRANDS = [ + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#A2E771', + ink: '#162C07', + tint: '#F2FBEA', + sheen: 'rgba(162, 231, 113, 0.32)', + }, + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#387BC7', + ink: '#ffffff', + tint: '#EDF3FA', + sheen: 'rgba(56, 123, 199, 0.28)', + }, + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#9747F5', + ink: '#ffffff', + tint: '#F4EDFE', + sheen: 'rgba(151, 71, 245, 0.26)', + }, +]; + +/** + * Milliseconds each brand is shown before cycling to the next. + */ +const BRAND_CYCLE_INTERVAL_MS = 2400; + +export const BrandingUpsell = () => { + const organisation = useCurrentOrganisation(); + + const isReducedMotion = useReducedMotion(); + const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS)); + + const isStatic = isReducedMotion ?? false; + + const brand = DEMO_BRANDS[brandIndex]; + + return ( + Teams} + title={Unlock Branding Preferences} + description={ + Put your own brand on every document you send. Branding is available on the Teams plan and above. + } + features={[ + Your logo on signing pages and emails, + Company details and website in email footers, + Separate branding per team, + ]} + preview={ +
+
+ + Brand accent + + +
+ {DEMO_BRANDS.map((dotBrand, index) => ( + + ))} +
+
+ +
+ + {/* The sender identity never changes — only the tile colours tween per brand. */} + + {brand.letter} + + + {/* + * Hardcoded inks (not theme tokens): this row sits on the + * hardcoded light `tint` band, so it pairs with hardcoded ink + * colours the same way the email sibling pairs its hardcoded + * avatar surfaces (hardcoded surface => hardcoded ink). + */} +
+
+

{brand.name}

+
+ +
+

{brand.domain}

+
+
+
+ +
+

+ Please sign: Example.pdf +

+ +

+ {organisation.name} has invited you to sign this document. +

+ + {/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */} + + + Sign + + +
+ +
+ + Company details + + + + + +
+ + {/* + * Keyed remount replays the sweep per brand. No opacity envelope — + * keyframe arrays are unreliable on strict-mode remounts; both + * endpoints sit outside the overflow-hidden card, so the clip + * provides the fade in/out instead. + */} + +
+
+ } + /> + ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx b/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx new file mode 100644 index 000000000..183ad3b80 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx @@ -0,0 +1,213 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Trans } from '@lingui/react/macro'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { BadgeCheckIcon, MailIcon } from 'lucide-react'; +import { BrandingLogoIcon } from '../branding-logo-icon'; +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +/** + * Named sender identities cycled through while the preview is in its branded + * state — one per branded cycle step, shown as the sender name and address. + */ +const BRANDED_SENDERS = [ + { name: 'Support', email: 'support@example.com' }, + { name: 'Team', email: 'hello@example.com' }, + { name: 'Sales', email: 'sales@example.com' }, + { name: 'Example', email: 'noreply@example.com' }, +]; + +/** + * How long the initial unbranded (Documenso default) state is shown before + * the first flip starts. Shown exactly once — the cycle never returns to it. + */ +const INITIAL_STATE_DURATION_MS = 2500; + +/** + * How long each branded sender identity is shown before cycling to the next, + * giving the viewer time to read the changed address. + */ +const BRANDED_STATE_DURATION_MS = 5000; + +/** + * One duration per cycle step: the unbranded state first, then one step per + * named sender identity, derived from the identity count so the two cannot + * drift. + */ +const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)]; + +export const EmailDomainsUpsell = () => { + const organisation = useCurrentOrganisation(); + + const isReducedMotion = useReducedMotion(); + + // Loop from index 1: the unbranded Documenso intro plays exactly once, + // then the cycle rotates through the branded senders only. + const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1); + + const isBranded = cycleIndex > 0; + const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0]; + + const isStatic = isReducedMotion ?? false; + + return ( + Enterprise} + title={Unlock Email Domains} + description={ + Send documents from your own domain. Email domains are available on the Enterprise plan. + } + features={[ + Send emails to recipients from your domain, + Easy DNS setup with auto-generated DKIM and SPF records, + Named senders with defaults per team, template or document, + ]} + ctaLabel={Contact Sales} + ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL} + ctaExternal + preview={ +
+
+ + + {isBranded ? ( + + ) : ( + + )} + + + {isBranded ? Sending from your domain : Sending from app.documenso.com} + + + +
+ +
+
+
+ + + {/* + * Remounts with its keyed parent on every cycle step so the + * pop replays each change. Single-value spring (POP + * overshoots past 1) instead of scale keyframes — keyframe + * arrays are unreliable on strict-mode remounts. + */} + + {isBranded ? ( + + {organisation.avatarImageId && ( + + )} + {brandedSender.name[0]} + + ) : ( + + )} + + + +
+ +
+
+ + + {isBranded ? brandedSender.name : 'Documenso'} + + {/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */} + {isBranded && ( + + + + )} + + +
+ +
+ + + {isBranded ? brandedSender.email : 'noreply@app.documenso.com'} + + +
+
+
+ +
+

+ Please sign: Example.pdf +

+ +

+ {organisation.name} has invited you to sign this document. +

+
+ + {/* + * Keyed remount replays the sweep on every cycle step. No opacity + * envelope — keyframe arrays are unreliable on strict-mode + * remounts; both endpoints sit outside the overflow-hidden card, + * so the clip provides the fade in/out instead. + */} + +
+
+ } + /> + ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/motion.ts b/apps/remix/app/components/general/settings-upsell/motion.ts new file mode 100644 index 000000000..da8ee359f --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/motion.ts @@ -0,0 +1,9 @@ +/** + * Shared motion vocabulary for the settings upsell previews. Values ported + * from the design prototype (`design/SSO Upsell.dc.html`). + */ +export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const; + +export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const; + +export const EASE = [0.22, 0.61, 0.36, 1] as const; diff --git a/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx b/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx new file mode 100644 index 000000000..054ef875f --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx @@ -0,0 +1,118 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans } from '@lingui/react/macro'; +import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; + +export type SettingsUpsellCardProps = { + planLabel: ReactNode; + title: ReactNode; + description: ReactNode; + features: ReactNode[]; + preview: ReactNode; + + /** + * CTA label. Defaults to "Upgrade Plan". + */ + ctaLabel?: ReactNode; + + /** + * CTA destination. Defaults to the organisation billing settings page. + */ + ctaTo?: string; + + /** + * Render the CTA as an external link (new tab) instead of an internal route. + */ + ctaExternal?: boolean; +}; + +/** + * Shared split-card layout for claim-gated settings upsells on Documenso + * Cloud. The left pane pitches the feature (plan badge, title, description, + * feature list, upgrade CTA); the right pane renders a decorative scenario + * preview supplied by the caller. + * + * Callers decide *when* to render this (cloud + missing claim flag). + */ +export const SettingsUpsellCard = ({ + planLabel, + title, + description, + features, + preview, + ctaLabel, + ctaTo, + ctaExternal = false, +}: SettingsUpsellCardProps) => { + const organisation = useCurrentOrganisation(); + + const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole); + + const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`; + + const ctaContent = ( + <> + {ctaLabel ?? Upgrade Plan} + + + ); + + return ( +
+ {/* + * `min-w-0` on both grid items: `fr` tracks have an `auto` content + * minimum, so long preview content (e.g. a wide mono domain line) would + * otherwise widen the right track beyond its 0.92fr share — and + * re-balance the whole grid on every preview cycle (layout shift). + */} +
+ + + {planLabel} + + +

{title}

+ +

{description}

+ +
    + {features.map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+ + {canManageBilling ? ( + + ) : ( +

+ Contact your organisation owner to upgrade plans. +

+ )} +
+ + +
+ ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx b/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx new file mode 100644 index 000000000..1444c1ea1 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx @@ -0,0 +1,260 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app'; +import { Trans } from '@lingui/react/macro'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { FingerprintIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +export const SsoPortalUpsell = () => { + const isReducedMotion = useReducedMotion(); + const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS); + + return ( + Enterprise} + title={Unlock the Organisation SSO Portal} + description={ + + Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan. + + } + features={[ + Works with any OIDC provider — Okta, Entra ID, Google and more, + Accounts are automatically added to your organisation on sign-in, + Restrict sign-ins by email domain and choose the default role, + ]} + ctaLabel={Contact Sales} + ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL} + ctaExternal + preview={ +
+
+ + {sceneIndex === 0 && } + {sceneIndex === 1 && } + {sceneIndex === 2 && } + +
+
+ } + /> + ); +}; + +/** + * Absolute-positioned panel each scene renders in, handling the shared + * slide-and-fade transition between scenes. + */ +const ScenePanel = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +/** + * Scene 1: the organisation's SSO portal, with a timed faux press on the + * "Continue with SSO" button (cursor flies in, button dips, sheen sweeps). + * + * When `isStatic` is set (reduced motion) every element renders with + * `initial={false}`, skipping entrance and press animations. + */ +const PortalScene = ({ isStatic }: { isStatic: boolean }) => { + const organisation = useCurrentOrganisation(); + + const rise = (delay: number) => ({ + initial: isStatic ? false : { y: 10, opacity: 0 }, + animate: { y: 0, opacity: 1 }, + transition: { ...SPRING, delay }, + }); + + return ( + + +
+ {([...organisation.name][0] ?? 'D').toUpperCase()} +
+
+ + + Welcome to {organisation.name} + + + + Single sign-on + + +
+ + + Continue with SSO + + + + + + + + +
+
+ ); +}; + +/** + * Scene 2: redirecting to the identity provider, with a rotating ring around + * a fingerprint tile and a filling progress bar. + */ +const RedirectScene = () => { + return ( + +
+ + +
+ +
+
+ + + Redirecting to your identity provider + + +
+ +
+
+ ); +}; + +/** + * Scene 3: signed in, with an expanding pulse ring, a popping green circle, + * a drawn checkmark and the signed-in member's email. + */ +const SuccessScene = () => { + const { user } = useSession(); + + return ( + +
+ + + + + + + +
+ + + Signed in + + + + {user.email} + +
+ ); +}; + +/** + * Milliseconds each scene is shown before advancing: portal, redirect, + * success. + */ +const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000]; diff --git a/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts b/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts new file mode 100644 index 000000000..57e9b952d --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts @@ -0,0 +1,47 @@ +import { useReducedMotion } from 'framer-motion'; +import { useEffect, useState } from 'react'; + +/** + * Cycles an index through `durations.length` steps, waiting `durations[i]` + * milliseconds on step `i` before advancing to the next. + * + * When the cycle wraps past the last step it continues from `loopStartIndex` + * (default `0`), letting consumers play intro-only steps exactly once and + * then loop through the remaining steps forever. + * + * Under `prefers-reduced-motion` the cycle never starts and the index stays + * at 0, so consumers render their initial state statically. + * + * Pass module-level constants for `durations` and `loopStartIndex` — their + * identities are intentionally not dependencies. + */ +export const useTimedCycle = (durations: number[], loopStartIndex = 0) => { + const [index, setIndex] = useState(0); + + const isReducedMotion = useReducedMotion(); + + useEffect(() => { + if (isReducedMotion || durations.length === 0) { + setIndex(0); + return; + } + + let current = 0; + let timeout: ReturnType; + + const tick = () => { + const next = current + 1; + + current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next; + + setIndex(current); + timeout = setTimeout(tick, durations[current]); + }; + + timeout = setTimeout(tick, durations[0]); + + return () => clearTimeout(timeout); + }, [isReducedMotion]); + + return index; +}; diff --git a/apps/remix/app/components/general/use-admin-search-categories.ts b/apps/remix/app/components/general/use-admin-search-categories.ts index fc950b708..547fe7afb 100644 --- a/apps/remix/app/components/general/use-admin-search-categories.ts +++ b/apps/remix/app/components/general/use-admin-search-categories.ts @@ -39,13 +39,14 @@ const ADMIN_GROUP_ICONS: Record = { /** * Admin list pages which support prefilling their search from the URL, used - * for the "View all results" links on capped groups. Teams, recipients and + * for the "View all results" links on capped groups. Teams and * subscriptions have no admin list pages. */ const ADMIN_GROUP_LIST_PATHS: Partial string>> = { document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`, user: (query) => `/admin/users?search=${encodeURIComponent(query)}`, organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`, + recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`, }; export type UseAdminSearchCategoriesOptions = { diff --git a/apps/remix/app/entry.client.tsx b/apps/remix/app/entry.client.tsx index 0ffb3602e..57daa65e3 100644 --- a/apps/remix/app/entry.client.tsx +++ b/apps/remix/app/entry.client.tsx @@ -18,6 +18,23 @@ import './utils/polyfills/promise-with-resolvers'; * the page early, leaving dead event handlers (broken dropdowns, native form * submits). */ +/** + * Signing and direct template URLs contain recipient tokens which must never + * be sent to PostHog. Recipient context is attached explicitly via + * `recipientId` where needed instead. + */ +const redactTokensFromUrl = (value: string) => { + return value.replace(/(\/(?:sign|d|direct)\/)([^/?#]+)/g, '$1:token'); +}; + +const URL_EVENT_PROPERTIES = [ + '$current_url', + '$pathname', + '$referrer', + '$initial_referrer', + '$prev_pageview_pathname', +] as const; + function initPosthog() { const postHogConfig = extractPostHogConfig(); @@ -25,12 +42,59 @@ function initPosthog() { void import('posthog-js').then(({ default: posthog }) => { posthog.init(postHogConfig.key, { api_host: postHogConfig.host, + // Only create person profiles for identified (authenticated) users, + // anonymous recipients on signing pages stay anonymous. + person_profiles: 'identified_only', + // Explicit events only, autocapture on signing pages blows up usage + // without providing actionable data. + autocapture: false, + capture_pageview: true, + capture_pageleave: false, capture_exceptions: true, + before_send: (event) => { + if (!event) { + return null; + } + + for (const property of URL_EVENT_PROPERTIES) { + const value = event.properties?.[property]; + + if (typeof value === 'string') { + event.properties[property] = redactTokensFromUrl(value); + } + } + + if (event.$set_once && typeof event.$set_once['$initial_current_url'] === 'string') { + event.$set_once['$initial_current_url'] = redactTokensFromUrl(event.$set_once['$initial_current_url']); + } + + return event; + }, }); }); } } +/** + * Surfaces hydration recoveries (React 19 discards the server HTML and + * re-renders on the client instead of dying) so we can track how often + * extensions/early clicks interfere with hydration in the wild. + */ +function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) { + console.error('[hydration] recovered from error', error, errorInfo.componentStack); + + if (extractPostHogConfig()) { + void import('posthog-js').then(({ default: posthog }) => { + if (posthog.__loaded) { + posthog.capture('$hydration_recoverable_error', { + message: error instanceof Error ? error.message : String(error), + componentStack: errorInfo.componentStack, + }); + } + }); + } +} + async function main() { const locale = detect(fromHtmlTag('lang')) || 'en'; @@ -44,6 +108,7 @@ async function main() { , + { onRecoverableError }, ); }); diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index a7c29b4be..baffaa6ec 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -1,5 +1,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { SessionProvider } from '@documenso/lib/client-only/providers/session'; +import { getBasePath } from '@documenso/lib/constants/app'; import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; import { createPublicEnv } from '@documenso/lib/utils/env'; import { extractLocaleData } from '@documenso/lib/utils/i18n'; @@ -8,6 +10,7 @@ import { getOrganisationSession } from '@documenso/trpc/server/organisation-rout import { Toaster } from '@documenso/ui/primitives/toaster'; import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'; +import { useEffect } from 'react'; import { data, isRouteErrorResponse, @@ -20,7 +23,6 @@ import { useMatches, } from 'react-router'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; - import type { Route } from './+types/root'; import stylesheet from './app.css?url'; import { GenericErrorLayout } from './components/general/generic-error-layout'; @@ -68,6 +70,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { lang, theme: getTheme(), disableAnimations, + basePath: getBasePath(), // Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all // SSR-rendered