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/.env.example b/.env.example index 5f3da7c1f..4d409e798 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,8 @@ NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET= NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL= # OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps). NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY= +# OPTIONAL: Reason to embed in PDF signatures. Defaults to "Signed by Documenso". +NEXT_PRIVATE_SIGNING_REASON= # OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL. NEXT_PUBLIC_SIGNING_CONTACT_INFO= # OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. 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/common-errors.mdx b/apps/docs/content/docs/developers/api/common-errors.mdx index f26eba13c..91fcb9313 100644 --- a/apps/docs/content/docs/developers/api/common-errors.mdx +++ b/apps/docs/content/docs/developers/api/common-errors.mdx @@ -15,6 +15,8 @@ This guide provides a comprehensive troubleshooting matrix for the standard erro | `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. | | `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. | | `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. | +| `MISSING_ENV_VAR` | A required environment variable is not configured on the server (500). | Primarily affects self-hosted instances: set the environment variable named in the error message and restart. On Documenso Cloud, contact support. | +| `MISSING_SIGNATURE_FIELD` | A signer has no signature field placed on the document (400). Returned when distributing an envelope. | Add at least one signature field for every recipient with a signing role before calling `/envelope/distribute`. | | `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. | | `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. | | `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. | @@ -37,7 +39,28 @@ The following errors occur when attempting to perform actions on an envelope tha | `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. | | `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. | | `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. | +| `ENVELOPE_CANCELLED` | The action cannot be performed because the envelope was cancelled (400). | Create a new envelope if you need to restart the signing process. | | `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. | +| `ENVELOPE_TSP_LOCKED` | An AES/QES envelope cannot be modified after it leaves the draft state (400). | Make changes while the envelope is in `DRAFT`, or create a new envelope. | + +## CSC Signing Errors + +These errors apply to Cloud Signature Consortium (CSC) signing flows. + +| Error Code | Description | Recommended Action | +| :--- | :--- | :--- | +| `CSC_INSTANCE_MODE_MISMATCH` | The requested signature level does not match the instance's CSC mode (400). | Use the signature level supported by the instance's signing configuration. | +| `CSC_UNLICENSED` | CSC signing is not licensed for this instance (403). | Enable the CSC signing license before retrying. | +| `CSC_PROVIDER_INFO_FAILED` | The CSC provider's discovery request failed or returned unusable information (500). | Check the provider URL, availability, and OAuth configuration. | +| `CSC_PROVIDER_NO_TSA` | A timestamp authority is unavailable or unusable for CSC signing (500). | Configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` and verify provider timestamp access. | +| `CSC_CREDENTIAL_LIST_EMPTY` | The CSC provider returned no signing credentials for the authenticated user (400). | Enrol a signing credential with the provider, then authenticate again. | +| `CSC_CERT_INVALID` | The selected signing certificate is missing, invalid, or outside its validity period (400). | Select or renew a valid certificate, then authenticate again. | +| `CSC_ALGORITHM_REFUSED` | The signing credential uses an unsupported key or digest algorithm (400). | Select a credential that satisfies the instance's CSC algorithm policy. | +| `CSC_SAD_EXPIRED_PRE_SIGN` | The signature activation data is missing, expired, or unreadable before signing (400). | Repeat the credential authorization flow. | +| `CSC_TSP_TIMEOUT` | The trust service provider did not complete the signing request before the timeout (408). | Retry the signing request after checking provider availability. | +| `CSC_EMBED_FAILED` | The returned CSC signature could not be embedded into the envelope items (400). | Restart the signing attempt. If it fails again, contact support. | +| `CSC_BASE_DOCUMENT_MUTATED` | The document changed between signature preparation and signing (500). | Restart signing from the current envelope state. | +| `CSC_REQUEST_FAILED` | A CSC provider request failed without a more specific CSC error (500). | Check provider availability and configuration, then retry. | ## See Also 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/fields.mdx b/apps/docs/content/docs/developers/api/fields.mdx index 71f680a62..0cec72339 100644 --- a/apps/docs/content/docs/developers/api/fields.mdx +++ b/apps/docs/content/docs/developers/api/fields.mdx @@ -6,6 +6,8 @@ description: Add signature and form fields to documents via API. import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). @@ -19,13 +21,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; | `secondaryId` | string | Secondary identifier for audit logs | | `type` | string | Field type (see [Field Types](#field-types)) | | `recipientId` | number | ID of the recipient assigned to this field | -| `envelopeId` | number | ID of the parent envelope | +| `envelopeId` | string | ID of the parent envelope | | `envelopeItemId` | string | ID of the PDF item the field is placed on | | `page` | number | Page number (1-indexed) | -| `positionX` | number | X coordinate as percentage (0-100) | -| `positionY` | number | Y coordinate as percentage (0-100) | -| `width` | number | Width as percentage of page (0-100) | -| `height` | number | Height as percentage of page (0-100) | +| `positionX` | string | X coordinate as percentage (0-100), a decimal serialized as a string | +| `positionY` | string | Y coordinate as percentage (0-100), a decimal serialized as a string | +| `width` | string | Width as percentage of page (0-100), a decimal serialized as a string | +| `height` | string | Height as percentage of page (0-100), a decimal serialized as a string | | `customText` | string | Value entered by the recipient | | `inserted` | boolean | Whether the field has been completed | | `fieldMeta` | object \| null | Type-specific configuration options | @@ -38,18 +40,19 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; "secondaryId": "field_abc123", "type": "SIGNATURE", "recipientId": 123, - "envelopeId": 789, - "envelopeItemId": "envelope_item_xyz", + "envelopeId": "envelope_abcdefhiklmnorst", + "envelopeItemId": "envelope_item_abcdefhiklmnorst", "page": 1, - "positionX": 10, - "positionY": 80, - "width": 30, - "height": 5, + "positionX": "10", + "positionY": "80", + "width": "30", + "height": "5", "customText": "", "inserted": false, "fieldMeta": { "type": "signature", - "required": true + "required": true, + "overflow": "auto" } } ``` @@ -61,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; | Type | Description | Auto-filled | | ---------------- | ----------------------------------------- | ----------- | | `SIGNATURE` | Drawn, typed, or uploaded signature | No | -| `FREE_SIGNATURE` | Unrestricted signature without validation | No | +| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No | | `INITIALS` | Recipient's initials | No | | `NAME` | Recipient's full name | Yes | | `EMAIL` | Recipient's email address | Yes | @@ -134,10 +137,12 @@ POST /envelope/field/create-many ### Request Body -| Field | Type | Required | Description | -| ----------- | ------ | -------- | ------------------------------- | -| `documentId`| number | Yes | The document ID | -| `fields` | array | Yes | Array of field configurations | +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ------------------------------- | +| `envelopeId` | string | Yes | The envelope ID | +| `data` | array | Yes | Array of field configurations | + +Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`. ### Code Examples @@ -148,32 +153,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \ -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ - "documentId": 123, - "fields": [ + "envelopeId": "envelope_abcdefhiklmnorst", + "data": [ { "type": "SIGNATURE", "recipientId": 456, - "pageNumber": 1, - "pageX": 10, - "pageY": 80, + "page": 1, + "positionX": 10, + "positionY": 80, "width": 30, "height": 5 }, { "type": "DATE", "recipientId": 456, - "pageNumber": 1, - "pageX": 50, - "pageY": 80, + "page": 1, + "positionX": 50, + "positionY": 80, "width": 20, "height": 3 }, { "type": "TEXT", "recipientId": 456, - "pageNumber": 1, - "pageX": 10, - "pageY": 70, + "page": 1, + "positionX": 10, + "positionY": 70, "width": 40, "height": 4, "fieldMeta": { @@ -199,32 +204,32 @@ const response = await fetch( 'Content-Type': 'application/json', }, body: JSON.stringify({ - documentId: 123, - fields: [ + envelopeId: 'envelope_abcdefhiklmnorst', + data: [ { type: 'SIGNATURE', recipientId: 456, - pageNumber: 1, - pageX: 10, - pageY: 80, + page: 1, + positionX: 10, + positionY: 80, width: 30, height: 5, }, { type: 'DATE', recipientId: 456, - pageNumber: 1, - pageX: 50, - pageY: 80, + page: 1, + positionX: 50, + positionY: 80, width: 20, height: 3, }, { type: 'TEXT', recipientId: 456, - pageNumber: 1, - pageX: 10, - pageY: 70, + page: 1, + positionX: 10, + positionY: 70, width: 40, height: 4, fieldMeta: { @@ -239,8 +244,8 @@ const response = await fetch( } ); -const { fields } = await response.json(); -console.log(`Created ${fields.length} fields`); +const { data } = await response.json(); +console.log(`Created ${data.length} fields`); ```` @@ -250,36 +255,68 @@ console.log(`Created ${fields.length} fields`); ```json { - "fields": [ + "data": [ { "id": 101, + "secondaryId": "field_abc123", + "envelopeId": "envelope_abcdefhiklmnorst", + "envelopeItemId": "envelope_item_abcdefhiklmnorst", "type": "SIGNATURE", "recipientId": 456, "page": 1, - "positionX": 10, - "positionY": 80, - "width": 30, - "height": 5 + "positionX": "10", + "positionY": "80", + "width": "30", + "height": "5", + "customText": "", + "inserted": false, + "fieldMeta": { + "type": "signature", + "fontSize": 18, + "overflow": "auto" + } }, { "id": 102, + "secondaryId": "field_def456", + "envelopeId": "envelope_abcdefhiklmnorst", + "envelopeItemId": "envelope_item_abcdefhiklmnorst", "type": "DATE", "recipientId": 456, "page": 1, - "positionX": 50, - "positionY": 80, - "width": 20, - "height": 3 + "positionX": "50", + "positionY": "80", + "width": "20", + "height": "3", + "customText": "", + "inserted": false, + "fieldMeta": { + "type": "date", + "fontSize": 12, + "textAlign": "left", + "overflow": "auto" + } }, { "id": 103, + "secondaryId": "field_ghi789", + "envelopeId": "envelope_abcdefhiklmnorst", + "envelopeItemId": "envelope_item_abcdefhiklmnorst", "type": "TEXT", "recipientId": 456, "page": 1, - "positionX": 10, - "positionY": 70, - "width": 40, - "height": 4 + "positionX": "10", + "positionY": "70", + "width": "40", + "height": "4", + "customText": "", + "inserted": false, + "fieldMeta": { + "type": "text", + "label": "Job Title", + "placeholder": "Enter your job title", + "required": true + } } ] } @@ -299,8 +336,10 @@ POST /envelope/field/update-many | Field | Type | Required | Description | | ------------ | ------ | -------- | ----------------------------- | -| `documentId` | number | Yes | The document ID | -| `fields` | array | Yes | Array of field update objects | +| `envelopeId` | string | Yes | The envelope ID | +| `data` | array | Yes | Array of field update objects | + +Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates. ### Code Examples @@ -311,17 +350,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \ -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ - "documentId": 123, - "fields": [ + "envelopeId": "envelope_abcdefhiklmnorst", + "data": [ { "id": 101, "type": "SIGNATURE", - "pageY": 85 + "positionY": 85 }, { "id": 102, "type": "DATE", - "pageY": 85 + "positionY": 85 } ] }' @@ -338,16 +377,16 @@ const response = await fetch( 'Content-Type': 'application/json', }, body: JSON.stringify({ - documentId: 123, - fields: [ - { id: 101, type: 'SIGNATURE', pageY: 85 }, - { id: 102, type: 'DATE', pageY: 85 }, + envelopeId: 'envelope_abcdefhiklmnorst', + data: [ + { id: 101, type: 'SIGNATURE', positionY: 85 }, + { id: 102, type: 'DATE', positionY: 85 }, ], }), } ); -const { fields } = await response.json(); +const { data } = await response.json(); ```` @@ -357,9 +396,48 @@ const { fields } = await response.json(); ```json { - "fields": [ - { "id": 101, "type": "SIGNATURE", "positionY": 85 }, - { "id": 102, "type": "DATE", "positionY": 85 } + "data": [ + { + "id": 101, + "secondaryId": "field_abc123", + "envelopeId": "envelope_abcdefhiklmnorst", + "envelopeItemId": "envelope_item_abcdefhiklmnorst", + "type": "SIGNATURE", + "recipientId": 456, + "page": 1, + "positionX": "10", + "positionY": "85", + "width": "30", + "height": "5", + "customText": "", + "inserted": false, + "fieldMeta": { + "type": "signature", + "fontSize": 18, + "overflow": "auto" + } + }, + { + "id": 102, + "secondaryId": "field_def456", + "envelopeId": "envelope_abcdefhiklmnorst", + "envelopeItemId": "envelope_item_abcdefhiklmnorst", + "type": "DATE", + "recipientId": 456, + "page": 1, + "positionX": "50", + "positionY": "85", + "width": "20", + "height": "3", + "customText": "", + "inserted": false, + "fieldMeta": { + "type": "date", + "fontSize": 12, + "textAlign": "left", + "overflow": "auto" + } + } ] } ```` @@ -443,8 +521,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions. (0,0) ─────────────────────────── (100,0) │ │ │ ┌─────────┐ │ - │ │ Field │ (pageX: 10, │ - │ │ │ pageY: 20, │ + │ │ Field │ (positionX: 10, │ + │ │ │ positionY: 20, │ │ └─────────┘ width: 30, │ │ height: 5) │ │ │ @@ -457,9 +535,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions. const field = { type: 'SIGNATURE', recipientId: 123, - pageNumber: 1, - pageX: 60, // 60% from left - pageY: 85, // 85% from top (near bottom) + page: 1, + positionX: 60, // 60% from left + positionY: 85, // 85% from top (near bottom) width: 30, // 30% of page width height: 8, // 8% of page height }; @@ -479,6 +557,33 @@ This approach is useful when generating PDFs programmatically or using templates See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options. +### Placeholder Positioning via the API + +`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass: + +| Field | Type | Required | Description | +| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ | +| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. | +| `width` | number | No | Override the field width. Defaults to the width of the matched text. | +| `height` | number | No | Override the field height. Defaults to the height of the matched text. | +| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. | + +```json +{ + "envelopeId": "envelope_abcdefhiklmnorst", + "data": [ + { + "type": "SIGNATURE", + "recipientId": 456, + "placeholder": "{{signature}}", + "matchAll": true + } + ] +} +``` + +`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only. + --- ## Field Meta Options @@ -643,15 +748,15 @@ All field types support these base options: Create a document with a signature block containing multiple field types: ```typescript -async function addSignatureBlock(documentId: number, recipientId: number) { - const fields = [ +async function addSignatureBlock(envelopeId: string, recipientId: number) { + const data = [ // Signature { type: 'SIGNATURE', recipientId, - pageNumber: 1, - pageX: 10, - pageY: 80, + page: 1, + positionX: 10, + positionY: 80, width: 30, height: 8, fieldMeta: { @@ -663,9 +768,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) { { type: 'NAME', recipientId, - pageNumber: 1, - pageX: 10, - pageY: 90, + page: 1, + positionX: 10, + positionY: 90, width: 30, height: 4, fieldMeta: { @@ -677,9 +782,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) { { type: 'DATE', recipientId, - pageNumber: 1, - pageX: 50, - pageY: 80, + page: 1, + positionX: 50, + positionY: 80, width: 20, height: 4, fieldMeta: { @@ -691,9 +796,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) { { type: 'TEXT', recipientId, - pageNumber: 1, - pageX: 50, - pageY: 90, + page: 1, + positionX: 50, + positionY: 90, width: 30, height: 4, fieldMeta: { @@ -710,7 +815,7 @@ async function addSignatureBlock(documentId: number, recipientId: number) { Authorization: 'api_xxxxxxxxxxxxxxxx', 'Content-Type': 'application/json', }, - body: JSON.stringify({ documentId, fields }), + body: JSON.stringify({ envelopeId, data }), }); return response.json(); diff --git a/apps/docs/content/docs/developers/api/index.mdx b/apps/docs/content/docs/developers/api/index.mdx index e8d7139eb..ac64de966 100644 --- a/apps/docs/content/docs/developers/api/index.mdx +++ b/apps/docs/content/docs/developers/api/index.mdx @@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx href="/docs/developers/api/templates" /> 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/recipients.mdx b/apps/docs/content/docs/developers/api/recipients.mdx index 6751a400a..bb72afb92 100644 --- a/apps/docs/content/docs/developers/api/recipients.mdx +++ b/apps/docs/content/docs/developers/api/recipients.mdx @@ -6,6 +6,8 @@ description: Add and manage envelope recipients via API. import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). @@ -16,7 +18,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; ```json { "id": 123, - "envelopeId": "clu1abc2def3ghi4jkl", + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -134,7 +136,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/create-many" \ -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ - "envelopeId": "clu1abc2def3ghi4jkl", + "envelopeId": "envelope_abcdefhiklmnorst", "data": [ { "email": "signer@example.com", @@ -164,7 +166,7 @@ const response = await fetch( 'Content-Type': 'application/json', }, body: JSON.stringify({ - envelopeId: 'clu1abc2def3ghi4jkl', + envelopeId: 'envelope_abcdefhiklmnorst', data: [ { email: 'signer@example.com', @@ -196,7 +198,7 @@ const { data: recipients } = await response.json(); "data": [ { "id": 789, - "envelopeId": "clu1abc2def3ghi4jkl", + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -209,7 +211,7 @@ const { data: recipients } = await response.json(); }, { "id": 790, - "envelopeId": "clu1abc2def3ghi4jkl", + "envelopeId": "envelope_abcdefhiklmnorst", "email": "approver@example.com", "name": "Jane Smith", "role": "APPROVER", @@ -262,7 +264,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/update-many" \ -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ - "envelopeId": "clu1abc2def3ghi4jkl", + "envelopeId": "envelope_abcdefhiklmnorst", "data": [ { "id": 789, @@ -284,7 +286,7 @@ const response = await fetch( 'Content-Type': 'application/json', }, body: JSON.stringify({ - envelopeId: 'clu1abc2def3ghi4jkl', + envelopeId: 'envelope_abcdefhiklmnorst', data: [ { id: 789, @@ -387,7 +389,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien 'Content-Type': 'application/json', }, body: JSON.stringify({ - envelopeId: 'clu1abc2def3ghi4jkl', + envelopeId: 'envelope_abcdefhiklmnorst', data: [ { email: 'approver@example.com', @@ -462,7 +464,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien 'Content-Type': 'application/json', }, body: JSON.stringify({ - envelopeId: 'clu1abc2def3ghi4jkl', + envelopeId: 'envelope_abcdefhiklmnorst', data: [ { email: 'signer@example.com', diff --git a/apps/docs/content/docs/developers/api/teams.mdx b/apps/docs/content/docs/developers/api/teams.mdx index 99d708b41..258e015c0 100644 --- a/apps/docs/content/docs/developers/api/teams.mdx +++ b/apps/docs/content/docs/developers/api/teams.mdx @@ -1,44 +1,29 @@ --- -title: Teams API -description: Manage team resources, documents, and templates with team-scoped API tokens. +title: Team-Scoped API Access +description: Use team-scoped API tokens with document and template envelopes. --- import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). -## Team Object +## Team Context -A team object contains the following properties: + + The V2 REST API does not expose `/team/*` endpoints. Create and manage teams, members, and team + settings in the Documenso web application. This page explains how a team-scoped token applies + that team context to supported API resources. + -| Property | Type | Description | -| ----------------- | -------------- | --------------------------------------------------- | -| `id` | number | Unique team identifier | -| `name` | string | Team display name | -| `url` | string | Unique team URL slug | -| `createdAt` | string | ISO 8601 timestamp | -| `avatarImageId` | string \| null | ID of the team's avatar image | -| `organisationId` | string | ID of the parent organisation | -| `currentTeamRole` | string | Your role in the team: `ADMIN`, `MANAGER`, `MEMBER` | - -### Example Team Object - -```json -{ - "id": 123, - "name": "Engineering", - "url": "engineering", - "createdAt": "2025-01-15T10:30:00.000Z", - "avatarImageId": null, - "organisationId": "org_abc123", - "currentTeamRole": "ADMIN" -} -``` +The API resolves the team from your token. You do not pass a team ID when creating, listing, or +using envelopes. The token's team ID determines which resources the request can access. ## Team-Scoped API Tokens @@ -95,7 +80,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", @@ -156,26 +141,26 @@ 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" +curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" # Filter by status -curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" +curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" ```` ```typescript -const response = await fetch('https://app.documenso.com/api/v2/envelope', { +const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', { method: 'GET', headers: { Authorization: TEAM_API_TOKEN, }, }); -const { data, pagination } = await response.json(); -console.log(`Found ${pagination.totalItems} team documents`); +const { data, count } = await response.json(); +console.log(`Found ${count} team documents`); ```` @@ -190,10 +175,11 @@ 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" \ +curl -X POST "https://app.documenso.com/api/v2/envelope/create" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: multipart/form-data" \ -F 'payload={ + "type": "TEMPLATE", "title": "NDA Template", "recipients": [ { @@ -223,6 +209,7 @@ curl -X POST "https://app.documenso.com/api/v2/template/create" \ const form = new FormData(); const payload = { + type: 'TEMPLATE', title: 'NDA Template', recipients: [ { @@ -249,7 +236,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), { contentType: 'application/pdf', }); -const response = await fetch('https://app.documenso.com/api/v2/template/create', { +const response = await fetch('https://app.documenso.com/api/v2/envelope/create', { method: 'POST', headers: { Authorization: TEAM_API_TOKEN, @@ -257,8 +244,8 @@ const response = await fetch('https://app.documenso.com/api/v2/template/create', body: form, }); -const template = await response.json(); -console.log('Created team template:', template.id); +const { id } = await response.json(); +console.log('Created team template envelope:', id); ```` @@ -268,14 +255,14 @@ console.log('Created team template:', template.id); ```bash -curl -X GET "https://app.documenso.com/api/v2/template" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" +curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" ```` ```typescript -const response = await fetch('https://app.documenso.com/api/v2/template', { +const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', { method: 'GET', headers: { Authorization: TEAM_API_TOKEN, @@ -330,19 +317,19 @@ const SALES_TEAM_TOKEN = process.env.SALES_TEAM_API_TOKEN; const LEGAL_TEAM_TOKEN = process.env.LEGAL_TEAM_API_TOKEN; // Get pending documents from sales team -const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=PENDING', { +const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING', { headers: { Authorization: SALES_TEAM_TOKEN }, }); const salesDocs = await salesResponse.json(); // Get completed documents from legal team -const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', { +const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=COMPLETED', { headers: { Authorization: LEGAL_TEAM_TOKEN }, }); const legalDocs = await legalResponse.json(); -console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`); -console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`); +console.log(`Sales team: ${salesDocs.count} pending`); +console.log(`Legal team: ${legalDocs.count} completed`); ``` ## Error Responses diff --git a/apps/docs/content/docs/developers/api/templates.mdx b/apps/docs/content/docs/developers/api/templates.mdx index b3f52e146..2d3675100 100644 --- a/apps/docs/content/docs/developers/api/templates.mdx +++ b/apps/docs/content/docs/developers/api/templates.mdx @@ -13,7 +13,194 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; see the [OpenAPI Reference](https://openapi.documenso.com). -## Template Object +## Use a Template Envelope + +New integrations should create a document from a template envelope with the Envelope API. + +``` +POST /envelope/use +Content-Type: multipart/form-data +``` + +The request uses `multipart/form-data`: + +| Part | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------------------------------------ | +| `payload` | JSON | Yes | Template envelope ID, recipient details, and document settings | +| `files` | File(s) | No | Replacement PDFs referenced by entries in `customDocumentData` | + +### Payload Schema + +| Field | Type | Required | Description | +| -------------------- | ------- | -------- | ------------------------------------------------------------------------ | +| `envelopeId` | string | Yes | ID of the template envelope | +| `externalId` | string | No | Your identifier for the created document envelope | +| `recipients` | array | No | Recipient details mapped to recipients in the template | +| `distributeDocument` | boolean | No | If `true`, create the document as pending and distribute it | +| `customDocumentData` | array | No | Maps uploaded replacement PDFs to template envelope items | +| `folderId` | string | No | Folder in which to create the document | +| `prefillFields` | array | No | Field values to prefill before distribution | +| `override` | object | No | Template values to override for the created document | +| `attachments` | array | No | Link attachments to add to the document | +| `formValues` | object | No | PDF form values to apply | + +Each recipient entry accepts the following fields: + +| Field | Type | Required | Description | +| -------------- | ------- | -------- | -------------------------------------------- | +| `id` | number | Yes | Recipient ID from the template envelope | +| `email` | string | Yes | Recipient email address | +| `name` | string | No | Recipient display name | +| `signingOrder` | number | No | Recipient position in sequential signing | + +Each `customDocumentData` entry maps an uploaded file to a template item: + +| Field | Type | Required | Description | +| ---------------- | ---------------- | -------- | --------------------------------------------------------------- | +| `identifier` | string \| number | Yes | Uploaded filename or zero-based file index | +| `envelopeItemId` | string | Yes | Template envelope item whose PDF the uploaded file replaces | + +### Code Examples + + + +```bash +curl -X POST "https://app.documenso.com/api/v2/envelope/use" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ + -F 'payload={ + "envelopeId": "envelope_template123", + "externalId": "contract-2025-001", + "recipients": [ + { + "id": 1, + "email": "john.doe@example.com", + "name": "John Doe" + } + ], + "prefillFields": [ + { + "id": 101, + "type": "text", + "value": "Senior Software Engineer" + } + ], + "distributeDocument": false + }' +``` + + +```typescript +const form = new FormData(); + +form.append( + 'payload', + JSON.stringify({ + envelopeId: 'envelope_template123', + externalId: 'contract-2025-001', + recipients: [ + { + id: 1, + email: 'john.doe@example.com', + name: 'John Doe', + }, + ], + prefillFields: [ + { + id: 101, + type: 'text', + value: 'Senior Software Engineer', + }, + ], + distributeDocument: false, + }), +); + +const response = await fetch('https://app.documenso.com/api/v2/envelope/use', { + method: 'POST', + headers: { + Authorization: 'api_xxxxxxxxxxxxxxxx', + }, + body: form, +}); + +const document = await response.json(); +console.log('Created document envelope:', document.id); +``` + + + +### Response + +```json +{ + "id": "envelope_document123", + "recipients": [ + { + "id": 1, + "name": "John Doe", + "email": "john.doe@example.com", + "token": "recipient_token", + "role": "SIGNER", + "signingOrder": 1, + "signingUrl": "https://app.documenso.com/sign/recipient_token" + } + ] +} +``` + +### Distribute the Created Envelope + +If you leave `distributeDocument` unset or set it to `false`, distribute the created document with +`POST /envelope/distribute`. Its response confirms delivery and includes each recipient's signing URL. + +```typescript +const distributionResponse = await fetch( + 'https://app.documenso.com/api/v2/envelope/distribute', + { + method: 'POST', + headers: { + Authorization: 'api_xxxxxxxxxxxxxxxx', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + envelopeId: document.id, + }), + }, +); + +const distribution = await distributionResponse.json(); +console.log('Signing URL:', distribution.recipients[0].signingUrl); +``` + +```json +{ + "success": true, + "id": "envelope_document123", + "recipients": [ + { + "id": 1, + "name": "John Doe", + "email": "john.doe@example.com", + "token": "recipient_token", + "role": "SIGNER", + "signingOrder": 1, + "signingUrl": "https://app.documenso.com/sign/recipient_token" + } + ] +} +``` + +--- + +## Deprecated Template Endpoint Reference + + + Every `/template/*` endpoint below is deprecated. Use the Envelope API for new integrations and + follow [Migrating to Envelopes](/docs/developers/api/migrate-to-envelopes) to replace existing calls. + The legacy reference remains here to support migrations. + + +## Legacy Template Object A template object contains the following properties: @@ -91,7 +278,7 @@ A template object contains the following properties: } ``` -## List Templates +## List Templates (Deprecated) Retrieve a paginated list of templates. @@ -139,8 +326,8 @@ const response = await fetch(`${BASE_URL}/template`, { }, }); -const { data, pagination } = await response.json(); -console.log(`Found ${pagination.totalItems} templates`); +const { data, count } = await response.json(); +console.log(`Found ${count} templates`); // Filter by type const privateResponse = await fetch( @@ -181,18 +368,16 @@ const privateTemplates = await privateResponse.json(); ] } ], - "pagination": { - "page": 1, - "perPage": 10, - "totalPages": 3, - "totalItems": 25 - } + "count": 25, + "currentPage": 1, + "perPage": 10, + "totalPages": 3 } ``` --- -## Get Template +## Get Template (Deprecated) Retrieve a single template by ID. @@ -238,9 +423,9 @@ Returns the full template object including recipients, fields, and metadata. --- -## Create Document from Template +## Create Document from Template (Deprecated) -Create a new document using a template. This is the primary way to use templates programmatically. +Create a new document using the deprecated template endpoint. This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning. @@ -415,32 +600,57 @@ const prefilledDocument = await prefillResponse.json(); ### Response -Returns the created document object with recipients and signing URLs. +The endpoint returns the full legacy document object. The selected fields below show both the numeric +legacy `id` and canonical `envelopeId`. Recipient entries do not include a `signingUrl`. ```json { - "id": "envelope_xyz789", - "type": "DOCUMENT", + "id": 789, + "envelopeId": "envelope_xyz789", "status": "PENDING", - "title": "Employment Contract", "source": "TEMPLATE", + "title": "Employment Contract", "externalId": "contract-2025-001", "recipients": [ { "id": 1, + "envelopeId": "envelope_xyz789", + "documentId": 789, + "templateId": null, "email": "john.doe@example.com", "name": "John Doe", "role": "SIGNER", "signingStatus": "NOT_SIGNED", - "signingUrl": "https://app.documenso.com/sign/abc123" + "signingOrder": 1 } ] } -```` +``` + +To send a document created with `distributeDocument: false` and receive signing links, call +`POST /envelope/distribute` with its `envelopeId`: + +```typescript +const document = await response.json(); + +const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, { + method: 'POST', + headers: { + Authorization: API_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + envelopeId: document.envelopeId, + }), +}); + +const distribution = await distributionResponse.json(); +console.log('Signing URL:', distribution.recipients[0].signingUrl); +``` --- -## Override Template Settings +## Override Template Settings (Deprecated) When creating a document from a template, you can override various settings: @@ -488,7 +698,7 @@ const response = await fetch(`${BASE_URL}/template/use`, { --- -## Prefill Fields +## Prefill Fields (Deprecated) Prefill field values when creating a document from a template. This is useful for populating known data before sending. @@ -577,7 +787,7 @@ const response = await fetch(`${BASE_URL}/template/use`, { --- -## Update Template +## Update Template (Deprecated) Update a template's properties. @@ -643,7 +853,7 @@ const template = await response.json(); --- -## Duplicate Template +## Duplicate Template (Deprecated) Create a copy of an existing template. @@ -695,7 +905,7 @@ console.log('New template ID:', duplicatedTemplate.id); --- -## Delete Template +## Delete Template (Deprecated) Delete a template. @@ -754,7 +964,7 @@ const { success } = await response.json(); --- -## Direct Link Templates +## Direct Link Templates (Deprecated) Direct link templates allow recipients to create and sign documents without requiring you to explicitly create each document. When a recipient visits the direct link, a new document is automatically created from the template. @@ -898,7 +1108,7 @@ const { success } = await response.json(); --- -## Custom Document Data +## Custom Document Data (Deprecated) When creating a document from a template, you can replace the template's PDF with a custom PDF by using the `customDocumentData` parameter. This is useful when you need to generate the PDF dynamically while reusing the template's recipient and field configuration. @@ -913,7 +1123,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request --- -## Template Types +## Template Types (Legacy) | Type | Description | | --------- | ------------------------------------------------------------------ | @@ -922,7 +1132,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request --- -## Complete Example: Contract Workflow +## Complete Legacy Example: Contract Workflow (Deprecated) This example demonstrates a complete workflow for using templates to send contracts. @@ -996,16 +1206,29 @@ async function sendEmploymentContract(employeeData: { subject: `Employment Contract for ${employeeData.name}`, message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`, }, - distributeDocument: true, + distributeDocument: false, externalId: `emp-contract-${Date.now()}`, }), }); const document = await documentResponse.json(); + // 5. Distribute the envelope and get recipient signing links + const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, { + method: 'POST', + headers: { + Authorization: API_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + envelopeId: document.envelopeId, + }), + }); + const distribution = await distributionResponse.json(); + return { - documentId: document.id, - signingUrl: document.recipients[0].signingUrl, + envelopeId: document.envelopeId, + signingUrl: distribution.recipients[0].signingUrl, }; } @@ -1018,7 +1241,7 @@ const result = await sendEmploymentContract({ startDate: '2025-03-01', }); -console.log('Document created:', result.documentId); +console.log('Document created:', result.envelopeId); console.log('Signing URL:', result.signingUrl); ```` diff --git a/apps/docs/content/docs/developers/examples/common-workflows.mdx b/apps/docs/content/docs/developers/examples/common-workflows.mdx index 704bf415f..02f99506b 100644 --- a/apps/docs/content/docs/developers/examples/common-workflows.mdx +++ b/apps/docs/content/docs/developers/examples/common-workflows.mdx @@ -51,6 +51,7 @@ async function createAndSendDocument( pdfBuffer: Buffer, filename: string, title: string, + externalId: string, recipients: Recipient[], ): Promise { const recipientPayload = recipients.map((recipient, index) => ({ @@ -89,6 +90,7 @@ async function createAndSendDocument( JSON.stringify({ type: 'DOCUMENT', title, + externalId, recipients: recipientPayload, meta: { subject: `Please sign: ${title}`, @@ -145,6 +147,7 @@ const result = await createAndSendDocument( pdfBuffer, 'contract.pdf', 'Service Agreement', + 'nda-contract-ndac214', [ { email: 'client@example.com', name: 'John Smith', role: 'SIGNER' }, { email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' }, @@ -172,6 +175,7 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \ -F 'payload={ "type": "DOCUMENT", "title": "Service Agreement", + "externalId": "nda-contract-ndac214", "recipients": [ { "email": "client@example.com", @@ -241,6 +245,8 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}' +`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents). + --- ## Workflow 2: Create Document from Template with Custom Data @@ -256,8 +262,11 @@ Use templates for repeatable document workflows. This example creates an employm Map template fields by label and build a prefillFields array - Call POST /template/use with recipients, prefill data, and{' '} - distributeDocument: true + Call POST /template/use with recipients and prefill data + + + Distribute the returned envelope via POST /envelope/distribute and read its signing + links @@ -291,7 +300,7 @@ type TemplateRecipient = { async function sendEmploymentContract( templateId: number, employee: EmployeeData, -): Promise<{ documentId: string; signingUrl: string }> { +): Promise<{ documentId: number; signingUrl: string }> { const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, { headers: { Authorization: API_TOKEN }, }); @@ -368,7 +377,6 @@ async function sendEmploymentContract( subject: `Your Employment Contract at ${employee.department}`, message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`, }, - distributeDocument: true, externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`, }), }); @@ -380,9 +388,25 @@ async function sendEmploymentContract( const document = await createResponse.json(); + const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, { + method: 'POST', + headers: { + Authorization: API_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ envelopeId: document.envelopeId }), + }); + + if (!distributeResponse.ok) { + const error = await distributeResponse.json(); + throw new Error(`Failed to send document: ${error.message}`); + } + + const distributeResult = await distributeResponse.json(); + return { documentId: document.id, - signingUrl: document.recipients[0].signingUrl, + signingUrl: distributeResult.recipients[0].signingUrl, }; } @@ -447,12 +471,17 @@ RESPONSE=$(curl -s -X POST "${BASE_URL}/template/use" \ \"subject\": \"Your Employment Contract\", \"message\": \"Please review and sign your employment contract.\" }, - \"distributeDocument\": true, \"externalId\": \"emp-$(date +%s)-alice\" }") -echo "Document created:" -echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}' +ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId') +DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \ + -H "Authorization: ${API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"envelopeId\": \"${ENVELOPE_ID}\"}") + +echo "Document created: $(echo $RESPONSE | jq -r '.id')" +echo "Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')" ```` @@ -470,8 +499,10 @@ Send the same document to multiple recipients in parallel. Useful for policy ack Fetch the template and get the signer recipient slot ID - For each recipient, call POST /template/use with{' '} - distributeDocument: true + For each recipient, call POST /template/use + + + Distribute each returned envelope via POST /envelope/distribute Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute) @@ -534,7 +565,6 @@ async function bulkSendFromTemplate( recipients: [ { id: signerSlot.id, email: recipient.email, name: recipient.name }, ], - distributeDocument: true, externalId: `bulk-${Date.now()}-${recipient.email}`, }), }); @@ -545,10 +575,26 @@ async function bulkSendFromTemplate( } const document = await response.json(); + + const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, { + method: 'POST', + headers: { + Authorization: API_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ envelopeId: document.envelopeId }), + }); + + if (!distributeResponse.ok) { + const error = await distributeResponse.json(); + throw new Error(error.message || 'Failed to distribute document'); + } + + const distributeResult = await distributeResponse.json(); return { email: recipient.email, - envelopeId: document.id, - signingUrl: document.recipients[0].signingUrl, + envelopeId: document.envelopeId, + signingUrl: distributeResult.recipients[0].signingUrl, }; }), ); @@ -621,14 +667,24 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do \"email\": \"${EMAIL}\", \"name\": \"${NAME}\" }], - \"distributeDocument\": true, \"externalId\": \"bulk-$(date +%s)-${EMAIL}\" }") - if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then - echo " Success: $(echo $RESPONSE | jq -r '.id')" + if echo $RESPONSE | jq -e '.envelopeId' > /dev/null 2>&1; then + ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId') + DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \ + -H "Authorization: ${API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"envelopeId\": \"${ENVELOPE_ID}\"}") + + if echo $DISTRIBUTE_RESPONSE | jq -e '.success' > /dev/null 2>&1; then + echo " Success: ${ENVELOPE_ID}" + echo " Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')" + else + echo " Failed to distribute: $(echo $DISTRIBUTE_RESPONSE | jq -r '.message')" + fi else - echo " Failed: $(echo $RESPONSE | jq -r '.message')" + echo " Failed to create: $(echo $RESPONSE | jq -r '.message')" fi # Rate limiting delay @@ -831,13 +887,15 @@ After a document is completed, download the signed PDF with all signatures embed +The `version` query parameter accepts `original`, `pending`, or `signed`. + ```typescript const API_TOKEN = process.env.DOCUMENSO_API_TOKEN; const BASE_URL = 'https://app.documenso.com/api/v2'; -type DownloadVersion = 'signed' | 'original'; +type DownloadVersion = 'original' | 'pending' | 'signed'; async function downloadDocument( envelopeId: string, @@ -891,7 +949,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise { { headers: { Authorization: API_TOKEN } }, ); - const { data, pagination } = await response.json(); + const { data, count, currentPage, perPage, totalPages } = await response.json(); for (const envelope of data) { try { @@ -905,8 +963,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise { await new Promise((resolve) => setTimeout(resolve, 500)); } - hasMore = page < pagination.totalPages; - page++; + hasMore = currentPage < totalPages; + page = currentPage + 1; } } @@ -1000,9 +1058,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/developers/getting-started/authentication.mdx b/apps/docs/content/docs/developers/getting-started/authentication.mdx index 82d42bb1a..42d4311da 100644 --- a/apps/docs/content/docs/developers/getting-started/authentication.mdx +++ b/apps/docs/content/docs/developers/getting-started/authentication.mdx @@ -24,20 +24,18 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; {/* prettier-ignore */} -### Open settings +### Select a team -- Log in to your Documenso account -- Click your avatar in the top right corner -- Select **Settings** from the dropdown menu - -![User dropdown menu](/public-api-images/documenso-user-dropdown-menu.webp) +Log in to Documenso and open the team that the integration should access. API tokens are scoped to a +team. -### Navigate to the API Tokens tab +### Open API Tokens -Go to **Settings** and open the **API Tokens** tab. +Go to **Team Settings** → **API Tokens**, or open +`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL. ![API tokens page](/public-api-images/api-tokens-page-documenso.webp) @@ -48,7 +46,7 @@ Go to **Settings** and open the **API Tokens** tab. - Click **Create Token** - Enter a descriptive name (e.g., `production-backend`, `zapier-integration`) -- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year +- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never - Click **Create Token** @@ -75,21 +73,21 @@ Include the token in the `Authorization` header of your HTTP requests. ### cURL ```bash -curl https://app.documenso.com/api/v2/document \ +curl https://app.documenso.com/api/v2/envelope \ -H "Authorization: api_xxxxxxxxxxxxxxxx" ``` ### JavaScript / TypeScript ```typescript -const response = await fetch('https://app.documenso.com/api/v2/document', { +const response = await fetch('https://app.documenso.com/api/v2/envelope', { method: 'GET', headers: { Authorization: 'api_xxxxxxxxxxxxxxxx', }, }); -const documents = await response.json(); +const envelopes = await response.json(); ``` ### Using the TypeScript SDK @@ -129,7 +127,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript) ## Token Security -API tokens grant full access to your account. Follow these practices to keep them secure: +API tokens grant full API access to the team they were created for. Follow these practices to keep them secure: - **Never commit tokens to version control.** Use environment variables instead. - **Use descriptive names.** Names like `zapier-prod` or `backend-staging` help you identify token usage. @@ -155,12 +153,11 @@ const client = new Documenso({ ## Token Scope -API tokens have full access to your account, including: +API tokens have full API access to the team they were created for, including: - Creating, reading, updating, and deleting documents - Managing recipients and fields - Accessing templates -- Managing team resources (if the token owner has team access) There is currently no way to create tokens with limited scopes or permissions. @@ -171,7 +168,7 @@ To revoke a token: {/* prettier-ignore */} - Go to **Settings** > **API Tokens** + Go to **Team Settings** → **API Tokens** Find the token you want to revoke @@ -194,7 +191,7 @@ Revoked tokens stop working immediately. Any integrations using that token will Create a new token in settings. - Ensure you're accessing resources owned by the token's account. + Ensure you're accessing resources owned by the token's team. diff --git a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx index e87b85438..3f9083959 100644 --- a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx +++ b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx @@ -78,12 +78,10 @@ A successful response returns a list of your documents (envelopes): "createdAt": "2025-01-15T10:30:00.000Z" } ], - "pagination": { - "page": 1, - "perPage": 10, - "totalPages": 1, - "totalItems": 1 - } + "count": 1, + "currentPage": 1, + "perPage": 10, + "totalPages": 1 } ```` @@ -228,9 +226,12 @@ After creating a document, it's in `DRAFT` status. To send it to recipients, use ```bash -curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \ +curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \ -H "Authorization: YOUR_API_TOKEN" \ - -H "Content-Type: application/json" + -H "Content-Type: application/json" \ + -d '{ + "envelopeId": "envelope_abc123" + }' ```` @@ -238,16 +239,14 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribu ```javascript const envelopeId = 'envelope_abc123'; -const response = await fetch( - `https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`, - { - method: 'POST', - headers: { - Authorization: 'YOUR_API_TOKEN', - 'Content-Type': 'application/json', - }, +const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', { + method: 'POST', + headers: { + Authorization: 'YOUR_API_TOKEN', + 'Content-Type': 'application/json', }, -); + body: JSON.stringify({ envelopeId }), +}); const data = await response.json(); console.log('Document sent:', data); @@ -337,16 +336,14 @@ async function createAndSendDocument(pdfPath, recipientEmail, recipientName) { console.log('Created envelope:', envelope.id); // Step 2: Send the document for signing - const distributeResponse = await fetch( - `${BASE_URL}/envelope/${envelope.id}/distribute`, - { - method: 'POST', - headers: { - 'Authorization': API_TOKEN, - 'Content-Type': 'application/json', - }, - } - ); + const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, { + method: 'POST', + headers: { + 'Authorization': API_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ envelopeId: envelope.id }), + }); if (!distributeResponse.ok) { const error = await distributeResponse.json(); @@ -422,9 +419,12 @@ echo "Created envelope: ${ENVELOPE_ID}" # Step 2: Send the document for signing echo "Sending document..." -curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \ +curl -s -X POST "${BASE_URL}/envelope/distribute" \ -H "Authorization: ${API_TOKEN}" \ - -H "Content-Type: application/json" + -H "Content-Type: application/json" \ + -d "{ + \"envelopeId\": \"${ENVELOPE_ID}\" + }" echo "Document sent for signing!" @@ -441,7 +441,7 @@ The API returns standard HTTP status codes and JSON error responses: | `400` | Bad request - check your request payload | | `401` | Unauthorized - invalid or missing API token | | `404` | Not found - resource doesn't exist | -| `429` | Rate limited - wait 60 seconds and retry | +| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. | | `500` | Server error - retry or contact support | ### Error Response Format @@ -485,7 +485,9 @@ The API returns standard HTTP status codes and JSON error responses: ### Handling Rate Limits -The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying: +The API has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically. + +Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information. ```javascript async function fetchWithRetry(url, options, maxRetries = 3) { @@ -493,8 +495,15 @@ async function fetchWithRetry(url, options, maxRetries = 3) { const response = await fetch(url, options); if (response.status === 429) { - console.log('Rate limited, waiting 60 seconds...'); - await new Promise((resolve) => setTimeout(resolve, 60000)); + const retryAfter = response.headers.get('Retry-After'); + + if (!retryAfter) { + return response; + } + + const retryAfterSeconds = Number.parseInt(retryAfter, 10); + console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`); + await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000)); continue; } diff --git a/apps/docs/content/docs/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..7834fb7ef 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.3", "next-plausible": "^3.12.5", "next-themes": "^0.4.6", "react": "^19.2.4", diff --git a/apps/openpage-api/lib/cors.ts b/apps/openpage-api/lib/cors.ts index 78dcebde5..707a258b3 100644 --- a/apps/openpage-api/lib/cors.ts +++ b/apps/openpage-api/lib/cors.ts @@ -1,142 +1,23 @@ /** - * Multi purpose CORS lib. - * Note: Based on the `cors` package in npm but using only web APIs. - * Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts + * Apply the public statistics API's wildcard CORS policy, without credentials. */ - -type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[]; - -type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise; - -interface CorsOptions { - origin?: StaticOrigin | OriginFn; - methods?: string | string[]; - allowedHeaders?: string | string[]; - exposedHeaders?: string | string[]; - credentials?: boolean; - maxAge?: number; - preflightContinue?: boolean; - optionsSuccessStatus?: number; -} - -const defaultOptions: CorsOptions = { - origin: '*', - methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', - preflightContinue: false, - optionsSuccessStatus: 204, -}; - -function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean { - return Array.isArray(allowed) - ? allowed.some((o) => isOriginAllowed(origin, o)) - : typeof allowed === 'string' - ? origin === allowed - : allowed instanceof RegExp - ? allowed.test(origin) - : !!allowed; -} - -function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) { - const headers = new Headers(); - - if (origin === '*') { - headers.set('Access-Control-Allow-Origin', '*'); - } else if (typeof origin === 'string') { - headers.set('Access-Control-Allow-Origin', origin); - headers.append('Vary', 'Origin'); - } else { - const allowed = isOriginAllowed(reqOrigin ?? '', origin); - - if (allowed && reqOrigin) { - headers.set('Access-Control-Allow-Origin', reqOrigin); - } - headers.append('Vary', 'Origin'); - } - - return headers; -} - -async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) { - const reqOrigin = req.headers.get('Origin') || undefined; - const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin; - - if (!value) { - return; - } - return getOriginHeaders(reqOrigin, value); -} - -function getAllowedHeaders(req: Request, allowed?: string | string[]) { - const headers = new Headers(); - - if (!allowed) { - allowed = req.headers.get('Access-Control-Request-Headers')!; - headers.append('Vary', 'Access-Control-Request-Headers'); - } else if (Array.isArray(allowed)) { - allowed = allowed.join(','); - } - if (allowed) { - headers.set('Access-Control-Allow-Headers', allowed); - } - - return headers; -} - -export default async function cors(req: Request, res: Response, options?: CorsOptions) { - const opts = { ...defaultOptions, ...options }; +export default async function cors(req: Request, res: Response): Promise { const { headers } = res; - const originHeaders = await originHeadersFromReq(req, opts.origin ?? false); - const mergeHeaders = (v: string, k: string) => { - if (k === 'Vary') { - headers.append(k, v); - } else { - headers.set(k, v); - } - }; + headers.set('Access-Control-Allow-Origin', '*'); - // If there's no origin we won't touch the response - if (!originHeaders) { - return res; - } - - originHeaders.forEach(mergeHeaders); - - if (opts.credentials) { - headers.set('Access-Control-Allow-Credentials', 'true'); - } - - const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(',') : opts.exposedHeaders; - - if (exposed) { - headers.set('Access-Control-Expose-Headers', exposed); - } - - // Handle the preflight request if (req.method === 'OPTIONS') { - if (opts.methods) { - const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods; + headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE'); + headers.set('Vary', 'Access-Control-Request-Headers'); - headers.set('Access-Control-Allow-Methods', methods); - } + const allowedHeaders = req.headers.get('Access-Control-Request-Headers'); - getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders); - - if (typeof opts.maxAge === 'number') { - headers.set('Access-Control-Max-Age', String(opts.maxAge)); - } - - if (opts.preflightContinue) { - return res; + if (allowedHeaders) { + headers.set('Access-Control-Allow-Headers', allowedHeaders); } headers.set('Content-Length', '0'); - return new Response(null, { status: opts.optionsSuccessStatus, headers }); + return new Response(null, { status: 204, headers }); } - // If we got here, it's a normal request return res; } - -export function initCors(options?: CorsOptions) { - return async (req: Request, res: Response) => cors(req, res, options); -} diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index bcc93e039..8d1d1d11b 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.3" }, "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-delete-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx index 17e8c9740..b2e8c8efb 100644 --- a/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx @@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({ if (isDocument) { await trpcUtils.document.findDocumentsInternal.invalidate(); } else { - await trpcUtils.template.findTemplates.invalidate(); + await Promise.all([ + trpcUtils.template.findTemplates.invalidate(), + trpcUtils.template.findTemplatesInternal.invalidate(), + ]); } if (result.failedIds.length > 0) { 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..2d938d466 100644 --- a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx @@ -290,15 +290,16 @@ export const EnvelopesBulkDownloadDialog = ({ {isOverDownloadLimit && ( - - You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to - continue. - + )} -
+
{envelopes.map((envelope) => { diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx index 69dbb8793..4ced9cbc8 100644 --- a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx @@ -96,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({ if (isDocument) { await trpcUtils.document.findDocumentsInternal.invalidate(); } else { - await trpcUtils.template.findTemplates.invalidate(); + await Promise.all([ + trpcUtils.template.findTemplates.invalidate(), + trpcUtils.template.findTemplatesInternal.invalidate(), + ]); } await onSuccess?.(data.folderId); 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..af03b216d 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 { @@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; +import { Plural, 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/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index 48507fb7d..a78f4179a 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -8,6 +8,7 @@ import { AppError } from '@documenso/lib/errors/app-error'; import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; +import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; @@ -23,6 +24,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 { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SpinnerBox } from '@documenso/ui/primitives/spinner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -38,27 +40,64 @@ import { useNavigate } from 'react-router'; import * as z from 'zod'; import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages'; -const ZAddRecipientsForNewDocumentSchema = z.object({ - distributeDocument: z.boolean(), - useCustomDocument: z.boolean().default(false), - customDocumentData: z - .array( +const DOCUMENT_NAME_SOURCE = { + TEMPLATE: 'template', + UPLOAD: 'upload', + CUSTOM: 'custom', +} as const; + +const getUploadedDocumentTitle = (file: File) => { + return file.name.replace(/\.[^/.]+$/, '').trim(); +}; + +/** + * Whether the file name can be used as a document title. + */ +const isUploadedFileNameUsable = (file?: File): file is File => { + if (!file) { + return false; + } + + const title = getUploadedDocumentTitle(file); + + return title.length > 0 && title.length <= DOCUMENT_TITLE_MAX_LENGTH; +}; + +const ZAddRecipientsForNewDocumentSchema = z + .object({ + distributeDocument: z.boolean(), + useCustomDocument: z.boolean().default(false), + documentNameSource: z.enum([ + DOCUMENT_NAME_SOURCE.TEMPLATE, + DOCUMENT_NAME_SOURCE.UPLOAD, + DOCUMENT_NAME_SOURCE.CUSTOM, + ]), + customDocumentName: z + .string() + .trim() + .max(DOCUMENT_TITLE_MAX_LENGTH, { message: msg`Document name is too long`.id }), + customDocumentData: z + .array( + z.object({ + title: z.string(), + data: z.instanceof(File).optional(), + envelopeItemId: z.string(), + }), + ) + .optional(), + recipients: z.array( z.object({ - title: z.string(), - data: z.instanceof(File).optional(), - envelopeItemId: z.string(), + id: z.number(), + email: ZRecipientEmailSchema, + name: z.string(), + signingOrder: z.number().optional(), }), - ) - .optional(), - recipients: z.array( - z.object({ - id: z.number(), - email: ZRecipientEmailSchema, - name: z.string(), - signingOrder: z.number().optional(), - }), - ), -}); + ), + }) + .refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, { + message: msg`Document name is required`.id, + path: ['customDocumentName'], + }); type TAddRecipientsForNewDocumentSchema = z.infer; @@ -87,6 +126,7 @@ export function TemplateUseDialog({ const navigate = useNavigate(); const [open, setOpen] = useState(false); + const [lastUploadedFile, setLastUploadedFile] = useState(); const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery( { @@ -106,6 +146,8 @@ export function TemplateUseDialog({ return { distributeDocument: false, useCustomDocument: false, + documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE, + customDocumentName: '', customDocumentData: envelopeItems.map((item) => ({ title: item.title, data: undefined, @@ -140,11 +182,39 @@ export function TemplateUseDialog({ const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation(); + /** + * Track the most recently uploaded file so its name can be used as the document name. + * Files with an unusable name are ignored, and the document name source is reset if + * no usable file remains. + */ + const updateLastUploadedFile = (file?: File) => { + const usableFile = isUploadedFileNameUsable(file) ? file : undefined; + + setLastUploadedFile(usableFile); + + if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) { + form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE); + } + }; + + const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => { + if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) { + return data.customDocumentName; + } + + if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) { + return getUploadedDocumentTitle(lastUploadedFile); + } + + return undefined; + }; + const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { try { - const customFilesToUpload = (data.customDocumentData || []).filter( - (item): item is { data: File; envelopeItemId: string; title: string } => - item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined, + const documentTitle = getDocumentTitle(data); + + const customFilesToUpload = (data.customDocumentData ?? []).filter( + (item): item is typeof item & { data: File } => item.data !== undefined, ); const customDocumentData = await Promise.all( @@ -163,6 +233,7 @@ export function TemplateUseDialog({ recipients: data.recipients, distributeDocument: data.distributeDocument, customDocumentData, + ...(documentTitle ? { override: { title: documentTitle } } : {}), }); toast({ @@ -195,9 +266,14 @@ export function TemplateUseDialog({ name: 'recipients', }); + const useCustomDocument = form.watch('useCustomDocument'); + const documentNameSource = form.watch('documentNameSource'); + const canUseUploadedDocumentName = Boolean(lastUploadedFile); + useEffect(() => { if (open) { form.reset(generateDefaultFormValues()); + setLastUploadedFile(undefined); } }, [open, form]); @@ -238,9 +314,9 @@ export function TemplateUseDialog({
- -
-
+ +
+
{formRecipients.map((recipient, index) => (
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && ( @@ -401,7 +477,17 @@ export function TemplateUseDialog({ onCheckedChange={(checked) => { field.onChange(checked); if (!checked) { - form.setValue('customDocumentData', undefined); + const customDocumentData = form.getValues('customDocumentData'); + + form.setValue( + 'customDocumentData', + customDocumentData?.map((item) => ({ + ...item, + data: undefined, + })), + ); + form.clearErrors('customDocumentData'); + updateLastUploadedFile(undefined); } }} /> @@ -428,7 +514,7 @@ export function TemplateUseDialog({ )} /> - {form.watch('useCustomDocument') && ( + {useCustomDocument && (
{isLoadingEnvelopeItems ? ( @@ -443,7 +529,7 @@ export function TemplateUseDialog({
@@ -451,13 +537,15 @@ export function TemplateUseDialog({
-
-

{item.title}

+
+

+ {field.value ? getUploadedDocumentTitle(field.value) : item.title} +

{field.value ? ( -

+ Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file -
+ ) : ( Default file )} @@ -475,6 +563,18 @@ export function TemplateUseDialog({ onClick={(e) => { e.preventDefault(); field.onChange(undefined); + + if (field.value === lastUploadedFile) { + // Fall back to any other uploaded file so the option stays available. + const remainingUploadedFile = form + .getValues('customDocumentData') + ?.find( + (item) => + item.data !== field.value && isUploadedFileNameUsable(item.data), + )?.data; + + updateLastUploadedFile(remainingUploadedFile); + } }} > @@ -517,7 +617,7 @@ export function TemplateUseDialog({ } if (file.type !== 'application/pdf') { - form.setError('customDocumentData', { + form.setError(`customDocumentData.${i}.data`, { type: 'manual', message: _(msg`Please select a PDF file`), }); @@ -526,7 +626,7 @@ export function TemplateUseDialog({ } if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) { - form.setError('customDocumentData', { + form.setError(`customDocumentData.${i}.data`, { type: 'manual', message: _( msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`, @@ -537,6 +637,8 @@ export function TemplateUseDialog({ } field.onChange(file); + form.clearErrors(`customDocumentData.${i}.data`); + updateLastUploadedFile(file); }} />
@@ -550,6 +652,112 @@ export function TemplateUseDialog({ )}
)} + + ( + + + Document name + + + +
+ + +
+ +
+ +
+
+ + + + + + + + + The document name will use the most recently uploaded file name without its + extension. + + + +
+ + {lastUploadedFile && ( +

+ {lastUploadedFile.name} +

+ )} + + {!canUseUploadedDocumentName && ( +

+ Upload a custom document to use its file name. +

+ )} +
+
+ +
+ + +
+
+
+ +
+ )} + /> + + {documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && ( + ( + + + + + + + )} + /> + )}
diff --git a/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx b/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx index 12180ab2a..3818cfcf3 100644 --- a/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx +++ b/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx @@ -18,6 +18,8 @@ import { useCallback, useRef } from 'react'; import type { Control } from 'react-hook-form'; import { useFieldArray, useFormContext, useFormState } from 'react-hook-form'; +import { useCspNonce } from '~/utils/nonce'; + import { useConfigureDocument } from './configure-document-context'; import type { TConfigureEmbedFormSchema } from './configure-document-view.types'; @@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps { export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => { const { _ } = useLingui(); const { isTemplate } = useConfigureDocument(); + const cspNonce = useCspNonce(); const $sensorApi = useRef(null); @@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure /> { 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/2fa/two-factor-code-dialog.tsx b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx new file mode 100644 index 000000000..79767f58f --- /dev/null +++ b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx @@ -0,0 +1,158 @@ +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { Trans } from '@lingui/react/macro'; +import type React from 'react'; +import { useState } from 'react'; +import { type FieldValues, type Path, useFormContext } from 'react-hook-form'; +import { z } from 'zod'; + +/** + * Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`. + */ +export const ZTwoFactorCodeFieldSchema = z.object({ + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), +}); + +export type TTwoFactorCodeFieldSchema = z.infer; + +export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode; + +type TwoFactorMethod = 'totp' | 'backup'; + +export type TwoFactorCodeDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + isSubmitting?: boolean; + submitLabel: React.ReactNode; + + /** + * Called when the user submits the code. Typically the parent form's submit handler. + */ + onSubmit: () => void; +}; + +/** + * Collects a TOTP or backup code on top of an existing form, mirroring the + * sign in and disable 2FA dialogs. + * + * Must be rendered inside a `` whose values include `totpCode` and `backupCode`. + */ +export const TwoFactorCodeDialog = ({ + open, + onOpenChange, + isSubmitting, + submitLabel, + onSubmit, +}: TwoFactorCodeDialogProps) => { + const form = useFormContext(); + + const [method, setMethod] = useState('totp'); + + const totpCodeName = 'totpCode' as Path; + const backupCodeName = 'backupCode' as Path; + + const onToggleMethod = () => { + form.resetField(totpCodeName); + form.resetField(backupCodeName); + + setMethod((current) => (current === 'totp' ? 'backup' : 'totp')); + }; + + const handleOpenChange = (value: boolean) => { + if (isSubmitting) { + return; + } + + if (!value) { + form.resetField(totpCodeName); + form.resetField(backupCodeName); + setMethod('totp'); + } + + onOpenChange(value); + }; + + return ( + + + + + Two-Factor Authentication + + + + {method === 'totp' ? ( + Enter the code from your authenticator app to continue. + ) : ( + Enter one of your backup codes to continue. + )} + + + +
+ {method === 'totp' && ( + ( + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + )} + + {method === 'backup' && ( + ( + + + Backup Code + + + + + + + )} + /> + )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/forms/password-setup-request-button.tsx b/apps/remix/app/components/forms/password-setup-request-button.tsx new file mode 100644 index 000000000..bac09f11d --- /dev/null +++ b/apps/remix/app/components/forms/password-setup-request-button.tsx @@ -0,0 +1,53 @@ +import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { CheckIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +/** + * Compact "send me a setup link" button that reports via toast, for settings + * cards where the surrounding layout provides the explanation. + */ +export const PasswordSetupRequestButton = () => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { user } = useSession(); + + const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({ + onSuccess: () => { + toast({ + title: _(msg`Check your email`), + description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`), + duration: 5000, + }); + }, + onError: (errorCode) => { + toast({ + title: _(msg`An error occurred`), + description: match(errorCode) + .with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`)) + .otherwise(() => _(msg`We were unable to send the email. Please try again later.`)), + variant: 'destructive', + }); + }, + }); + + if (isSuccess) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/apps/remix/app/components/forms/password-setup-request.tsx b/apps/remix/app/components/forms/password-setup-request.tsx new file mode 100644 index 000000000..35cfcaf80 --- /dev/null +++ b/apps/remix/app/components/forms/password-setup-request.tsx @@ -0,0 +1,61 @@ +import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { match } from 'ts-pattern'; + +export type PasswordSetupRequestProps = { + className?: string; +}; + +/** + * Inline "send me a setup link" control with its own sent/error states, for + * contexts like dialogs where a toast would be missed. + */ +export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => { + const { _ } = useLingui(); + const { user } = useSession(); + + const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest(); + + if (isSuccess) { + return ( + + + Check your email + + + + We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue. + + + + ); + } + + return ( +
+ {errorCode && ( + + + An error occurred + + + {match(errorCode) + .with('SIGNIN_DISABLED', () => + _(msg`Password sign in is disabled for this instance. Please contact support.`), + ) + .otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))} + + + )} + + +
+ ); +}; diff --git a/apps/remix/app/components/forms/password.tsx b/apps/remix/app/components/forms/password.tsx index d04f84131..65ba8c8c0 100644 --- a/apps/remix/app/components/forms/password.tsx +++ b/apps/remix/app/components/forms/password.tsx @@ -1,6 +1,6 @@ import { authClient } from '@documenso/auth/client'; import type { SessionUser } from '@documenso/auth/server/lib/session/session'; -import { AppError } from '@documenso/lib/errors/app-error'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -11,20 +11,21 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; -import { z } from 'zod'; +import type { z } from 'zod'; -export const ZPasswordFormSchema = z - .object({ - currentPassword: ZCurrentPasswordSchema, - password: ZPasswordSchema, - repeatedPassword: ZPasswordSchema, - }) - .refine((data) => data.password === data.repeatedPassword, { - message: 'Passwords do not match', - path: ['repeatedPassword'], - }); +import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog'; + +export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({ + currentPassword: ZCurrentPasswordSchema, + password: ZPasswordSchema, + repeatedPassword: ZPasswordSchema, +}).refine((data) => data.password === data.repeatedPassword, { + message: 'Passwords do not match', + path: ['repeatedPassword'], +}); export type TPasswordFormSchema = z.infer; @@ -33,29 +34,51 @@ export type PasswordFormProps = { user: SessionUser; }; -export const PasswordForm = ({ className }: PasswordFormProps) => { +export const PasswordForm = ({ className, user }: PasswordFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false); + const form = useForm({ values: { currentPassword: '', password: '', repeatedPassword: '', + totpCode: '', + backupCode: '', }, resolver: zodResolver(ZPasswordFormSchema), }); const isSubmitting = form.formState.isSubmitting; - const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { + const onFormSubmit = async (values: TPasswordFormSchema) => { + const { currentPassword, password, totpCode, backupCode } = values; + + // Collect the 2FA code in a dialog once the password fields are valid. + if (user.twoFactorEnabled && !hasTwoFactorCode(values)) { + if (isTwoFactorDialogOpen) { + const message = _(msg`A code is required`); + + form.setError('totpCode', { message }); + form.setError('backupCode', { message }); + } + + setIsTwoFactorDialogOpen(true); + return; + } + try { await authClient.emailPassword.updatePassword({ currentPassword, password, + totpCode: totpCode || undefined, + backupCode: backupCode || undefined, }); form.reset(); + setIsTwoFactorDialogOpen(false); toast({ title: _(msg`Password updated`), @@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { const error = AppError.parseError(err); const errorMessage = match(error.code) - .with('NO_PASSWORD', () => msg`User has no password.`) - .with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`) - .with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`) + .with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`) + .with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`) + .with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`) + .with( + AppErrorCode.INCORRECT_TWO_FACTOR_CODE, + AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS, + () => msg`The two factor code you provided is invalid. Please try again.`, + ) .otherwise( () => msg`We encountered an unknown error while attempting to update your password. Please try again later.`, ); @@ -83,7 +111,12 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { return ( - + {/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */} +
{
+ + + open={isTwoFactorDialogOpen} + onOpenChange={setIsTwoFactorDialogOpen} + isSubmitting={isSubmitting} + submitLabel={Update password} + onSubmit={form.handleSubmit(onFormSubmit)} + /> ); }; diff --git a/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx b/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx new file mode 100644 index 000000000..7e1476a77 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx @@ -0,0 +1,371 @@ +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Input } from '@documenso/ui/primitives/input'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { LucideIcon } from 'lucide-react'; +import { SearchIcon, UsersIcon } from 'lucide-react'; +import type { MouseEvent, ReactNode } from 'react'; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; +import { formatRelativeDate } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */ +export type AnalyticsActivityRow = { + key: string | number; + avatar: { + imageId: string | null; + fallback: string; + }; + title: string; + subtitle?: string | null; + sent: number; + completed: number; + pending: number; + /** 0-100, null when nothing was sent. */ + completionRate: number | null; + lastActiveAt: Date | null; + /** When set the whole row navigates here and the title becomes a link. */ + href?: string; +}; + +export type AnalyticsActivityTableCardProps = { + query: AnalyticsQueryResult; + /** Rows derived from `query.data`; empty while loading. */ + rows: AnalyticsActivityRow[]; + /** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */ + rangeKey: string; + title: ReactNode; + description: ReactNode; + /** Header of the first column, e.g. "Member". */ + columnLabel: ReactNode; + /** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */ + renderSummary: (count: number, activeCount: number) => ReactNode; + /** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */ + renderShowing: (visibleCount: number, totalCount: number) => ReactNode; + emptyLabel: ReactNode; + emptyIcon?: LucideIcon; + /** Placeholder for the search input, e.g. "Search members". */ + searchPlaceholder: string; + /** Rendered when the search matches nothing, e.g. "No members match your search". */ + noSearchResultsLabel: ReactNode; + /** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */ + testIdPrefix: string; + className?: string; +}; + +export const AnalyticsActivityTableCard = ({ + query, + rows, + rangeKey, + title, + description, + columnLabel, + renderSummary, + renderShowing, + emptyLabel, + emptyIcon: EmptyIcon = UsersIcon, + searchPlaceholder, + noSearchResultsLabel, + testIdPrefix, + className, +}: AnalyticsActivityTableCardProps) => { + const { i18n } = useLingui(); + + // Tracks which window "Show all" was pressed for, so it resets whenever the window changes. + const [expandedRangeKey, setExpandedRangeKey] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + const isExpanded = expandedRangeKey === rangeKey; + + const { data, isLoading, isError, refetch } = query; + + const activeCount = rows.filter((row) => row.sent > 0).length; + + const normalisedSearchTerm = searchTerm.trim().toLowerCase(); + const isSearching = normalisedSearchTerm.length > 0; + + // Search always shows every match; the preview limit only applies to the unfiltered list. + const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows; + const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT); + const hasHiddenRows = filteredRows.length > visibleRows.length; + + const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`; + + return ( + + +
+ {title} + + {description} +
+ + {data !== undefined && rows.length > 0 && ( +

+ {renderSummary(rows.length, activeCount)} +

+ )} +
+ + + {isError ? ( + + ) : isLoading || data === undefined ? ( +
    + {Array.from({ length: 4 }, (_, index) => ( +
  • + + +
    + + +
    + + + + + + +
  • + ))} +
+ ) : rows.length === 0 ? ( +
+
+
+ +

{emptyLabel}

+
+ ) : ( +
+
+
+ + {filteredRows.length === 0 ? ( +

+ {noSearchResultsLabel} +

+ ) : ( + <> + {/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */} +
+ + + + {columnLabel} + + Sent + + + Completed + + + Pending + + + Completion rate + + + Last active + + + + + + {visibleRows.map((row) => ( + + ))} + +
+
+ + {hasHiddenRows && ( +
+

+ {renderShowing(visibleRows.length, filteredRows.length)} +

+ + +
+ )} + + )} +
+ )} +
+
+ ); +}; + +type ActivityRowProps = { + row: AnalyticsActivityRow; + locale: string; + testIdPrefix: string; +}; + +const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => { + const { _ } = useLingui(); + const navigate = useNavigate(); + + const isActive = row.sent > 0; + + const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`; + + // Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout. + const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground'); + const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums'; + + // role="img" so the aria-label is valid (a bare span has no role that supports it). + const notAvailable = ( + + — + + ); + + /** + * The title link is the accessible target; clicking anywhere else on the row + * navigates too. Modifier clicks and clicks on the link itself are left to the + * browser so open-in-new-tab keeps working, and drag-selecting text does not + * navigate. + */ + const handleRowClick = (event: MouseEvent) => { + if (!row.href || event.defaultPrevented || event.button !== 0) { + return; + } + + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + if (event.target instanceof Element && event.target.closest('a')) { + return; + } + + if (window.getSelection()?.toString()) { + return; + } + + void navigate(row.href); + }; + + return ( + + {/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */} + +
+ + {row.avatar.imageId && } + {row.avatar.fallback} + + +
+ {row.href ? ( + + {row.title} + + ) : ( + + {row.title} + + )} + + {row.subtitle && {row.subtitle}} +
+
+
+ + + {row.sent.toLocaleString(locale)} + + + + {row.completed.toLocaleString(locale)} + + + + {row.pending.toLocaleString(locale)} + + + + {row.completionRate === null ? ( + notAvailable + ) : ( +
+ + )} + + + + {row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)} + + + ); +}; + +const ROW_PREVIEW_LIMIT = 8; + +const matchesSearch = (row: AnalyticsActivityRow, term: string) => { + return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term); +}; + +/** + * The table is pulled out to the card edge (-mx-6), so the outer cells get the + * card's px-6 gutter to line up with the header. "Last active" is hidden below + * md, so "Completion rate" takes the right gutter there. + */ +const FIRST_CELL_CLASS = '!pl-6'; +const LAST_CELL_CLASS = '!pr-6'; +const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4'; diff --git a/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx b/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx new file mode 100644 index 000000000..f122afe39 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx @@ -0,0 +1,203 @@ +import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { BarChart3Icon } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; + +import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics'; +import { getAnalyticsDateRangeDays } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsDocumentsOverTimeCardProps = { + range: AnalyticsRangeValue; + query: AnalyticsQueryResult; + className?: string; +}; + +type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket']; + +export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + // The backend decides the bucket, and it must match the points being rendered so + // the tick and tooltip formatting line up. Before data arrives it is guessed from + // the requested range. + const bucket: Bucket = data ? data.range.bucket : guessBucket(range); + + const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0; + + return ( + + +
+ + Documents created + + + {bucket === 'month' ? Monthly : Daily} +
+ + {data && ( +

+ + {data.total.toLocaleString(i18n.locale)} + {' '} + + total + +

+ )} +
+ + {/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */} + + {isError ? ( + + ) : isLoading || !data ? ( + + ) : data.total === 0 ? ( +
+
+
+ +

+ No documents created in this period +

+
+ ) : ( + + + + + formatTickLabel(value, bucket, i18n.locale)} + /> + + + + } + cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }} + /> + + + + + )} +
+
+ ); +}; + +type DocumentsOverTimeTooltipProps = { + active?: boolean; + payload?: Array<{ payload: { date: string; count: number } }>; + bucket: Bucket; + locale: string; +}; + +const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => { + const point = payload?.[0]?.payload; + + if (!active || !point) { + return null; + } + + const count = Number(point.count ?? 0); + + return ( +
+

{formatTooltipLabel(point.date, bucket, locale)}

+ +

+ +

+
+ ); +}; + +const CHART_HEIGHT = 240; + +const TARGET_DAILY_TICK_COUNT = 6; + +/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */ +const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92; + +const guessBucket = (range: AnalyticsRangeValue): Bucket => { + if (range.range === '12m') { + return 'month'; + } + + if (range.range === 'custom') { + return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day'; + } + + return 'day'; +}; + +/** + * Month buckets label every month (12 fit at the lg width) and let recharts drop + * overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels. + */ +const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => { + if (bucket === 'month') { + return 'preserveStartEnd'; + } + + if (pointCount <= TARGET_DAILY_TICK_COUNT) { + return 0; + } + + return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1); +}; + +const formatTickLabel = (date: string, bucket: Bucket, locale: string) => { + const parsed = DateTime.fromISO(date).setLocale(locale); + + if (bucket === 'month') { + return parsed.toLocaleString({ month: 'short' }); + } + + return parsed.toLocaleString({ month: 'short', day: 'numeric' }); +}; + +const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => { + const parsed = DateTime.fromISO(date).setLocale(locale); + + if (bucket === 'month') { + return parsed.toLocaleString({ month: 'long', year: 'numeric' }); + } + + return parsed.toLocaleString(DateTime.DATE_FULL); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx b/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx new file mode 100644 index 000000000..727284d35 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx @@ -0,0 +1,17 @@ +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { Trans } from '@lingui/react/macro'; + +/** + * Shown while the analytics route's `clientLoader` resolves the browser timezone + * during hydration. + */ +export const AnalyticsHydrateFallback = () => { + return ( +
+ + + Loading analytics + +
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx b/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx new file mode 100644 index 000000000..4eeae6387 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx @@ -0,0 +1,45 @@ +import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { InfoIcon } from 'lucide-react'; + +import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics'; + +export type AnalyticsNoActivityAlertProps = { + range: TTeamAnalyticsRange; + /** Invoked when the user asks to widen the range to the last 12 months. */ + onShowLastYear: () => void; +}; + +export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => { + const { _ } = useLingui(); + + const canWidenRange = range !== '12m'; + + return ( + + + + + + {canWidenRange && ( + + )} + + + ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx b/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx new file mode 100644 index 000000000..e7acfad18 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx @@ -0,0 +1,214 @@ +import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { LucideIcon } from 'lucide-react'; +import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; + +import { AnalyticsStatCard } from './analytics-stat-card'; + +/** The part of the overview response shared by the team and organisation procedures. */ +export type AnalyticsOverviewData = Pick; + +/** + * The third card counts the scope's "entities" (team members, organisation teams) + * and how many of them were active in the period. + */ +export type AnalyticsOverviewEntityCard = { + icon: LucideIcon; + title: ReactNode; + /** Applied to the value element, e.g. `analytics-members`. */ + testId: string; + select: (data: TData) => { active: number; total: number }; +}; + +export type AnalyticsOverviewCardsProps = { + query: AnalyticsQueryResult; + entity: AnalyticsOverviewEntityCard; +}; + +export const AnalyticsOverviewCards = ({ + query, + entity, +}: AnalyticsOverviewCardsProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + const entityCounts = data ? entity.select(data) : null; + + const formatNumber = (value: number) => value.toLocaleString(i18n.locale); + + const sharedProps = { + isLoading: isLoading || !data, + isError, + onRetry: refetch, + }; + + return ( +
+ Documents sent} + value={data ? formatNumber(data.sent.current) : null} + badge={data ? : null} + description={vs. previous period} + testId="analytics-sent" + /> + + Completion rate} + value={data ? formatRate(data.completionRate.rate) : null} + badge={ + data ? ( + + ) : null + } + description={of sent documents completed} + testId="analytics-completion-rate" + /> + + + {formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '} + inactive + + ) : null + } + testId={entity.testId} + /> +
+ ); +}; + +type SentDeltaBadgeProps = { + current: number; + previous: number; +}; + +const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => { + if (previous === 0 && current === 0) { + return null; + } + + if (previous === 0) { + return ( + + New + + ); + } + + const delta = Math.round(((current - previous) / previous) * 100); + + // Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display. + const label = + delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`; + + return ( + + {label} + + ); +}; + +type CompletionRateDeltaBadgeProps = { + rate: number | null; + previousRate: number | null; +}; + +const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => { + if (rate === null || previousRate === null) { + return null; + } + + // Compare the rounded values so the delta always agrees with the displayed rate. + const delta = Math.round(rate) - Math.round(previousRate); + + return ( + + {formatSignedNumber(delta)}% + + ); +}; + +type DeltaTone = 'positive' | 'negative' | 'zero' | 'new'; + +type DeltaBadgeProps = { + tone: DeltaTone; + testId: string; + children: ReactNode; +}; + +const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => { + const DeltaIcon = DELTA_TONE_ICONS[tone]; + + return ( + + {DeltaIcon && + ); +}; + +const DELTA_TONE_CLASSES: Record = { + positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + negative: 'bg-red-500/10 text-red-600 dark:text-red-400', + zero: 'bg-muted text-muted-foreground', + new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', +}; + +const MAX_DISPLAYED_DELTA_PERCENT = 999; + +const DELTA_TONE_ICONS: Record = { + positive: ArrowUpRightIcon, + negative: ArrowDownRightIcon, + zero: null, + new: null, +}; + +const formatRate = (rate: number | null) => { + if (rate === null) { + return '—'; + } + + return `${Math.round(rate)}%`; +}; + +const formatSignedNumber = (value: number) => { + if (value > 0) { + return `+${value}`; + } + + return String(value); +}; + +const getDeltaTone = (delta: number): DeltaTone => { + if (delta > 0) { + return 'positive'; + } + + if (delta < 0) { + return 'negative'; + } + + return 'zero'; +}; diff --git a/apps/remix/app/components/general/analytics/analytics-page-header.tsx b/apps/remix/app/components/general/analytics/analytics-page-header.tsx new file mode 100644 index 000000000..667d0c85b --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-page-header.tsx @@ -0,0 +1,67 @@ +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { ReactNode } from 'react'; + +import type { AnalyticsRangeValue } from '~/utils/analytics'; +import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics'; + +import { AnalyticsRangePicker } from './analytics-range-picker'; + +export type AnalyticsPageHeaderProps = { + avatarImageId: string | null; + /** The team or organisation name. */ + name: string; + range: AnalyticsRangeValue; + onRangeChange: (range: AnalyticsRangeValue) => void; + /** Rendered before the range picker, e.g. a link to a related analytics page. */ + actions?: ReactNode; + className?: string; +}; + +export const AnalyticsPageHeader = ({ + avatarImageId, + name, + range, + onRangeChange, + actions, + className, +}: AnalyticsPageHeaderProps) => { + const { _, i18n } = useLingui(); + + const rangeLabel = + range.range === 'custom' + ? formatAnalyticsDateRange(range.from, range.to, i18n.locale) + : _(ANALYTICS_RANGE_LABELS[range.range]); + + return ( +
+
+ + {avatarImageId && } + {name.slice(0, 1)} + + +
+

+ Analytics +

+ +

+ + Usage overview for {name} · {rangeLabel} + +

+
+
+ +
+ {actions} + + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-query-error.tsx b/apps/remix/app/components/general/analytics/analytics-query-error.tsx new file mode 100644 index 000000000..41dd48030 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-query-error.tsx @@ -0,0 +1,37 @@ +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type AnalyticsQueryErrorProps = { + onRetry: () => Promise; + className?: string; +}; + +export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => { + const [isRetrying, setIsRetrying] = useState(false); + + const handleRetry = async () => { + setIsRetrying(true); + + try { + await onRetry(); + } finally { + setIsRetrying(false); + } + }; + + return ( + + + + This data could not be loaded. + + + + + + ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-range-picker.tsx b/apps/remix/app/components/general/analytics/analytics-range-picker.tsx new file mode 100644 index 000000000..ff2ed33a4 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-range-picker.tsx @@ -0,0 +1,264 @@ +import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size'; +import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Button } from '@documenso/ui/primitives/button'; +import type { CalendarProps } from '@documenso/ui/primitives/calendar'; +import { Calendar } from '@documenso/ui/primitives/calendar'; +import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover'; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { DateTime } from 'luxon'; +import { useRef, useState } from 'react'; + +import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics'; +import { + ANALYTICS_PRESET_RANGES, + ANALYTICS_RANGE_LABELS, + formatAnalyticsDate, + formatAnalyticsDateRange, + getAnalyticsDateRangeDays, +} from '~/utils/analytics'; + +export type AnalyticsRangePickerProps = { + value: AnalyticsRangeValue; + onValueChange: (value: AnalyticsRangeValue) => void; +}; + +/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */ +type DraftRange = { + from: Date | undefined; + to: Date | undefined; +}; + +/** A single react-day-picker matcher, e.g. `{ after: Date }`. */ +type DayMatcher = Exclude; + +/** + * A preset select with a "Custom range…" item that opens a two month range + * calendar anchored to the select. The custom window is only committed when + * "Apply" is pressed. + */ +export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => { + const { _, i18n } = useLingui(); + const { width } = useWindowSize(); + + const triggerRef = useRef(null); + const contentRef = useRef(null); + + const [isPickerOpen, setIsPickerOpen] = useState(false); + const [draft, setDraft] = useState(); + + const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1; + + const today = DateTime.local().startOf('day'); + + const openPicker = () => { + setDraft( + value.range === 'custom' + ? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() } + : undefined, + ); + + setIsPickerOpen(true); + }; + + const closePicker = () => { + setIsPickerOpen(false); + setDraft(undefined); + }; + + const handleSelectValueChange = (nextValue: string) => { + if (nextValue === CUSTOM_RANGE_VALUE) { + openPicker(); + + return; + } + + const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue); + + if (!preset) { + return; + } + + onValueChange({ range: preset }); + }; + + /** + * Picking a day starts a new window unless one end is already pending, in which + * case it completes it. This replaces react-day-picker's default, which extends + * a completed window instead of starting over. + */ + const handleDaySelect = (_nextRange: unknown, day: Date) => { + if (draft?.from && !draft.to) { + setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day }); + + return; + } + + setDraft({ from: day, to: undefined }); + }; + + const handleApply = () => { + if (!draft?.from || !draft.to) { + return; + } + + onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) }); + + closePicker(); + }; + + // Only the last year (plus a day) up to today is selectable. + const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK); + const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }]; + + // Open on the month of the pending window (or today), keeping the current month + // as the right-most one so no fully disabled future month is shown. + const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month'); + const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 }); + const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate(); + + const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null; + const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null; + const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0; + + const customLabel = + value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined; + + return ( + { + if (!open) { + closePicker(); + } + }} + > + {/* + * The select never holds "custom" as its value so choosing "Custom range…" always + * fires a change, letting an active custom window be adjusted. The trigger shows + * the formatted window through the placeholder instead. + */} + + + { + if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) { + return; + } + + event.preventDefault(); + + const content = contentRef.current; + const firstTabbable = content?.querySelector(TABBABLE_SELECTOR); + + (firstTabbable ?? content)?.focus(); + }} + // There is no popover trigger element, so hand focus back to the select. + onCloseAutoFocus={(event) => { + event.preventDefault(); + triggerRef.current?.focus(); + }} + > +
+ +
+ +
+

+ {draftFrom && draftTo ? ( + <> + {formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '} + + + ) : draftFrom ? ( + Pick an end date + ) : ( + Pick a start date + )} +

+ +
+ + + +
+
+
+
+ ); +}; + +const CUSTOM_RANGE_VALUE = 'custom'; + +/** Tailwind `sm` breakpoint; two months are shown from here up. */ +const SM_BREAKPOINT = 640; + +/** First element the popover should focus: the calendar's month navigation, then the days. */ +const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]'; + +const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({ + value, + label: ANALYTICS_RANGE_LABELS[value], +})); diff --git a/apps/remix/app/components/general/analytics/analytics-stat-card.tsx b/apps/remix/app/components/general/analytics/analytics-stat-card.tsx new file mode 100644 index 000000000..b0f3ba814 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-stat-card.tsx @@ -0,0 +1,63 @@ +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsStatCardProps = { + icon: LucideIcon; + title: ReactNode; + value: ReactNode; + description: ReactNode; + badge?: ReactNode; + isLoading: boolean; + isError: boolean; + onRetry: () => Promise; + testId: string; +}; + +export const AnalyticsStatCard = ({ + icon: Icon, + title, + value, + description, + badge, + isLoading, + isError, + onRetry, + testId, +}: AnalyticsStatCardProps) => { + return ( + + +
+

{title}

+ +
+ + {isError ? ( + + ) : isLoading ? ( +
+ + +
+ ) : ( + <> +
+

+ {value} +

+ + {badge} +
+ +

{description}

+ + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx b/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx new file mode 100644 index 000000000..38bd260e8 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx @@ -0,0 +1,198 @@ +import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsStatusBreakdownCardProps = { + query: AnalyticsQueryResult; + className?: string; +}; + +export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => { + const { _, i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : []; + + return ( + + +
+ + Status breakdown + + + + Documents created in this period + +
+ + {data && ( +

+ + {data.total.toLocaleString(i18n.locale)} + {' '} + + total + +

+ )} +
+ + + {isError ? ( + + ) : isLoading || !data ? ( +
+ + +
+ {STATUS_ROWS.slice(0, 3).map((row) => ( + + ))} +
+
+ ) : data.total === 0 ? ( +
+ + +

+ No documents in this period +

+
+ ) : ( +
+ + +
    + {rows.map((row) => ( +
  • +
    +
    + +
    + + {row.count.toLocaleString(i18n.locale)} + + {row.percent}% +
    +
  • + ))} +
+
+ )} +
+
+ ); +}; + +type StatusBarProps = { + segments: Array<{ key: string; percent: number; color: string }>; + label: string; +}; + +/** + * Stacked horizontal bar. Segment widths come from the largest-remainder + * percentages so they always add up to the full width; an empty list renders + * the muted track on its own. + */ +const StatusBar = ({ segments, label }: StatusBarProps) => { + return ( +
+ {segments.map((segment) => ( +
+ ))} +
+ ); +}; + +type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled'; + +type StatusRow = { + key: StatusKey; + label: MessageDescriptor; + color: string; +}; + +/** + * Single source of truth for status colours so the bar and the legend cannot drift. + */ +const STATUS_ROWS: StatusRow[] = [ + { key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' }, + { key: 'pending', label: msg`Pending`, color: '#f59e0b' }, + { key: 'rejected', label: msg`Rejected`, color: '#ef4444' }, + { key: 'cancelled', label: msg`Cancelled`, color: '#f97316' }, + { key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' }, +]; + +/** + * Assign integer percentages to the non-zero rows using largest-remainder + * allocation so the values always sum to exactly 100, with every non-zero row + * shown as at least 1%. + */ +const allocatePercentages = (rows: T[]): Array => { + const visibleRows = rows.filter((row) => row.count > 0); + const total = visibleRows.reduce((sum, row) => sum + row.count, 0); + + if (total === 0) { + return []; + } + + const allocations = visibleRows.map((row, index) => { + const exact = (row.count / total) * 100; + const floored = Math.floor(exact); + + return { index, percent: floored, remainder: exact - floored }; + }); + + let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0); + + const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index); + + for (const allocation of byRemainder) { + if (remaining <= 0) { + break; + } + + allocation.percent += 1; + remaining -= 1; + } + + // Every non-zero row must display at least 1%; take the difference from the largest rows. + const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index); + + for (const allocation of allocations) { + if (allocation.percent > 0) { + continue; + } + + allocation.percent = 1; + + const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1); + + if (donor) { + donor.percent -= 1; + } + } + + return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent })); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx b/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx new file mode 100644 index 000000000..9f4837419 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx @@ -0,0 +1,145 @@ +import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { FileTextIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; +import { formatRelativeDate } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +/** The template shape shared by the team and organisation procedures. */ +export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number]; + +export type AnalyticsTemplateUsageCardProps = { + query: AnalyticsQueryResult<{ templates: TTemplate[] }>; + /** Where the template title links to. Return null to render a plain title. */ + getTemplateHref: (template: TTemplate) => string | null; + /** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */ + renderTemplateMeta?: (template: TTemplate) => ReactNode; + /** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */ + templatesHref?: string; + className?: string; +}; + +export const AnalyticsTemplateUsageCard = ({ + query, + getTemplateHref, + renderTemplateMeta, + templatesHref, + className, +}: AnalyticsTemplateUsageCardProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + return ( + + + + Template usage + + + + Documents created from templates + + + + + {isError ? ( + + ) : isLoading || !data ? ( +
    + {Array.from({ length: 3 }, (_, index) => ( +
  • + + + +
    + + +
    + + +
  • + ))} +
+ ) : data.templates.length === 0 ? ( +
+
+
+ +

+ No documents were created from templates in this period +

+ + {templatesHref && ( + + )} +
+ ) : ( +
    + {data.templates.map((template, index) => { + const href = template.title === null ? null : getTemplateHref(template); + const meta = renderTemplateMeta?.(template); + + return ( +
  1. + + +
    +
    + +
    + {template.title === null ? ( + + Unavailable template + + ) : href !== null ? ( + + {template.title} + + ) : ( + {template.title} + )} + + {(meta || template.updatedAt !== null) && ( + + {meta} + {meta && template.updatedAt !== null && ' · '} + {template.updatedAt !== null && ( + Updated {formatRelativeDate(template.updatedAt, i18n.locale)} + )} + + )} +
    + + + + +
  2. + ); + })} +
+ )} +
+
+ ); +}; diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index 7f245872f..7a53ff249 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -17,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, @@ -596,9 +596,13 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => { {hasValidSearch ? ( - {formatChipCount(totalVisibleCount, isVisibleCountCapped)} results + isVisibleCountCapped ? ( + {formatChipCount(totalVisibleCount, isVisibleCountCapped)} results + ) : ( + + ) ) : ( - {totalVisibleCount} items + )}
diff --git a/apps/remix/app/components/general/app-nav-mobile.tsx b/apps/remix/app/components/general/app-nav-mobile.tsx index 44f81f639..76eea8c24 100644 --- a/apps/remix/app/components/general/app-nav-mobile.tsx +++ b/apps/remix/app/components/general/app-nav-mobile.tsx @@ -1,6 +1,9 @@ import LogoImage from '@documenso/assets/logo.png'; import { authClient } from '@documenso/auth/client'; +import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations'; +import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; @@ -22,6 +25,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps const { organisations } = useSession(); const currentTeam = useOptionalCurrentTeam(); + const currentOrganisation = useOptionalCurrentOrganisation(); const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery( { @@ -37,18 +41,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps }; const menuNavigationLinks = useMemo(() => { - let teamUrl = currentTeam?.url || null; + const navigationTeam = + currentTeam ?? + (organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null); - if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) { - teamUrl = organisations[0].teams[0].url; - } - - if (!teamUrl) { + if (!navigationTeam) { return [ { href: '/inbox', text: t`Inbox`, }, + ...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole) + ? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }] + : []), { href: '/settings/profile', text: t`Settings`, @@ -56,6 +61,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps ]; } + const teamUrl = navigationTeam.url; + return [ { href: `/t/${teamUrl}/documents`, @@ -69,12 +76,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps href: '/inbox', text: t`Inbox`, }, + ...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole) + ? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }] + : []), { href: '/settings/profile', text: t`Settings`, }, ]; - }, [currentTeam, organisations]); + }, [currentTeam, currentOrganisation, organisations, t]); return ( diff --git a/apps/remix/app/components/general/avatar-with-recipient.tsx b/apps/remix/app/components/general/avatar-with-recipient.tsx deleted file mode 100644 index 53f5eae6e..000000000 --- a/apps/remix/app/components/general/avatar-with-recipient.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; -import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; -import type { TRecipientLite } from '@documenso/lib/types/recipient'; -import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import { cn } from '@documenso/ui/lib/utils'; -import { useToast } from '@documenso/ui/primitives/use-toast'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { DocumentStatus } from '@prisma/client'; - -import { StackAvatar } from './stack-avatar'; - -export type AvatarWithRecipientProps = { - recipient: TRecipientLite; - documentStatus: DocumentStatus; -}; - -export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) { - const [, copy] = useCopyToClipboard(); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null; - - const onRecipientClick = () => { - if (!signingToken) { - return; - } - - void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => { - toast({ - title: _(msg`Copied to clipboard`), - description: _(msg`The signing link has been copied to your clipboard.`), - }); - }); - }; - - return ( -
- - -
-

{recipient.email || recipient.name}

-

{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}

-
-
- ); -} 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-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx index 2d1b51747..1d2806fda 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx @@ -1,5 +1,7 @@ import { AppError } from '@documenso/lib/errors/app-error'; import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { UserAuthMethod } from '@documenso/lib/types/user-auth-method'; +import { trpc } from '@documenso/trpc/react'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; @@ -7,11 +9,13 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from ' import { Input } from '@documenso/ui/primitives/input'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; +import { Loader2Icon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password'; export type DocumentSigningAuthPasswordProps = { open: boolean; @@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({ }: DocumentSigningAuthPasswordProps) => { const { t } = useLingui(); - const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = - useRequiredDocumentSigningAuthContext(); + const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); + + // Fetched on demand since this is only needed once the user opts for password auth. + const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, { + enabled: !!user, + }); const form = useForm({ resolver: zodResolver(ZPasswordAuthFormSchema), @@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({ const [formErrorCode, setFormErrorCode] = useState(null); + // If the query fails we fall through to the regular password form rather than blocking. + const isPasswordSetupRequired = + !!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD); + const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => { try { setIsCurrentlyAuthenticating(true); @@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({ const error = AppError.parseError(err); setFormErrorCode(error.code); - - // Todo: Alert. } }; @@ -79,9 +89,22 @@ export const DocumentSigningAuthPassword = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); + if (user && isAuthMethodsPending) { + return ( +
+ +
+ ); + } + + if (isPasswordSetupRequired) { + return ; + } + return (
- + {/* method="post" so a pre-hydration native submit can't leak the password into the URL. */} +
{formErrorCode && ( diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx new file mode 100644 index 000000000..b827c1cbf --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx @@ -0,0 +1,63 @@ +import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { Trans } from '@lingui/react/macro'; + +import { PasswordSetupRequest } from '~/components/forms/password-setup-request'; + +export type DocumentSigningAuthSetPasswordProps = { + onOpenChange: (value: boolean) => void; +}; + +/** + * Shown in place of the password reauth form when the signed in user has no + * password (e.g. they signed up via OAuth or a passkey). + * + * Password based action auth is meant to prove more than possession of a session, + * so rather than letting the session set a password inline we send the user the + * verified reset link and ask them to come back. + */ +export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => { + const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email'); + + return ( +
+ {isEmailPasswordSigninEnabled ? ( + <> + + + No password set + + + + Signing this field requires a password, but your account does not have one. We can email you a link to + set one. Once done, sign in again and return to this document to continue. + + + + + + + ) : ( + + + Password authentication unavailable + + + + Your account does not have a password and password sign in is disabled for this instance. Please contact + the document sender to use a different authentication method. + + + + )} + + + + +
+ ); +}; 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-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 051ee562c..06bedf4b6 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -28,6 +28,7 @@ import { useNavigate, useSearchParams } from 'react-router'; import { z } from 'zod'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { useCurrentTeam } from '~/providers/team'; +import { useCspNonce } from '~/utils/nonce'; import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; export type DocumentEditFormProps = { @@ -42,6 +43,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => { const { toast } = useToast(); const { _ } = useLingui(); + const cspNonce = useCspNonce(); const navigate = useNavigate(); @@ -473,6 +475,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath onSubmit={onAddSignersFormSubmit} onAutoSave={onAddSignersFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} + nonce={cspNonce} /> { const { _ } = useLingui(); - const [query, setQuery] = useQueryState('query', documentsSearchParams.query); + const [{ query }, setSearchParams] = useQueryStates( + { + query: documentsSearchParams.query, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); const [searchTerm, setSearchTerm] = useState(query ?? ''); const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); useEffect(() => { if (debouncedSearchTerm !== (query ?? '')) { - void setQuery(debouncedSearchTerm || null); + // Reset pagination so a new search never lands on an empty page. + void setSearchParams({ + query: debouncedSearchTerm || null, + page: null, + }); } - }, [debouncedSearchTerm, query, setQuery]); + }, [debouncedSearchTerm, query, setSearchParams]); return ( { 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-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index cff9bab64..b3173b547 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -45,6 +45,7 @@ import { isDeepEqual } from 'remeda'; import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog'; import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog'; import { useCurrentTeam } from '~/providers/team'; +import { useCspNonce } from '~/utils/nonce'; export const EnvelopeEditorRecipientForm = () => { const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } = @@ -52,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => { const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); + const cspNonce = useCspNonce(); const { t } = useLingui(); const { toast } = useToast(); @@ -795,6 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
{ diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx index ea60d1819..1abf0fa54 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx @@ -16,7 +16,7 @@ import { ZDocumentMetaTimezoneSchema, } from '@documenso/lib/types/document-meta'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; -import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; +import { isHttpUrl } from '@documenso/lib/utils/is-http-url'; import { canAccessTeamDocument, DocumentSignatureType, extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { zEmail } from '@documenso/lib/utils/zod'; import { trpc } from '@documenso/trpc/react'; @@ -97,7 +97,7 @@ export const ZAddSettingsFormSchema = z.object({ redirectUrl: z .string() .optional() - .refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), { + .refine((value) => value === undefined || value === '' || isHttpUrl(value), { message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.', }), language: z 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..60d970caf 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'; @@ -25,6 +26,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog'; +import { useCspNonce } from '~/utils/nonce'; import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form'; @@ -41,10 +43,12 @@ type LocalFile = { export const EnvelopeEditorUploadPage = () => { const organisation = useCurrentOrganisation(); + const cspNonce = useCspNonce(); const { t, i18n } = useLingui(); const { maximumEnvelopeItemCount, remaining } = useLimits(); const { toast } = useToast(); + const analytics = useAnalytics(); const { envelope, @@ -213,6 +217,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 +300,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`, @@ -480,7 +496,7 @@ export const EnvelopeEditorUploadPage = () => { {/* Uploaded Files List */}
- + {(provided) => (
{ 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/filter-pill.tsx b/apps/remix/app/components/general/filter-pill.tsx index ae1d69f75..a786bd8f1 100644 --- a/apps/remix/app/components/general/filter-pill.tsx +++ b/apps/remix/app/components/general/filter-pill.tsx @@ -31,6 +31,8 @@ type FilterPillCommonProps = { enableSearch?: boolean; searchPlaceholder?: string; loading?: boolean; + /** Whether the selection can be removed. Defaults to true. */ + clearable?: boolean; testId?: string; }; @@ -61,7 +63,7 @@ export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps; * selections followed by a "+N more" chip. */ export const FilterPill = (props: FilterPillProps) => { - const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props; + const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, clearable = true, testId } = props; const [open, setOpen] = useState(false); @@ -84,7 +86,7 @@ export const FilterPill = (props: FilterPillProps) => { return; } - props.onChange(nextValue === props.value ? null : nextValue); + props.onChange(nextValue === props.value && clearable ? null : nextValue); setOpen(false); }; @@ -168,7 +170,7 @@ export const FilterPill = (props: FilterPillProps) => { ))} - {hasSelection && ( + {hasSelection && clearable && ( <> diff --git a/apps/remix/app/components/general/metric-card.tsx b/apps/remix/app/components/general/metric-card.tsx index 14ae66035..3659e62aa 100644 --- a/apps/remix/app/components/general/metric-card.tsx +++ b/apps/remix/app/components/general/metric-card.tsx @@ -7,9 +7,10 @@ export type CardMetricProps = { value?: string | number; className?: string; children?: React.ReactNode; + testId?: string; }; -export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => { +export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => { return (
{children || ( -

+

{typeof value === 'number' ? value.toLocaleString('en-US') : value}

)} diff --git a/apps/remix/app/components/general/org-menu-switcher.tsx b/apps/remix/app/components/general/org-menu-switcher.tsx index dfa52d964..842a801e0 100644 --- a/apps/remix/app/components/general/org-menu-switcher.tsx +++ b/apps/remix/app/components/general/org-menu-switcher.tsx @@ -6,9 +6,13 @@ import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/ import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { isAdmin } from '@documenso/lib/utils/is-admin'; -import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; +import { + canAccessOrganisationAnalytics, + canExecuteOrganisationAction, + formatOrganisationAnalyticsPath, +} from '@documenso/lib/utils/organisations'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog'; import { cn } from '@documenso/ui/lib/utils'; @@ -62,6 +66,13 @@ export const OrgMenuSwitcher = () => { const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole); + // Team analytics take precedence when in a team context, the team page links to organisation analytics. + const analyticsPath = canAccessTeamSettings + ? formatAnalyticsPath(currentTeam.url) + : currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole) + ? formatOrganisationAnalyticsPath(currentOrganisation.url) + : null; + // Use hovered org for teams display if available, // otherwise use current team's org if in a team, // finally fallback to selected org @@ -271,6 +282,14 @@ export const OrgMenuSwitcher = () => { + {analyticsPath && ( + + + Analytics + + + )} + (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/skeletons/document-edit-skeleton.tsx b/apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx deleted file mode 100644 index a17a80f31..000000000 --- a/apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Skeleton } from '@documenso/ui/primitives/skeleton'; -import { Trans } from '@lingui/react/macro'; -import { ChevronLeft, Loader } from 'lucide-react'; -import { Link } from 'react-router'; - -export default function DocumentEditSkeleton() { - return ( -
- - - Documents - - -

- Loading Document... -

- -
- -
- -
-
-
- - -

- Loading document... -

-
-
- -
-
-
- ); -} diff --git a/apps/remix/app/components/general/stack-avatar.tsx b/apps/remix/app/components/general/stack-avatar.tsx index 48caed2ee..22eb096bc 100644 --- a/apps/remix/app/components/general/stack-avatar.tsx +++ b/apps/remix/app/components/general/stack-avatar.tsx @@ -1,4 +1,5 @@ import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type'; +import { cn } from '@documenso/ui/lib/utils'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; const ZIndexes: { [key: string]: string } = { @@ -14,9 +15,10 @@ export type StackAvatarProps = { zIndex?: string; fallbackText?: string; type: RecipientStatusType; + className?: string; }; -export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => { +export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => { let classes = ''; let zIndexClass = ''; const firstClass = first ? '' : '-ml-3'; @@ -46,7 +48,14 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva } return ( - + {fallbackText} ); diff --git a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx index ef096afe6..6ff083ddd 100644 --- a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx +++ b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx @@ -1,16 +1,33 @@ -import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type'; +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { + getExtraRecipientsType, + getRecipientType, + RecipientStatusType, +} from '@documenso/lib/client-only/recipient-type'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import { cn } from '@documenso/ui/lib/utils'; import { PopoverHover } from '@documenso/ui/primitives/popover'; +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 type { DocumentStatus } from '@prisma/client'; -import { useMemo } from 'react'; +import { DocumentStatus } from '@prisma/client'; +import type { LucideIcon } from 'lucide-react'; +import { + CheckIcon, + CircleCheckIcon, + CircleDashedIcon, + CircleXIcon, + ClockIcon, + CopyIcon, + MailOpenIcon, +} from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { AvatarWithRecipient } from './avatar-with-recipient'; import { StackAvatar } from './stack-avatar'; -import { StackAvatars } from './stack-avatars'; export type StackAvatarsWithTooltipProps = { documentStatus: DocumentStatus; @@ -27,125 +44,238 @@ export const StackAvatarsWithTooltip = ({ }: StackAvatarsWithTooltipProps) => { const { _ } = useLingui(); - const waitingRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING, - ); + const sections = useMemo(() => { + const groups = groupRecipientsByStatus(recipients); - const openedRecipients = recipients.filter((recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED); - - const completedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED, - ); - - const uncompletedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED, - ); - - const rejectedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED, - ); - - const sortedRecipients = useMemo(() => { - const otherRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED, - ); - - return [ - ...rejectedRecipients.sort((a, b) => a.id - b.id), - ...otherRecipients.sort((a, b) => { - return a.id - b.id; - }), - ]; + return RECIPIENT_STATUS_SECTIONS.map((section) => ({ + ...section, + recipients: groups[section.type], + })).filter((section) => section.recipients.length > 0); }, [recipients]); + const canCopySigningLink = documentStatus === DocumentStatus.PENDING; + return ( } + trigger={children || } contentProps={{ - className: 'flex flex-col gap-y-5 py-2', + className: + 'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm', side: position, + // Keep clear of the sticky app header (h-16, z-[60]) which paints above popovers. + collisionPadding: { top: 72, bottom: 8, left: 8, right: 8 }, + // Opened via hover, so don't steal focus from wherever the user was. + onOpenAutoFocus: (event) => event.preventDefault(), }} > - {completedRecipients.length > 0 && ( -
-

- Completed -

- {completedRecipients.map((recipient) => ( -
- ( +
+
+ + {_(section.label)} + {section.recipients.length} +
+ +
+ {section.recipients.map((recipient) => ( + -
-

{recipient.email || recipient.name}

-

- {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} -

-
-
- ))} + ))} +
- )} - - {rejectedRecipients.length > 0 && ( -
-

- Rejected -

- {rejectedRecipients.map((recipient) => ( -
- -
-

{recipient.email || recipient.name}

-

- {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} -

-
-
- ))} -
- )} - - {waitingRecipients.length > 0 && ( -
-

- Waiting -

- {waitingRecipients.map((recipient) => ( - - ))} -
- )} - - {openedRecipients.length > 0 && ( -
-

- Opened -

- {openedRecipients.map((recipient) => ( - - ))} -
- )} - - {uncompletedRecipients.length > 0 && ( -
-

- Uncompleted -

- {uncompletedRecipients.map((recipient) => ( - - ))} -
- )} + ))} ); }; + +type RecipientRowProps = { + recipient: TRecipientLite; + signingToken: string | null; +}; + +const RecipientRow = ({ recipient, signingToken }: RecipientRowProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [, copy] = useCopyToClipboard(); + + const [isCopied, setIsCopied] = useState(false); + const copiedTimeoutRef = useRef | null>(null); + + useEffect(() => () => clearTimeout(copiedTimeoutRef.current ?? undefined), []); + + const onCopySigningLink = () => { + if (!signingToken) { + return; + } + + void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => { + setIsCopied(true); + + clearTimeout(copiedTimeoutRef.current ?? undefined); + copiedTimeoutRef.current = setTimeout(() => setIsCopied(false), COPIED_INDICATOR_DURATION_MS); + + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + }); + }; + + const content = ( + <> + + +
+

{recipient.email || recipient.name}

+

{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}

+
+ + ); + + if (!signingToken) { + return
{content}
; + } + + return ( + + ); +}; + +const RecipientAvatarStack = ({ recipients }: { recipients: TRecipientLite[] }) => { + const sortedRecipients = useMemo(() => { + const byId = (a: TRecipientLite, b: TRecipientLite) => a.id - b.id; + + const rejected = recipients.filter((r) => getRecipientType(r) === RecipientStatusType.REJECTED); + const others = recipients.filter((r) => getRecipientType(r) !== RecipientStatusType.REJECTED); + + return [...rejected.sort(byId), ...others.sort(byId)]; + }, [recipients]); + + const visibleRecipients = sortedRecipients.slice(0, MAX_VISIBLE_AVATARS); + const hiddenRecipients = sortedRecipients.slice(MAX_VISIBLE_AVATARS); + + return ( + <> + {visibleRecipients.map((recipient, index) => { + const isOverflowSlot = index === MAX_VISIBLE_AVATARS - 1 && hiddenRecipients.length > 0; + const zIndex = String(50 - index * 10); + + if (isOverflowSlot) { + return ( + + ); + } + + return ( + + ); + })} + + ); +}; + +const groupRecipientsByStatus = (recipients: TRecipientLite[]) => { + const groups: Record = { + [RecipientStatusType.COMPLETED]: [], + [RecipientStatusType.REJECTED]: [], + [RecipientStatusType.WAITING]: [], + [RecipientStatusType.OPENED]: [], + [RecipientStatusType.UNSIGNED]: [], + }; + + for (const recipient of recipients) { + groups[getRecipientType(recipient)].push(recipient); + } + + return groups; +}; + +const MAX_VISIBLE_AVATARS = 5; + +const COPIED_INDICATOR_DURATION_MS = 2000; + +const recipientRowClassName = '-mx-2 flex items-center gap-2 rounded-md px-2 py-1'; + +type RecipientStatusSection = { + type: RecipientStatusType; + label: MessageDescriptor; + icon: LucideIcon; + className: string; + /** Whether recipients in this section still need to sign, so a signing link can be copied. */ + hasSigningLink: boolean; +}; + +const RECIPIENT_STATUS_SECTIONS: RecipientStatusSection[] = [ + { + type: RecipientStatusType.COMPLETED, + label: msg`Completed`, + icon: CircleCheckIcon, + className: 'text-green-600 dark:text-green-400', + hasSigningLink: false, + }, + { + type: RecipientStatusType.REJECTED, + label: msg`Rejected`, + icon: CircleXIcon, + className: 'text-red-600 dark:text-red-400', + hasSigningLink: false, + }, + { + type: RecipientStatusType.WAITING, + label: msg`Waiting`, + icon: ClockIcon, + className: 'text-blue-600 dark:text-blue-400', + hasSigningLink: true, + }, + { + type: RecipientStatusType.OPENED, + label: msg`Opened`, + icon: MailOpenIcon, + className: 'text-amber-600 dark:text-amber-400', + hasSigningLink: true, + }, + { + type: RecipientStatusType.UNSIGNED, + label: msg`Uncompleted`, + icon: CircleDashedIcon, + className: 'text-muted-foreground', + hasSigningLink: true, + }, +]; diff --git a/apps/remix/app/components/general/stack-avatars.tsx b/apps/remix/app/components/general/stack-avatars.tsx deleted file mode 100644 index cf2cd2ca6..000000000 --- a/apps/remix/app/components/general/stack-avatars.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { getExtraRecipientsType, getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import type { TRecipientLite } from '@documenso/lib/types/recipient'; -import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; - -import { StackAvatar } from './stack-avatar'; - -export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) { - const renderStackAvatars = (recipients: TRecipientLite[]) => { - const zIndex = 50; - const itemsToRender = recipients.slice(0, 5); - const remainingItems = recipients.length - itemsToRender.length; - - return itemsToRender.map((recipient, index: number) => { - const first = index === 0; - - if (index === 4 && remainingItems > 0) { - return ( - - ); - } - - return ( - - ); - }); - }; - - return <>{renderStackAvatars(recipients)}; -} diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index ede3dfb6c..0b2dbc896 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -25,6 +25,7 @@ import { z } from 'zod'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { useCurrentTeam } from '~/providers/team'; +import { useCspNonce } from '~/utils/nonce'; export type TemplateEditFormProps = { className?: string; @@ -38,6 +39,7 @@ const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields']; export const TemplateEditForm = ({ initialTemplate, className, templateRootPath }: TemplateEditFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const cspNonce = useCspNonce(); const navigate = useNavigate(); const team = useCurrentTeam(); @@ -339,6 +341,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath onSubmit={onAddTemplatePlaceholderFormSubmit} onAutoSave={onAddTemplatePlaceholderFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} + nonce={cspNonce} /> { + const { _ } = useLingui(); + + const [{ query }, setSearchParams] = useQueryStates( + { + query: templatesSearchParams.query, + page: templatesSearchParams.page, + }, + { history: 'push' }, + ); + + const [searchTerm, setSearchTerm] = useState(query ?? ''); + const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); + + useEffect(() => { + if (debouncedSearchTerm !== (query ?? '')) { + void setSearchParams({ + query: debouncedSearchTerm || null, + page: null, + }); + } + }, [debouncedSearchTerm, query, setSearchParams]); + + return ( + setSearchTerm(e.target.value)} + data-testid="templates-search-input" + /> + ); +}; 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/components/general/user-profile-skeleton.tsx b/apps/remix/app/components/general/user-profile-skeleton.tsx deleted file mode 100644 index 022bbdcea..000000000 --- a/apps/remix/app/components/general/user-profile-skeleton.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { VerifiedIcon } from '@documenso/ui/icons/verified'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Trans } from '@lingui/react/macro'; -import { File, User2 } from 'lucide-react'; - -export type UserProfileSkeletonProps = { - className?: string; - user: { - name: string; - url: string; - }; - rows?: number; -}; - -export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => { - const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'); - - return ( -
-
- {baseUrl.host}/u/{user.url} -
- -
-
-
- -
-
-
- -
-
-

{user.name}

- - -
- -
-
-
- -
-
-
- Documents -
- - {Array(rows) - .fill(0) - .map((_, index) => ( -
-
- - -
-
-
-
-
- -
- -
-
- ))} -
-
-
- ); -}; diff --git a/apps/remix/app/components/tables/documents-table-status-filter.tsx b/apps/remix/app/components/tables/documents-table-status-filter.tsx index 25abc1164..0d5f23ddc 100644 --- a/apps/remix/app/components/tables/documents-table-status-filter.tsx +++ b/apps/remix/app/components/tables/documents-table-status-filter.tsx @@ -1,4 +1,4 @@ -import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; @@ -14,13 +14,26 @@ import { FilterPill } from '~/components/general/filter-pill'; import { documentsSearchParams } from '~/utils/documents-search-params'; type DocumentsTableStatusFilterProps = { - stats: TFindDocumentsInternalResponse['stats']; + /** + * Per-status document counts, shown next to each option. When omitted no + * counts are rendered. + */ + stats?: TFindDocumentsInternalResponse['stats']; + + /** + * The statuses available for selection. Defaults to every status that + * makes sense for the documents page. + */ + statuses?: ExtendedDocumentStatus[]; }; -export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => { +export const DocumentsTableStatusFilter = ({ + stats, + statuses = SELECTABLE_STATUSES, +}: DocumentsTableStatusFilterProps) => { const { _ } = useLingui(); - const organisation = useCurrentOrganisation(); + const organisation = useOptionalCurrentOrganisation(); const [{ status }, setSearchParams] = useQueryStates( { @@ -32,14 +45,14 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter const selectableStatuses = useMemo( () => - SELECTABLE_STATUSES.filter((value) => { - if (organisation.type === OrganisationType.PERSONAL) { + statuses.filter((value) => { + if (organisation?.type === OrganisationType.PERSONAL) { return value !== ExtendedDocumentStatus.INBOX; } return true; }), - [organisation.type], + [organisation?.type, statuses], ); const selectedStatus = useMemo( @@ -65,20 +78,22 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter options={selectableStatuses.map((value) => ({ value, label: , - trailing: formatStatsCount(stats[value]), + trailing: stats ? formatStatsCount(stats[value]) : undefined, }))} testId="documents-table-status-filter" /> {/* Visually hidden document counts, for screen readers and tests. */} - - {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => ( - - {_(FRIENDLY_STATUS_MAP[value].label)}:{' '} - {stats[value]} - - ))} - + {stats && ( + + {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => ( + + {_(FRIENDLY_STATUS_MAP[value].label)}:{' '} + {stats[value]} + + ))} + + )} ); }; diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx index f958da66d..0aec5bf22 100644 --- a/apps/remix/app/components/tables/inbox-table.tsx +++ b/apps/remix/app/components/tables/inbox-table.tsx @@ -16,22 +16,17 @@ import { Trans } from '@lingui/react/macro'; import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } from '@prisma/client'; import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react'; import { DateTime } from 'luxon'; +import { useQueryStates } from 'nuqs'; import { useMemo, useTransition } from 'react'; -import { useSearchParams } from 'react-router'; import { match } from 'ts-pattern'; import { DocumentStatus } from '~/components/general/document/document-status'; import { useOptionalCurrentTeam } from '~/providers/team'; +import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params'; import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog'; import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip'; -export type DocumentsTableProps = { - data?: TFindInboxResponse; - isLoading?: boolean; - isLoadingError?: boolean; -}; - type DocumentsTableRow = TFindInboxResponse['data'][number]; export const InboxTable = () => { @@ -40,17 +35,24 @@ export const InboxTable = () => { const team = useOptionalCurrentTeam(); const [isPending, startTransition] = useTransition(); - const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); - const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined; - const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined; + const [findInboxSearchParams] = useQueryStates(inboxSearchParams, { + history: 'push', + }); + + const status = resolveInboxStatus(findInboxSearchParams.status); + const query = findInboxSearchParams.query ?? ''; const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({ - page: page || 1, - perPage: perPage || 10, + page: Math.max(findInboxSearchParams.page ?? 1, 1), + perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100), + query: query || undefined, + status, }); + const hasSearchQuery = query.trim().length > 0; + const columns = useMemo(() => { return [ { @@ -123,7 +125,20 @@ export const InboxTable = () => { emptyState={

- Documents that require your attention will appear here + {match({ hasSearchQuery, status }) + .with({ hasSearchQuery: true }, () => No documents match your search) + .with({ status: DocumentStatusEnum.COMPLETED }, () => ( + Documents that you have completed will appear here + )) + .with({ status: DocumentStatusEnum.REJECTED }, () => ( + Documents that have been rejected will appear here + )) + .with({ status: DocumentStatusEnum.CANCELLED }, () => ( + Documents that have been cancelled will appear here + )) + .otherwise(() => ( + Documents that require your attention will appear here + ))}

} diff --git a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx index 9d8a288f5..f2b512b97 100644 --- a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -170,7 +170,10 @@ export const TemplatesTableActionDropdown = ({ onOpenChange={setRenameDialogOpen} envelopeType="template" onSuccess={async () => { - await trpcUtils.template.findTemplates.invalidate(); + await Promise.all([ + trpcUtils.template.findTemplates.invalidate(), + trpcUtils.template.findTemplatesInternal.invalidate(), + ]); }} /> diff --git a/apps/remix/app/components/tables/templates-table-owner-filter.tsx b/apps/remix/app/components/tables/templates-table-owner-filter.tsx new file mode 100644 index 000000000..39fef4193 --- /dev/null +++ b/apps/remix/app/components/tables/templates-table-owner-filter.tsx @@ -0,0 +1,61 @@ +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { trpc } from '@documenso/trpc/react'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { UserIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { templatesSearchParams } from '~/utils/templates-search-params'; + +type TemplatesTableOwnerFilterProps = { + teamId: number; +}; + +export const TemplatesTableOwnerFilter = ({ teamId }: TemplatesTableOwnerFilterProps) => { + const { _ } = useLingui(); + + const isMounted = useIsMounted(); + + const [{ ownerIds }, setSearchParams] = useQueryStates( + { + ownerIds: templatesSearchParams.ownerIds, + page: templatesSearchParams.page, + }, + { history: 'push' }, + ); + + const selectedOwnerIds = (ownerIds ?? []).map((ownerId) => ownerId.toString()); + + const { data, isLoading } = trpc.team.member.getMany.useQuery({ + teamId, + }); + + const options = (data ?? []).map((member) => ({ + label: member.name ?? member.email, + value: member.userId.toString(), + })); + + const onChange = (newOwnerIds: string[]) => { + void setSearchParams({ + ownerIds: newOwnerIds.length > 0 ? newOwnerIds.map(Number) : null, + page: null, + }); + }; + + return ( + Owner} + value={selectedOwnerIds} + onChange={onChange} + options={options} + enableSearch + searchPlaceholder={_(msg`Search members...`)} + loading={!isMounted || isLoading} + testId="templates-table-owner-filter" + /> + ); +}; diff --git a/apps/remix/app/components/tables/templates-table-view-filter.tsx b/apps/remix/app/components/tables/templates-table-view-filter.tsx new file mode 100644 index 000000000..b30cf8e64 --- /dev/null +++ b/apps/remix/app/components/tables/templates-table-view-filter.tsx @@ -0,0 +1,42 @@ +import { Trans } from '@lingui/react/macro'; +import { Building2Icon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { TEMPLATES_VIEW_VALUES, templatesSearchParams } from '~/utils/templates-search-params'; + +const VIEW_OPTIONS = [ + { value: 'team', label: Team }, + { value: 'organisation', label: Organisation }, +]; + +export const TemplatesTableViewFilter = () => { + const [{ view }, setSearchParams] = useQueryStates( + { + view: templatesSearchParams.view, + ownerIds: templatesSearchParams.ownerIds, + page: templatesSearchParams.page, + }, + { history: 'push' }, + ); + + const onChange = (newView: string | null) => { + // The owner filter only applies to the team view, so drop it on any view change. + void setSearchParams({ + view: TEMPLATES_VIEW_VALUES.find((value) => value === newView) ?? null, + ownerIds: null, + page: null, + }); + }; + + return ( + View} + value={view} + onChange={onChange} + options={VIEW_OPTIONS} + testId="templates-table-view-filter" + /> + ); +}; 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/entry.server.tsx b/apps/remix/app/entry.server.tsx index 7f28001f4..895ccba93 100644 --- a/apps/remix/app/entry.server.tsx +++ b/apps/remix/app/entry.server.tsx @@ -7,10 +7,11 @@ import { createReadableStreamFromReadable } from '@react-router/node'; import { isbot } from 'isbot'; import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; -import type { AppLoadContext, EntryContext } from 'react-router'; +import type { EntryContext, RouterContextProvider } from 'react-router'; import { ServerRouter } from 'react-router'; import { langCookie } from './storage/lang-cookie.server'; +import { nonceContext } from './utils/nonce'; export const streamTimeout = 5_000; @@ -19,7 +20,7 @@ export default async function handleRequest( responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: RouterContextProvider, ) { let language = await langCookie.parse(request.headers.get('cookie') ?? ''); @@ -33,7 +34,7 @@ export default async function handleRequest( // scripts it injects (route manifest, hydration data, module preloads). // The same nonce is also exposed to the React tree via the root loader so // our own inline scripts/styles can carry it. - const nonce = loadContext.nonce || undefined; + const nonce = loadContext.get(nonceContext) || undefined; return new Promise((resolve, reject) => { let shellRendered = false; diff --git a/apps/remix/app/middleware/admin.ts b/apps/remix/app/middleware/admin.ts new file mode 100644 index 000000000..0eef2c90d --- /dev/null +++ b/apps/remix/app/middleware/admin.ts @@ -0,0 +1,13 @@ +import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { isAdmin } from '@documenso/lib/utils/is-admin'; +import { type MiddlewareFunction, redirect } from 'react-router'; + +export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => { + const { user } = await getOptionalSession(request); + + if (!user || !isAdmin(user)) { + throw redirect('/'); + } + + return next(); +}; diff --git a/apps/remix/app/middleware/nonce.ts b/apps/remix/app/middleware/nonce.ts new file mode 100644 index 000000000..916d5232d --- /dev/null +++ b/apps/remix/app/middleware/nonce.ts @@ -0,0 +1,8 @@ +import type { MiddlewareFunction } from 'react-router'; + +import { getRequestNonce } from '../../server/load-context'; +import { nonceContext } from '../utils/nonce'; + +export const nonceMiddleware: MiddlewareFunction = ({ context }) => { + context.set(nonceContext, getRequestNonce()); +}; diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 75b37495c..e317068df 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -1,4 +1,5 @@ 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'; @@ -9,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, @@ -21,13 +23,16 @@ import { useMatches, } from 'react-router'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; +import { nonceMiddleware } from '~/middleware/nonce'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; import { GenericErrorLayout } from './components/general/generic-error-layout'; import { langCookie } from './storage/lang-cookie.server'; import { themeSessionResolver } from './storage/theme-session.server'; import { appMetaTags } from './utils/meta'; -import { nonce } from './utils/nonce'; +import { nonce, nonceContext } from './utils/nonce'; + +export const middleware = [nonceMiddleware]; export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }]; @@ -72,7 +77,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { // Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all // SSR-rendered