Compare commits

..
Author SHA1 Message Date
ephraimduncan 4cf49d7db5 chore(prisma): remove unused logging client 2026-09-06 18:43:39 +00:00
Martin Glaser 2cac63a000 fix: block SSRF via IPv4-mapped IPv6 webhook URLs (#2901) (#3166) 2026-09-04 15:42:39 +10:00
Catalin Pit 4aa3583e89 fix: temporary fix for British to US spelling (#3329) 2026-09-04 15:41:11 +10:00
Lucas Smith 30a6b19b47 chore: upgrade to node 24 lts and clean up docker image (#3332)
Upgrade to Node 24 LTS, using the alpine 3.23 tag to handle issues with
streaming zip files on 24.16 which hangs npm ci.

Pin npm to 11.19.1 for min-release-age-exclude support.

Slim the runner image by dropping dev deps, the react-email CLI, and
esbuild,
none of which run in production.

Install turbo from the lockfile version instead of a hardcoded one.
2026-09-04 12:29:02 +10:00
Nathan Delhaye dabb7b7a0d fix: increase signature placeholder size when a timestamp authority is configured (#3328) 2026-09-04 12:26:01 +10:00
Catalin Pit cbb1cf7bef fix: default unset signing transport to local (#3309)
`/api/health` and `/api/certificate-status` reported the cert as
available when `NEXT_PRIVATE_SIGNING_TRANSPORT` was unset, even though
sealing defaults to the local P12 and fails if it is missing,
unreadable, or expired.
2026-09-04 08:17:42 +10:00
Lucas Smith 3ec877a68b fix: add patches back to dockerfile (#3324) 2026-09-01 16:12:08 +10:00
Lucas Smith bf406aa943 chore: upgrade libpdf (#3323) 2026-09-01 14:11:58 +10:00
Catalin Pit a2745ed3c2 docs: clarify team API token and certificate behavior (#3317) 2026-09-01 13:54:57 +10:00
Catalin Pit 937e9376d8 fix: enlarge envelope field resize handle hit area (#3315) 2026-09-01 13:54:20 +10:00
David Nguyen eba71d9be4 fix: prevent dialogs from closing on blur (#3314) 2026-09-01 13:17:48 +10:00
Konrad 5082b475a0 chore: update Polish translation (#3316) 2026-08-28 17:08:53 +10:00
Lucas Smith 4285c88f12 chore: add provider request template (#3311) 2026-08-27 11:25:37 +10:00
Lucas Smith ae4b56dd88 feat: optimise posthog usage and add error tracking (#3301)
Disable autocapture and person profiles for anonymous users, and
redact signing tokens from captured URLs.

Manually wire exception capture into the signing, editor and embed
flows with recipient/envelope context for debugging recipient-reported
issues.

Track activation events (webhooks, api tokens, direct links, embed
sessions) server-side with org attribution, and drop client events
already tracked in-app.
2026-08-27 11:11:51 +10:00
Arunendra Tripathi ee8360585b fix: render adjacent email template variables correctly (#3153) 2026-08-26 11:57:35 +10:00
David Nguyen 9dc83bdb06 fix: allow zooming and dragging uploaded signatures (#3173) 2026-08-26 11:21:40 +10:00
github-actions[bot] e532843aa6 chore: extract translations (#3171) 2026-08-25 21:37:33 +10:00
Ephraim Duncan dd08da7ea7 refactor(trpc): use ts-pattern for envelope document-data resolution (#3222) 2026-08-25 18:29:24 +10:00
Catalin Pit 10f2b80025 feat: last used column for api tokens (#3230) 2026-08-25 18:07:31 +10:00
David Nguyen 73d58bdf31 feat: allow admin recipient search (#3277) 2026-08-25 11:20:11 +10:00
water 1de57a9e43 fix(emails): remove trailing period from document pending email subject (#3213) 2026-08-24 17:05:43 +10:00
Rodrigo Flávio 7f11c163fe fix(i18n): add plural forms for command menu results and item counts (#3207) (#3262) 2026-08-24 16:59:37 +10:00
Rodrigo Flávio 5beb57dbf0 fix(i18n): add Plural support for bulk download limit alert (#3209) (#3260) 2026-08-24 16:53:01 +10:00
misinierijon4-debug ead3104896 fix: add type button to multiselect remove and clear buttons (#3256) 2026-08-24 16:22:48 +10:00
Lucas Smith 75330166cc v2.17.0 2026-08-19 20:34:18 +10:00
Ephraim Duncan d42254ff52 docs(api): document cancel endpoint and fix get-many body shape (#3135)
## Description

Documents API page: adds the missing Cancel Document section and fixes a
fabricated request body on get-many that would fail schema validation
for anyone copying the docs.

## Changes Made

- Added a `## Cancel Document` section: `POST /envelope/cancel` with `{
envelopeId, reason? }`, PENDING-only (400 otherwise), not idempotent,
two-stage access (404 if not visible, 401 without owner/MANAGER+), fires
`DOCUMENT_CANCELLED` webhook, emails only SENT/OPENED non-CC
non-rejected recipients.
- Replaced the fabricated `envelopeIds: [...]` get-many body with the
real nested selector: `{ "ids": { "type": "envelopeId" | "documentId" |
"templateId", "ids": [...] } }` (string[] for envelopeId, number[]
otherwise, 1–20 IDs).
- Added the missing `### Response` for get-many (`{ "data": [...] }`)
and documented silent filtering of inaccessible IDs (no 404).
- Added `CANCELLED` to the status table, mermaid state diagram,
transitions prose, and filter values.
- Removed the nonexistent `source: "API"` value (real enum: `DOCUMENT |
TEMPLATE | TEMPLATE_DIRECT_LINK`).
- Fixed fabricated `pagination` wrappers to the real flat shape `{ data,
count, currentPage, perPage, totalPages }`; fixed Field `id` type and
mismatched code fences.
- Migration guide: warned that get-many's body shape changed from
`documentIds: number[]` — the breaking part of that migration.

## Testing Performed

Docs-only change (plus the migration guide). Verified against the
envelope-router types, `cancel-document.ts`, the cancel e2e spec, and
`schema.prisma`.
2026-08-19 09:28:55 +00:00
Ephraim Duncan 914e325486 docs(trpc): add openapi descriptions to envelope cancel, delete and update routes (#3134)
## Description

The envelope cancel, delete, and update routes rendered without
descriptions in the generated OpenAPI reference.

## Changes Made

- Added route-level OpenAPI `description` to `cancel-envelope.types.ts`,
`delete-envelope.types.ts`, and `update-envelope.types.ts`.
- Added field-level `.describe()` calls on request schemas, matching the
style of sibling envelope-router schemas (e.g.
`get-envelopes-by-ids.types.ts`, `distribute-envelope.types.ts`).

## Testing Performed

`npx tsc --noEmit -p packages/trpc` passes with no errors. Metadata-only
change — no runtime behavior affected.
2026-08-19 09:28:40 +00:00
Ephraim Duncan 05f646b326 docs(api): document rate limit headers and 429 variants (#3133)
## Description

The rate limits page claimed "No rate limit headers are currently
provided" and advised a fixed 60-second wait. The middleware has been
setting standard headers on every API response.

## Changes Made

- Documented `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
`X-RateLimit-Reset` (Unix epoch seconds) on every `/api/v1`, `/api/v2`,
and `/api/v2-beta` response, and `Retry-After` (seconds, min 1) on 429s.
- Explained that windows are fixed epoch-aligned 1-minute buckets, so
the real wait is 1–60s — clients should honor `Retry-After` instead of
sleeping a fixed 60s.
- Showed both 429 body shapes: the global per-IP limiter's `{ "error":
... }` vs AppError-based `code`/`message`/`statusCode`.
- Covered the three distinct 429 sources: global per-IP limit,
organisation windowed limits, and monthly envelope quota (which sends no
rate-limit headers).
- Added `/api/v2-beta/*` to the documented scope; left the
verified-correct 1000/min figure and plan-limits table untouched.

## Testing Performed

Docs-only change. Verified against `rate-limit-middleware.ts`,
`rate-limit.ts`, `check-organisation-rate-limits.ts`,
`check-monthly-quota.ts`, and the remix server router.
2026-08-19 09:28:23 +00:00
Ephraim Duncan 0099dd672a feat(ui): redesign recipient field hover card (#3070)
Redesigns the popover shown when hovering a recipient field avatar in
the envelope view.

- Field-first hierarchy: header shows field-type icon + "{Type} field"
with inline status (Signed/Pending/Read Only) as a colored dot + label
- Recipient (name/email) moved to a recessed footer well as secondary
context
- Hide-field action moved from floating over the text to a ghost icon
button in the footer well
- Added a `FieldType` → icon map mirroring `field-selector.tsx`

## Screenshots

| Before | After |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/ephraimduncan/documenso/assets-pr-3070-hover-card/.github/assets/hover-before.png"
width="320" alt="Previous hover tooltip: centered badge, title and
recipient text" /> | <img
src="https://raw.githubusercontent.com/ephraimduncan/documenso/assets-pr-3070-hover-card/.github/assets/hover-after.png"
width="320" alt="New hover card: field-first header with status,
recipient footer well" /> |
2026-08-19 09:23:48 +00:00
Lucas Smith 871c2a6f0e fix: surface actionable errors when completing documents (#3229) 2026-08-18 21:08:05 +10:00
David Nguyen 9bab1cddb3 chore: add github action timeouts (#3228) 2026-08-18 12:55:22 +10:00
Lucas Smith 3e0c1c444a chore: deps 2026-08-17 (#3225) 2026-08-18 12:54:54 +10:00
David Nguyen 6a8fe6f1ad chore: remove planning skills (#3177) 2026-08-18 12:23:28 +10:00
Lucas Smith 779de01fe8 feat: migrate to react 19 (#3107) 2026-08-17 16:58:11 +10:00
Lucas Smith 283c6d274b fix: use documenso fork of skia-canvas for rendering (#3214)
Use our fork of `skia-canvas` for rendering which handles
encoding characters correctly with the caveat font and other
similar fonts that can group glyphs like ligatures.

This resolves issues with pdf text extraction where characters
were unable to be extracted due to lacking any data within the cmaps.
2026-08-17 15:23:19 +10:00
177 changed files with 9499 additions and 7453 deletions
@@ -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.
-56
View File
@@ -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.
-56
View File
@@ -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.
@@ -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.
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Setup node'
inputs:
node_version:
required: false
default: v22.x
default: v24.x
runs:
using: 'composite'
+2
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
actions: read
contents: read
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout code
+1
View File
@@ -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
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
label_issues:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
issues: write
steps:
+1
View File
@@ -13,6 +13,7 @@ jobs:
contents: read
pull-requests: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/labeler@v4
with:
+2
View File
@@ -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
@@ -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
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
stale:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
issues: write
pull-requests: write
@@ -18,6 +18,7 @@ jobs:
pull_translations:
name: Force pull translations
runs-on: ubuntu-latest
timeout-minutes: 10
environment: Translations
permissions:
contents: write
+1
View File
@@ -16,6 +16,7 @@ jobs:
pull_translations:
name: Pull translations
runs-on: ubuntu-latest
timeout-minutes: 10
environment: Translations
permissions:
contents: write
@@ -14,6 +14,7 @@ jobs:
extract_translations:
name: Extract and upload translations
runs-on: ubuntu-latest
timeout-minutes: 30
environment: Translations
permissions:
contents: write
+1 -1
View File
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
# min-release-age = 7
min-release-age = 7
+31 -15
View File
@@ -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)
<table>
<tr>
<td align="center" width="200">
<a href="https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40" />
</a>
</td>
<td align="center" width="200">
<a href="https://render.com/deploy?repo=https://github.com/documenso/documenso">
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40" />
</a>
</td>
<td align="center" width="200">
<a href="https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile">
<img src="https://www.koyeb.com/static/images/deploy/button.svg" alt="Deploy to Koyeb" height="40" />
</a>
</td>
</tr>
<tr>
<td align="center" width="200">
<a href="https://elest.io/open-source/documenso">
<img src="https://elest.io/images/logos/deploy-to-elestio-btn.png" alt="Deploy on Elestio" height="40" />
</a>
</td>
<td align="center" width="200"></td>
<td align="center" width="200"></td>
</tr>
</table>
## Security
@@ -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
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123",
"reason": "The agreement is no longer needed."
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', {
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abc123',
reason: 'The agreement is no longer needed.',
}),
});
const { success } = await response.json();
```
</Tab>
</Tabs>
### Response
```json
{
"success": true
}
```
### Behavior
- Only documents in `PENDING` status can be cancelled. Other statuses return `400`.
- Cancellation is not idempotent. Cancelling the same document again returns `400`.
- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`.
- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook.
- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document.
---
## Delete Document
Delete a document. Completed documents cannot be deleted.
@@ -670,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
const { success } = await response.json();
````
```
</Tab>
</Tabs>
@@ -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"]
}
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
method: 'POST',
headers: {
@@ -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();
````
```
</Tab>
</Tabs>
### Response
```json
{
"data": [
{
"id": "envelope_abc123",
"type": "DOCUMENT",
"status": "PENDING",
"title": "Service Agreement"
}
]
}
```
The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions.
---
## Document Statuses
@@ -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
<Callout type="warn">
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++;
}
@@ -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` |
@@ -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.
</Callout>
### 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.
}
```
<Callout type="warn">
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying.
</Callout>
### 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.
---
@@ -95,7 +95,7 @@ Documents created with a team token belong to that team:
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "DOCUMENT",
@@ -157,11 +157,11 @@ Retrieve all documents belonging to the team:
```bash
# List all team documents
curl -X GET "https://app.documenso.com/api/v2/envelope" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
-H "Authorization: api_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -191,7 +191,7 @@ Templates created with a team token are shared across the team.
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/template/create" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"title": "NDA Template",
@@ -269,7 +269,7 @@ console.log('Created team template:', template.id);
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -1000,9 +1000,12 @@ async function fetchWithRetry(
// Retry on rate limit
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
const delay = retryAfter
? parseInt(retryAfter) * 1000
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
console.log(`Rate limited, waiting ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
@@ -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).
<Callout type="warn">
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.
</Callout>
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.
<Callout type="warn">
@@ -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:
<Callout type="info">
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.
</Callout>
| Endpoint | Purpose |
| ------------------------- | -------------------------------------------------------------- |
| `/api/health` | Checks database connectivity and certificate status |
@@ -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)
@@ -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 |
---
@@ -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.
+4 -5
View File
@@ -10,13 +10,12 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"@radix-ui/react-tabs": "^1.1.13",
"fumadocs-core": "16.5.0",
"fumadocs-mdx": "14.2.6",
"fumadocs-ui": "16.5.0",
"fumadocs-core": "16.14.3",
"fumadocs-mdx": "15.2.3",
"fumadocs-ui": "16.14.3",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.2.6",
"next": "16.3.0",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
+2 -2
View File
@@ -12,11 +12,11 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.2.6"
"next": "16.3.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.27",
"@types/react": "^19.2.17",
"typescript": "5.6.2"
}
}
-25
View File
@@ -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"]
-26
View File
@@ -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"]
@@ -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({
@@ -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({
@@ -290,10 +290,11 @@ export const EnvelopesBulkDownloadDialog = ({
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
<Plural
value={MAX_BULK_DOWNLOAD_ENVELOPES}
one="You can download up to # document at a time. Deselect some documents to continue."
other="You can download up to # documents at a time. Deselect some documents to continue."
/>
</AlertDescription>
</Alert>
)}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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 {
@@ -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,21 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
<span className="ml-auto text-muted-foreground text-xs">
{hasValidSearch ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
isVisibleCountCapped ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural
value={totalVisibleCount}
one="# result"
other="# results"
/>
)
) : (
<Trans>{totalVisibleCount} items</Trans>
<Plural
value={totalVisibleCount}
one="# item"
other="# items"
/>
)}
</span>
</div>
@@ -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),
@@ -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.`),
@@ -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',
});
}
};
@@ -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.`),
@@ -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.`),
@@ -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.`),
@@ -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',
});
@@ -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.`),
@@ -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.`),
@@ -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.`),
@@ -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<number | null>(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 {
@@ -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 && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
<RadioGroup
value={selectedOption}
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3 shrink-0"
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.checked}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
@@ -167,14 +186,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
)}
{field.inserted && (
<RadioGroup className="my-0.5 gap-y-1">
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3"
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.value === field.customText}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
@@ -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.`,
@@ -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.`),
@@ -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.`),
@@ -1,5 +1,4 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -38,7 +37,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
const team = useCurrentTeam();
const navigate = useNavigate();
const analytics = useAnalytics();
const organisation = useCurrentOrganisation();
const userTimezone =
@@ -103,12 +101,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
// Handle legacy template creation.
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@@ -37,8 +38,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
/** How far past a resize handle you can still grab it, in screen pixels. */
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -276,6 +281,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
@@ -350,6 +362,9 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
shouldOverdrawWholeArea: true,
ignoreStroke: true,
flipEnabled: false,
anchorStyleFunc: (anchor) => {
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / scale);
},
boundBoxFunc: (oldBox, newBox) => {
// Enforce minimum size
if (newBox.width < 30 || newBox.height < 20) {
@@ -1,4 +1,5 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
@@ -45,6 +46,7 @@ export const EnvelopeEditorUploadPage = () => {
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const analytics = useAnalytics();
const {
envelope,
@@ -213,6 +215,12 @@ export const EnvelopeEditorUploadPage = () => {
const { data } = await createPromise.catch((error) => {
console.error(error);
analytics.captureException(error, {
source: isEmbedded ? 'embed' : 'editor',
location: 'create_envelope_items',
envelopeId: envelope.id,
});
// Set error state on files in batch upload.
setLocalFiles((prev) =>
prev.map((uploadingFile) =>
@@ -290,6 +298,12 @@ export const EnvelopeEditorUploadPage = () => {
} catch (error) {
console.error(error);
analytics.captureException(error, {
source: isEmbedded ? 'embed' : 'editor',
location: 'replace_pdf',
envelopeId: envelope.id,
});
toast({
title: t`Replace failed`,
description: t`Something went wrong while replacing the PDF`,
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
@@ -18,6 +19,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const {
envelopeStatus,
@@ -114,6 +116,13 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: currentEnvelopeItem?.envelopeId,
});
setRenderError(true);
}
};
@@ -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.`,
@@ -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;
}
};
@@ -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({
@@ -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({
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
@@ -68,6 +69,7 @@ export default function PDFViewer({
}: PDFViewerProps) {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const $el = useRef<HTMLDivElement>(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<HTMLDivElement>;
constraintRef: React.RefObject<HTMLDivElement | null>;
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<ImageLoadingState>('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 {
@@ -39,13 +39,14 @@ const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
/**
* 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<Record<TAdminSearchResultType, (_query: string) => string>> = {
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
};
export type UseAdminSearchCategoriesOptions = {
+65
View File
@@ -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() {
<HydratedRouter />
</I18nProvider>
</StrictMode>,
{ onRecoverableError },
);
});
+10
View File
@@ -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,
@@ -200,11 +202,19 @@ export default function App() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error);
}
useEffect(() => {
if (errorCode !== 404) {
analytics.captureException(error, { source: 'app', location: 'root_boundary' });
}
}, [error]);
return <GenericErrorLayout errorCode={errorCode} />;
}
@@ -12,7 +12,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -30,6 +30,12 @@ export default function AdminDocumentsPage() {
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const urlTerm = searchParams?.get?.('term') ?? '';
useEffect(() => {
setTerm(urlTerm);
}, [urlTerm]);
const { data: findDocumentsData, isPending: isFindDocumentsLoading } = trpc.admin.document.find.useQuery(
{
query: debouncedTerm,
@@ -131,7 +137,7 @@ export default function AdminDocumentsPage() {
<div>
<Input
type="search"
placeholder={_(msg`Search by document title, team:123 or user:123`)}
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
@@ -1,4 +1,5 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -7,6 +8,7 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { ShouldRevalidateFunctionArgs } from 'react-router';
import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router';
@@ -66,8 +68,18 @@ export default function DocumentsLayout() {
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'editor',
location: 'editor_layout_boundary',
envelopeId: params.id,
});
}, [error]);
const errorCodeMap = {
404: {
subHeading: msg`404 Document not found`,
@@ -68,6 +68,20 @@ export default function ApiTokensPage() {
return i18n.date(row.original.expires);
},
},
{
header: t`Last Used`,
cell: ({ row }) => {
if (!row.original.lastUsedAt) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
return <span className="text-foreground">{i18n.date(row.original.lastUsedAt)}</span>;
},
},
{
header: t`Actions`,
cell: ({ row }) => (
@@ -146,6 +160,9 @@ export default function ApiTokensPage() {
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
@@ -1,4 +1,5 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -8,6 +9,7 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useEffect } from 'react';
import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -84,8 +86,18 @@ export default function TemplatesLayout() {
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'editor',
location: 'editor_layout_boundary',
envelopeId: params.id,
});
}, [error]);
const errorCodeMap = {
404: {
subHeading: msg`404 Template not found`,
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -5,6 +6,7 @@ import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { useEffect } from 'react';
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -48,8 +50,14 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, { source: 'signing', location: 'recipient_layout_boundary' });
}, [error]);
return (
<GenericErrorLayout
errorCode={errorCode}
@@ -1,8 +1,8 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
export const loader = () => {
export const loader = async () => {
try {
const certStatus = getCertificateStatus();
const certStatus = await getCertificateStatus();
return Response.json({
isAvailable: certStatus.isAvailable,
+1 -1
View File
@@ -22,7 +22,7 @@ export const loader = async () => {
}
try {
const certStatus = getCertificateStatus();
const certStatus = await getCertificateStatus();
if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' };
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
@@ -5,6 +6,7 @@ import {
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { Trans } from '@lingui/react/macro';
import { useEffect } from 'react';
import { isRouteErrorResponse, Outlet, useRouteError } from 'react-router';
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
@@ -47,10 +49,30 @@ export default function Layout() {
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
const analytics = useAnalytics();
const error = useRouteError();
console.log({ routeError: error });
useEffect(() => {
const isExpectedEmbedResponse =
isRouteErrorResponse(error) &&
[
'embed-authentication-required',
'embed-paywall',
'embed-waiting-for-turn',
'embed-recipient-expired',
'embed-document-rejected',
'embed-document-completed',
].includes(error.data?.type);
if (isExpectedEmbedResponse) {
return;
}
analytics.captureException(error, { source: 'embed', location: 'embed_layout_boundary' });
}, [error]);
if (isRouteErrorResponse(error)) {
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
return (
@@ -2,11 +2,13 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { data } from 'react-router';
@@ -93,6 +95,30 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: template.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: template.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: template.envelopeId,
},
});
});
return {
token,
user,
@@ -194,6 +220,30 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
);
}
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: envelope.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: envelope.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: envelope.id,
},
});
});
return {
token,
user,
@@ -2,6 +2,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
@@ -13,6 +14,7 @@ import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
@@ -139,6 +141,30 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
})
: [];
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: document.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: document.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: document.envelopeId,
},
});
});
return {
token,
user,
@@ -272,6 +298,30 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: envelope.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: envelope.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: envelope.id,
},
});
});
return {
token,
user,
@@ -1,8 +1,11 @@
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { prisma } from '@documenso/prisma';
import { TrpcProvider } from '@documenso/trpc/react';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
@@ -35,6 +38,40 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
});
allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
// Derive the kind of authoring session from the child route path, e.g.
// /embed/v1/authoring/document/create -> resource 'document', mode 'create'.
// Completed and error pages are not new sessions and are skipped.
const [resource, mode] = url.pathname.split('/').filter(Boolean).slice(3);
const isAuthoringSession =
(resource === 'document' || resource === 'template') && (mode === 'create' || mode === 'edit');
if (isAuthoringSession) {
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: result.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: team?.organisationId,
teamId: result.teamId,
properties: {
type: 'authoring',
version: 'v1',
resource,
mode,
},
});
});
}
}
return {
@@ -1,8 +1,11 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { prisma } from '@documenso/prisma';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { useEffect, useLayoutEffect, useState } from 'react';
@@ -42,6 +45,15 @@ export async function loader({ request }: Route.LoaderArgs) {
const firstDocument = envelopes[0]?.document;
if (!firstDocument) {
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
properties: {
type: 'signing',
version: 'v1',
},
});
return superLoaderJson({
envelopes,
user,
@@ -55,6 +67,29 @@ export async function loader({ request }: Route.LoaderArgs) {
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: firstDocument.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: firstDocument.teamId,
properties: {
type: 'signing',
version: 'v1',
envelopeId: firstDocument.envelopeId,
},
});
});
return superLoaderJson({
envelopes,
user,
@@ -1,5 +1,6 @@
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
@@ -12,7 +13,7 @@ import type { OrganisationSession } from '@documenso/trpc/server/organisation-ro
import { Spinner } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
import { useLayoutEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import { isRouteErrorResponse, Outlet, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
@@ -166,8 +167,17 @@ export default function AuthoringLayout() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'embed',
location: 'embed_authoring_boundary',
});
}, [error]);
return (
<div>
{match(errorCode)
@@ -1,5 +1,6 @@
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
@@ -10,6 +11,7 @@ import {
ZEmbedCreateEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
@@ -76,6 +78,30 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
},
});
fireAndForget(async () => {
const team = result.teamId
? await prisma.team.findFirst({
where: {
id: result.teamId,
},
select: {
organisationId: true,
},
})
: null;
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: team?.organisationId,
teamId: result.teamId ?? undefined,
properties: {
type: 'authoring',
version: 'v2',
},
});
});
return superLoaderJson({
token,
tokenUserId: result.userId,
@@ -1,5 +1,6 @@
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
@@ -94,6 +95,18 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
brandingLogo = settings.brandingLogo;
}
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: envelope.team.organisationId,
teamId: result.teamId ?? undefined,
properties: {
type: 'authoring',
version: 'v2',
envelopeId: envelope.id,
},
});
return superLoaderJson({
token,
envelope,
@@ -42,6 +42,51 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
}));
};
/**
* Toast messages for errors thrown while a recipient attempts to complete
* (sign) a document, so the user knows whether retrying can help and what to
* do next.
*/
export const getSigningCompletionErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with(AppErrorCode.NOT_FOUND, () => ({
title: msg`Document no longer available`,
description: msg`This document can no longer be signed. It may have been removed by the sender, or your signing access may have been revoked. Please contact the sender for a new signing link.`,
}))
.with(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, () => ({
title: msg`Some fields were not saved`,
description: msg`One or more of your required fields have not been saved. Please refresh the page, complete any empty required fields, and try again.`,
}))
.with(AppErrorCode.RECIPIENT_OUT_OF_TURN, () => ({
title: msg`It's not your turn to sign yet`,
description: msg`This document is signed in a set order and other recipients must sign before you. You will receive an email when it is your turn.`,
}))
.with(AppErrorCode.RECIPIENT_EXPIRED, () => ({
title: msg`Signing link expired`,
description: msg`Your signing link has expired. Please contact the sender to request a new one.`,
}))
.with(AppErrorCode.ENVELOPE_COMPLETED, () => ({
title: msg`Document already completed`,
description: msg`This document has already been completed and no further signatures can be added.`,
}))
.with(AppErrorCode.ENVELOPE_REJECTED, () => ({
title: msg`Document rejected`,
description: msg`This document has been rejected by a recipient and can no longer be signed.`,
}))
.with(AppErrorCode.ENVELOPE_CANCELLED, () => ({
title: msg`Document cancelled`,
description: msg`This document has been cancelled by the sender and can no longer be signed. Please contact the sender if you believe this is a mistake.`,
}))
.with(AppErrorCode.ENVELOPE_DRAFT, () => ({
title: msg`Document not ready`,
description: msg`This document has not been sent for signing yet. Please wait for the sender to send it before signing.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
}));
};
export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
+6 -6
View File
@@ -57,9 +57,9 @@
"papaparse": "^5.5.3",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"react-call": "^1.8.1",
"react-dom": "^18",
"react-dom": "^19.2.7",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-hotkeys-hook": "^4.6.2",
@@ -93,11 +93,11 @@
"@types/luxon": "^3.7.1",
"@types/node": "^20",
"@types/papaparse": "^5.5.0",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/ua-parser-js": "^0.7.39",
"cross-env": "^10.1.0",
"esbuild": "^0.27.0",
"esbuild": "^0.28.1",
"remix-flat-routes": "^0.8.5",
"rollup": "^4.53.3",
"tsx": "^4.23.1",
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.16.0"
"version": "2.17.0"
}
+1 -1
View File
@@ -121,7 +121,7 @@ export default defineConfig({
'nodemailer',
/playwright/,
'@playwright/browser-chromium',
'skia-canvas',
'@documenso/skia-canvas',
],
},
},
+25 -12
View File
@@ -1,7 +1,7 @@
###########################
# BASE CONTAINER #
###########################
FROM node:22-alpine3.22 AS base
FROM node:24-alpine3.23 AS base
RUN apk add --no-cache openssl
RUN apk add --no-cache font-freefont
@@ -19,7 +19,10 @@ WORKDIR /app
COPY . .
RUN npm install -g "turbo@^2.10.0"
# Install the exact turbo version resolved in the lockfile, without installing
# the rest of the dependency tree (prune must run before any npm ci).
RUN TURBO_VERSION="$(jq -r '.packages["node_modules/turbo"].version' package-lock.json)" \
&& npm install -g "turbo@${TURBO_VERSION}"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@@ -39,9 +42,9 @@ RUN apk add --no-cache make cmake g++ openssl bash
WORKDIR /app
# Disable husky from installing hooks
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
ENV NEXT_TELEMETRY_DISABLED=1
# Encryption keys
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
@@ -85,17 +88,17 @@ COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
RUN npm install -g "turbo@^2.10.0"
ENV NODE_OPTIONS="--max-old-space-size=8192"
RUN turbo run build --filter=@documenso/remix...
RUN npx turbo run build --filter=@documenso/remix...
###########################
# RUNNER CONTAINER #
###########################
FROM base AS runner
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
# Telemetry credentials (baked into image at build time, can be disabled at runtime)
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
@@ -118,7 +121,16 @@ COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --only=production
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
# Strip build-time residue that ships as production dependencies but is never
# executed at runtime.
RUN rm -rf \
node_modules/react-email/dist/cli \
node_modules/esbuild \
node_modules/@esbuild \
node_modules/.bin/esbuild \
node_modules/.bin/email
# Automatically leverage output traces to reduce image size
# https://nodejs.org/docs/advanced-features/output-file-tracing
@@ -129,8 +141,9 @@ COPY --from=installer --chown=nodejs:nodejs /app/apps/remix/public ./apps/remix/
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/schema.prisma ./packages/prisma/schema.prisma
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./packages/prisma/migrations
# Generate the prisma client again
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
# Generate the prisma client again, this time only targeting the client generator
RUN npx prisma generate --schema ./packages/prisma/schema.prisma --generator client \
&& npm cache clean --force
# Get the start script from docker/
+3887 -4799
View File
File diff suppressed because it is too large Load Diff
+49 -12
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.16.0",
"version": "2.17.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -41,27 +41,30 @@
"translate:extract": "lingui extract --clean",
"translate:compile": "lingui compile"
},
"packageManager": "npm@11.11.0",
"packageManager": "npm@11.19.1",
"engines": {
"npm": ">=11.11.0",
"node": ">=22.0.0"
"npm": ">=11.17.0",
"node": ">=24.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@datadog/pprof": "^5.13.5",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/cli": "^5.6.0",
"@prisma/client": "^6.19.0",
"@trpc/client": "11.8.1",
"@trpc/react-query": "11.8.1",
"@trpc/server": "11.8.1",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.27.0",
"esbuild": "^0.28.1",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
@@ -86,27 +89,61 @@
"zod-prisma-types": "3.3.5"
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
"ai": "^5.0.104",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-alert-dialog": "^1.1.19",
"@radix-ui/react-aspect-ratio": "^1.1.11",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-context-menu": "^2.3.3",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-menubar": "^1.1.20",
"@radix-ui/react-navigation-menu": "^1.2.18",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-toast": "^1.2.19",
"@radix-ui/react-toggle": "^1.1.14",
"@radix-ui/react-toggle-group": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.12",
"ai": "^7.0.58",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"sharp": "0.35.3",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
},
"overrides": {
"lodash": "4.18.1",
"brace-expansion@1": "^1.1.18",
"pdfjs-dist": "5.4.296",
"postcss": "^8.5.19",
"react": "$react",
"react-dom": "$react-dom",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"typescript": "5.6.2",
"zod": "$zod",
"fumadocs-mdx": {
@@ -10,7 +10,7 @@ test.describe.configure({ mode: 'parallel' });
const nanoid = customAlphabet('1234567890abcdef', 10);
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organizations…';
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
@@ -437,3 +437,98 @@ test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without e
expect(adminSearchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: capped recipients group links to the admin documents page', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientPrefix = `viewall-recipient-${nanoid()}`;
// Seed 5 documents, each with one recipient email sharing the prefix, to
// hit the 5 result cap on the recipients group.
const documents = [];
for (let i = 0; i < 5; i++) {
documents.push(
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-viewall-${nanoid()}` },
}),
);
}
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
// Only the recipients group matches the prefix, so this is its link.
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
'href',
`/admin/documents?term=${encodeURIComponent(`recipient:${recipientPrefix}`)}`,
);
await viewAllOption.click();
await page.waitForURL((url) => url.pathname === '/admin/documents');
// The term input is prefilled with the recipient query and the matching
// documents are listed.
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
for (const document of documents) {
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
}
});
test('[ADMIN][GLOBAL_SEARCH]: view all results updates the documents page when already on it', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientPrefix = `viewall-live-${nanoid()}`;
// Seed 5 documents with recipients matching the prefix to hit the cap, and
// one control document whose recipient does not match: with a stale
// (unfiltered) query the control would show, with the filter it must not.
const documents = [];
for (let i = 0; i < 5; i++) {
documents.push(
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-live-${nanoid()}` },
}),
);
}
const controlDocument = await seedPendingDocument(sender, team.id, [`control-${nanoid()}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-live-control-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
// Start ON the admin documents page: the buggy state initializer has
// already run with an empty term.
await page.goto('/admin/documents');
await expect(page.getByPlaceholder(/Search by document title/)).toBeVisible();
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: 'View all results' }).first().click();
await page.waitForURL((url) => url.searchParams.get('term') === `recipient:${recipientPrefix}`);
// The same-route navigation must update both the input and the results.
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
await expect(page.getByRole('link', { name: documents[0].title })).toBeVisible();
await expect(page.getByRole('link', { name: controlDocument.title })).toHaveCount(0);
});
@@ -411,7 +411,7 @@ test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', asyn
});
// They should NOT see the organisation settings heading for this org.
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
await expect(page.getByText('Organization Settings')).not.toBeVisible();
});
// ─── Access control: UI ──────────────────────────────────────────────────────
@@ -50,9 +50,9 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
});
// Verify we're on the admin organisation page
await expect(page.getByText(`Manage organisation`)).toBeVisible();
await expect(page.getByText(`Manage organization`)).toBeVisible();
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
await expect(page.getByLabel('Organization Name')).toHaveValue(organisation.name);
// Check that the organisation members table shows the correct roles
const ownerRow = page.getByRole('row', { name: ownerUser.email });
@@ -356,7 +356,7 @@ test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
});
// Should show 404 error
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Organization not found' })).toBeVisible({
timeout: 10_000,
});
});
@@ -525,8 +525,8 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
// Should be able to access organisation settings
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeEnabled();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
@@ -0,0 +1,226 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../../../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
type AdminFindDocumentsResult = {
data: Array<{ envelopeId: string; title: string }>;
count: number;
};
const callAdminFindDocuments = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query, page: 1, perPage: 20 } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.document.find?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
result: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json as AdminFindDocumentsResult)
: null,
};
};
// ─── Access control ──────────────────────────────────────────────────────────
test('[ADMIN][TRPC][FIND_DOCUMENTS]: non-admin user is rejected with 401', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
await apiSignin({ page, email: nonAdminUser.email });
const { res } = await callAdminFindDocuments(page, 'recipient:anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
// ─── recipient: prefix ───────────────────────────────────────────────────────
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient email', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientEmail = `recipient-find-${nanoid()}@test.documenso.com`;
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientEmail], {
createDocumentOptions: { title: `recipient-find-match-${nanoid()}` },
});
const otherDocument = await seedPendingDocument(sender, team.id, [`other-${nanoid()}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-find-other-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientEmail}`);
expect(res.ok()).toBeTruthy();
expect(result?.count).toBe(1);
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
expect(result?.data.map((document) => document.envelopeId)).not.toContain(otherDocument.id);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient name case-insensitively', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientName = `recipient-name-${nanoid()}`;
const { user: recipientUser } = await seedUser({ name: recipientName });
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientUser], {
createDocumentOptions: { title: `recipient-name-match-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
// Query the uppercased name: matching must be case-insensitive.
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientName.toUpperCase()}`);
expect(res.ok()).toBeTruthy();
expect(result?.count).toBe(1);
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with empty value returns no results', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const emptyValueSearch = await callAdminFindDocuments(page, 'recipient:');
expect(emptyValueSearch.res.ok()).toBeTruthy();
expect(emptyValueSearch.result?.data).toEqual([]);
expect(emptyValueSearch.result?.count).toBe(0);
// Whitespace-only values are treated the same as empty.
const whitespaceValueSearch = await callAdminFindDocuments(page, 'recipient: ');
expect(whitespaceValueSearch.res.ok()).toBeTruthy();
expect(whitespaceValueSearch.result?.data).toEqual([]);
expect(whitespaceValueSearch.result?.count).toBe(0);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with no matches returns no results', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const { res, result } = await callAdminFindDocuments(page, 'recipient:zzzz-no-such-recipient-9x7q');
expect(res.ok()).toBeTruthy();
expect(result?.data).toEqual([]);
expect(result?.count).toBe(0);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with numeric value matches by exact recipient ID', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const matchingDocument = await seedPendingDocument(sender, team.id, [`recipient-id-${nanoid()}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-id-match-${nanoid()}` },
});
const recipient = matchingDocument.recipients[0];
// A decoy whose recipient email contains the ID as text: an exact ID lookup
// must exclude it, while an accidental "ID or contains" match would not.
const decoyDocument = await seedPendingDocument(
sender,
team.id,
[`decoy-${recipient.id}-${nanoid()}@test.documenso.com`],
{
createDocumentOptions: { title: `recipient-id-decoy-${nanoid()}` },
},
);
await apiSignin({ page, email: adminUser.email });
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipient.id}`);
expect(res.ok()).toBeTruthy();
expect(result?.count).toBe(1);
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
expect(result?.data.map((document) => document.envelopeId)).not.toContain(decoyDocument.id);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with nonexistent recipient ID returns no results', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
// Int4 max - 1: a valid ID-shaped number that no autoincrement recipient
// sequence will plausibly reach, and that no other test seeds as text.
const { res, result } = await callAdminFindDocuments(page, 'recipient:2147483646');
expect(res.ok()).toBeTruthy();
expect(result?.data).toEqual([]);
expect(result?.count).toBe(0);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with oversized number falls back to text search', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
// treated as text (and must not 500).
const oversizedNumber = '99999999999999';
const matchingDocument = await seedPendingDocument(
sender,
team.id,
[`${oversizedNumber}-${nanoid()}@test.documenso.com`],
{
createDocumentOptions: { title: `recipient-oversized-${nanoid()}` },
},
);
await apiSignin({ page, email: adminUser.email });
const { res, result } = await callAdminFindDocuments(page, `recipient:${oversizedNumber}`);
expect(res.ok()).toBeTruthy();
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: user and team prefixes with oversized numbers return no results', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
// 99999999999999 exceeds Int4, so it can never be a valid user or team ID.
// The ID schema must reject it so the query returns empty instead of
// overflowing Postgres and erroring.
const userSearch = await callAdminFindDocuments(page, 'user:99999999999999');
expect(userSearch.res.ok()).toBeTruthy();
expect(userSearch.result?.data).toEqual([]);
expect(userSearch.result?.count).toBe(0);
const teamSearch = await callAdminFindDocuments(page, 'team:99999999999999');
expect(teamSearch.res.ok()).toBeTruthy();
expect(teamSearch.result?.data).toEqual([]);
expect(teamSearch.result?.count).toBe(0);
});
@@ -408,7 +408,7 @@ test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) =
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
await expectToastTextToBeVisible(page, 'Documents cancelled');
await expectToastTextToBeVisible(page, 'Documents canceled');
// Selection clears after a successful cancel.
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
@@ -455,7 +455,7 @@ test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// Only one of the three was pending, so this is a partial result.
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
await expectToastTextToBeVisible(page, 'Documents partially canceled');
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pending.id },
@@ -505,7 +505,7 @@ test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', as
// The server rejects the cancellation for a document the MEMBER does not own,
// so it reports zero cancelled (a partial result with the document in failedIds).
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
await expectToastTextToBeVisible(page, 'Documents partially canceled');
// The document remains pending.
const envelope = await prisma.envelope.findFirstOrThrow({
@@ -41,7 +41,7 @@ const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: s
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
if (reason) {
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
await page.getByPlaceholder('Add an optional reason for canceling this document').fill(reason);
}
await page.getByRole('button', { name: 'Cancel document' }).click();
@@ -58,13 +58,13 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
// The cancelled document is still listed.
await selectDocumentStatusFilter(page, 'Cancelled');
await selectDocumentStatusFilter(page, 'Canceled');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
@@ -95,7 +95,7 @@ test('[DOCUMENTS]: cancelling a pending document retains it for recipients', asy
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
await apiSignout({ page });
@@ -125,10 +125,10 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await selectDocumentStatusFilter(page, 'Cancelled');
await selectDocumentStatusFilter(page, 'Canceled');
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
@@ -328,7 +328,7 @@ test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
@@ -29,11 +29,11 @@ test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
await page.waitForURL(`/settings/organisations`);
await expectTextToBeVisible(page, 'No results found');
await page.getByRole('button', { name: 'Create organisation' }).click();
await page.getByRole('button', { name: 'Create organization' }).click();
await page.getByLabel('Organisation Name*').fill('test');
await page.getByLabel('Organization Name*').fill('test');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Your organisation has been created').first()).toBeVisible();
await expect(page.getByText('Your organization has been created').first()).toBeVisible();
await page.reload();
await page.getByRole('row').filter({ hasText: 'test' }).getByRole('link').nth(1).click();
@@ -53,12 +53,12 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
const updatedOrganisationId = `organisation-${Date.now()}`;
// Update team.
await page.getByLabel('Organisation Name*').click();
await page.getByLabel('Organisation Name*').clear();
await page.getByLabel('Organisation Name*').fill(updatedOrganisationId);
await page.getByLabel('Organisation URL*').click();
await page.getByLabel('Organisation URL*').clear();
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
await page.getByLabel('Organization Name*').click();
await page.getByLabel('Organization Name*').clear();
await page.getByLabel('Organization Name*').fill(updatedOrganisationId);
await page.getByLabel('Organization URL*').click();
await page.getByLabel('Organization URL*').clear();
await page.getByLabel('Organization URL*').fill(updatedOrganisationId);
await page.getByRole('button', { name: 'Save changes' }).click();
@@ -277,8 +277,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Create a custom group A with 3 members "ORGANISATION ADMIN" to check that they get the correct roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
await page.getByRole('option', { name: 'Organization Admin' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member1' }).click();
await page.getByRole('option', { name: 'Member2' }).click();
@@ -291,16 +291,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm org roles have been applied to these members.
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail3)).toBeVisible();
// Test updating the group.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('link', { name: 'Manage' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
await page.getByRole('option', { name: 'Organisation Member' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Admin' }).click();
await page.getByRole('option', { name: 'Organization Member' }).click();
// Remove Member3 by clicking the X on its chip in the multiselect.
await page
.getByTestId('group-members-picker')
@@ -327,16 +327,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm admins still get admin roles.
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail3)).toBeVisible();
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
await page.getByRole('option', { name: 'Organization Admin' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member4' }).click();
await page.getByRole('option', { name: 'Member5' }).click();
@@ -537,6 +537,6 @@ test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
await expect(page.getByText('You have successfully left this organization').first()).toBeVisible();
await expect(page.getByText('No results found').first()).toBeVisible();
});
@@ -7,8 +7,8 @@ import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
const BANNER_EXCEEDED_TEXT = 'Your organization has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organization is approaching a fair use limit';
type SeedQuotaStateOptions = {
organisationId: string;
@@ -162,7 +162,7 @@ test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page })
});
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
@@ -176,7 +176,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('option', { name: 'Inherit from organization' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
@@ -254,9 +254,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page
.getByTestId('inheritable-email-document-settings')
.getByRole('combobox')
.filter({ hasText: 'Inherit from organisation' })
.filter({ hasText: 'Inherit from organization' })
.click();
await page.getByRole('option', { name: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Override organization settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
@@ -308,8 +308,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('combobox').filter({ hasText: 'Override organization settings' }).click();
await page.getByRole('option', { name: 'Inherit from organization' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
@@ -248,7 +248,7 @@ test.describe('Unified Settings', () => {
// Wait for the organisation page to actually render — asserting straight after
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organization Settings');
await expect(trigger).toContainText(selected.name);
@@ -283,7 +283,7 @@ test.describe('Unified Settings', () => {
// Selecting the inherit option stages the field back to inherited.
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: /inherit from organisation/i }).click();
await page.getByRole('option', { name: /inherit from organization/i }).click();
await expect(langStatus).toHaveText(/inherited/i);
});
@@ -450,7 +450,7 @@ test.describe('Unified Settings', () => {
// An empty group would just look broken, so it explains itself directly under the switcher.
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText(/permission to manage this organisation/i);
await expect(emptyState).toContainText(/permission to manage this organization/i);
// Team and account pages remain navigable.
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
@@ -0,0 +1,80 @@
import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
test('[TEMPLATES]: bulk send via CSV from the table action dropdown', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedTemplate({
title: 'Bulk send template',
userId: owner.id,
teamId: team.id,
});
const uniqueRecipientEmail = `bulk-send-${Date.now()}@documenso.com`;
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates`,
});
const actionBtn = page
.getByRole('row', { name: 'Bulk send template' })
.getByRole('cell', { name: 'Use Template' })
.getByRole('button')
.nth(1);
await openDropdownMenu(page, actionBtn);
await page.getByText('Bulk Send via CSV').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Bulk Send Template via CSV' });
await expect(dialog).toBeVisible();
// Opening the native file picker blurs the window. Radix dropdown menus close themselves on
// window blur, which used to unmount this dialog when it was nested inside the menu content,
// silently discarding the user's file selection.
await page.evaluate(() => window.dispatchEvent(new Event('blur')));
await expect(dialog).toBeVisible();
const csv = ['recipient_1_email,recipient_1_name', `${uniqueRecipientEmail},Bulk Recipient`].join('\n');
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bulk-send.csv',
mimeType: 'text/csv',
buffer: Buffer.from(csv),
});
await expect(dialog.getByText('bulk-send.csv')).toBeVisible();
await dialog.getByRole('button', { name: 'Upload and Process' }).click();
await expect(page.getByText('Your bulk send has been initiated').first()).toBeVisible();
// The bulk send runs as a background job, so poll for the created document.
await expect
.poll(
async () =>
await prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
teamId: team.id,
recipients: {
some: {
email: uniqueRecipientEmail,
},
},
},
}),
{ timeout: 30_000 },
)
.toBe(1);
});
+1 -1
View File
@@ -19,7 +19,7 @@
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^18",
"react": "^19.2.7",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}

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