Compare commits

...
Author SHA1 Message Date
ephraimduncan a18a04d911 fix(editor): add copy and paste shortcuts for fields
The envelope editor had no handler for Ctrl+C and Ctrl+V. As a result,
the documented shortcuts did nothing in the document and template editors.
2026-09-24 11:09:08 +00:00
Lucas Smith 39ae85483a fix: add date-fns to packages/ui (#3388) 2026-09-24 10:29:26 +10:00
Ephraim Duncan 638e92d534 feat: add team document analytics dashboard (#3355) 2026-09-24 09:50:35 +10:00
Catalin Pit c81bc72c4c fix: bulk download dialog overflow on long titles (#3380) 2026-09-21 19:23:11 +10:00
Zonaib Bokhari 1164d9578e Respect hidden sender details in document invite emails (#3061) 2026-09-21 09:20:39 +03:00
David Nguyen e658cc5818 feat: add inbox filters (#3372) 2026-09-17 17:21:34 +10:00
Ephraim Duncan 0c6249744a feat(ui): redesign recipient avatar stack hover popover (#3072) 2026-09-17 13:36:15 +10:00
Ephraim Duncan cf326f825d feat: replace template view tabs with a filter pill (#3148) 2026-09-17 10:24:19 +10:00
Ephraim Duncan da934c01a6 feat: allow signing reason override (#2848) 2026-09-16 14:41:55 +10:00
Ephraim Duncan 0693a4195b refactor(openpage): simplify the cors policy (#3338) 2026-09-16 14:10:55 +10:00
Ephraim Duncan 2618d4d4be chore(embedding): remove unregistered multi-sign mutation (#3339) 2026-09-16 14:07:04 +10:00
Ephraim Duncan c5a6ff42ee chore(ui): remove unused application skeletons (#3340) 2026-09-16 14:06:03 +10:00
Ephraim Duncan 132c4b08c5 fix(trpc): v2 field position updates silently dropped; rewrite fields API docs (#3136) 2026-09-16 13:59:40 +10:00
Ephraim Duncan 9542512ce9 refactor(lib): reuse the http url validator (#3346) 2026-09-16 13:54:52 +10:00
Ephraim Duncan 0f71a8b67d chore(prisma): remove unregistered middleware (#3345) 2026-09-16 13:53:19 +10:00
Ephraim Duncan b795883fc7 chore(lib): remove unused timezone labels (#3342) 2026-09-16 13:48:03 +10:00
Ephraim Duncan 643954ddd1 refactor(server): simplify request context middleware (#3341) 2026-09-16 13:46:01 +10:00
Catalin Pit a20e8a2376 feat: add document naming options when using templates (#3086) 2026-09-16 01:00:40 +10:00
Lucas Smith e73450dd08 chore: upgrade deps (#3367) 2026-09-14 16:14:44 +10:00
Christopher Ryan 5603a9e59d fix: preserve base path in email asset URLs (#3327) 2026-09-11 20:44:05 +10:00
Durgesh Shekhawat 20ae849a1a fix: use nuqs prevent infinite re-render loop (#2868) 2026-09-11 20:29:24 +10:00
Lucas Smith 6a99b40cba fix: improve action auth flow for passwordless users (#3358)
Selecting password auth failed with a generic "Unauthorized" for users
who signed up via OAuth or passkey, with no way to set one.

Detect the missing password and email the existing reset link from the
signing dialog and security settings. Require a 2FA code and rate limit
update-password.
2026-09-11 14:17:34 +10:00
Catalin Pit e1ad4a2c55 docs: add externalId to send-for-signature workflow example (#3325) 2026-09-10 14:41:05 +03:00
Konrad 82918163c5 fix(i18n): add missing plural in bulk template upload error handling (#3357) 2026-09-10 21:01:17 +10:00
Catalin Pit b97c22a607 fix: pass csp nonce to @hello-pangea/dnd style elements (#3354) 2026-09-10 15:29:41 +10:00
Lucas Smith 389390c884 v2.18.0 2026-09-09 11:34:10 +10:00
Ephraim Duncan c5acba538e chore(auth): remove commented account creation code (#3344) 2026-09-09 11:04:15 +10:00
Lucas Smith f5ab8a8ae3 fix: resolve build errors (#3352) 2026-09-09 11:04:01 +10:00
Ephraim Duncan 3b20c2f960 chore(ui): remove unused text fitting component (#3337) 2026-09-09 10:51:35 +10:00
Lucas Smith 5e8a434141 fix: use react router middleware (#3351) 2026-09-09 10:51:26 +10:00
David Nguyen 6a8bb4be04 fix: improve invalid bulk template upload error handling (#3326) 2026-09-08 21:03:02 +10: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
Catalin Pit 688ef2fdf3 fix: docker healtcheck (#3176) 2026-08-14 13:02:48 +03:00
Konrad a5e37af3e8 chore: update Polish translations (#3210) 2026-08-14 10:40:42 +10:00
David Nguyen 617f8cc204 feat: show feature gated setting pages (#3167) 2026-08-12 17:33:08 +10:00
Christopher Ryan 1bd09480e6 feat(remix): support serving app under a sub-path via NEXT_PUBLIC_BASE_PATH (#2824) 2026-08-12 16:12:28 +10:00
396 changed files with 23336 additions and 11807 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.
+2
View File
@@ -80,6 +80,8 @@ NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Reason to embed in PDF signatures. Defaults to "Signed by Documenso".
NEXT_PRIVATE_SIGNING_REASON=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
@@ -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
@@ -15,6 +15,8 @@ This guide provides a comprehensive troubleshooting matrix for the standard erro
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
| `MISSING_ENV_VAR` | A required environment variable is not configured on the server (500). | Primarily affects self-hosted instances: set the environment variable named in the error message and restart. On Documenso Cloud, contact support. |
| `MISSING_SIGNATURE_FIELD` | A signer has no signature field placed on the document (400). Returned when distributing an envelope. | Add at least one signature field for every recipient with a signing role before calling `/envelope/distribute`. |
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
@@ -37,7 +39,28 @@ The following errors occur when attempting to perform actions on an envelope tha
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
| `ENVELOPE_CANCELLED` | The action cannot be performed because the envelope was cancelled (400). | Create a new envelope if you need to restart the signing process. |
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
| `ENVELOPE_TSP_LOCKED` | An AES/QES envelope cannot be modified after it leaves the draft state (400). | Make changes while the envelope is in `DRAFT`, or create a new envelope. |
## CSC Signing Errors
These errors apply to Cloud Signature Consortium (CSC) signing flows.
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `CSC_INSTANCE_MODE_MISMATCH` | The requested signature level does not match the instance's CSC mode (400). | Use the signature level supported by the instance's signing configuration. |
| `CSC_UNLICENSED` | CSC signing is not licensed for this instance (403). | Enable the CSC signing license before retrying. |
| `CSC_PROVIDER_INFO_FAILED` | The CSC provider's discovery request failed or returned unusable information (500). | Check the provider URL, availability, and OAuth configuration. |
| `CSC_PROVIDER_NO_TSA` | A timestamp authority is unavailable or unusable for CSC signing (500). | Configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` and verify provider timestamp access. |
| `CSC_CREDENTIAL_LIST_EMPTY` | The CSC provider returned no signing credentials for the authenticated user (400). | Enrol a signing credential with the provider, then authenticate again. |
| `CSC_CERT_INVALID` | The selected signing certificate is missing, invalid, or outside its validity period (400). | Select or renew a valid certificate, then authenticate again. |
| `CSC_ALGORITHM_REFUSED` | The signing credential uses an unsupported key or digest algorithm (400). | Select a credential that satisfies the instance's CSC algorithm policy. |
| `CSC_SAD_EXPIRED_PRE_SIGN` | The signature activation data is missing, expired, or unreadable before signing (400). | Repeat the credential authorization flow. |
| `CSC_TSP_TIMEOUT` | The trust service provider did not complete the signing request before the timeout (408). | Retry the signing request after checking provider availability. |
| `CSC_EMBED_FAILED` | The returned CSC signature could not be embedded into the envelope items (400). | Restart the signing attempt. If it fails again, contact support. |
| `CSC_BASE_DOCUMENT_MUTATED` | The document changed between signature preparation and signing (500). | Restart signing from the current envelope state. |
| `CSC_REQUEST_FAILED` | A CSC provider request failed without a more specific CSC error (500). | Check provider availability and configuration, then retry. |
## See Also
@@ -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++;
}
+193 -88
View File
@@ -6,6 +6,8 @@ description: Add signature and form fields to documents via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -19,13 +21,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| `secondaryId` | string | Secondary identifier for audit logs |
| `type` | string | Field type (see [Field Types](#field-types)) |
| `recipientId` | number | ID of the recipient assigned to this field |
| `envelopeId` | number | ID of the parent envelope |
| `envelopeId` | string | ID of the parent envelope |
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
| `page` | number | Page number (1-indexed) |
| `positionX` | number | X coordinate as percentage (0-100) |
| `positionY` | number | Y coordinate as percentage (0-100) |
| `width` | number | Width as percentage of page (0-100) |
| `height` | number | Height as percentage of page (0-100) |
| `positionX` | string | X coordinate as percentage (0-100), a decimal serialized as a string |
| `positionY` | string | Y coordinate as percentage (0-100), a decimal serialized as a string |
| `width` | string | Width as percentage of page (0-100), a decimal serialized as a string |
| `height` | string | Height as percentage of page (0-100), a decimal serialized as a string |
| `customText` | string | Value entered by the recipient |
| `inserted` | boolean | Whether the field has been completed |
| `fieldMeta` | object \| null | Type-specific configuration options |
@@ -38,18 +40,19 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
"secondaryId": "field_abc123",
"type": "SIGNATURE",
"recipientId": 123,
"envelopeId": 789,
"envelopeItemId": "envelope_item_xyz",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"required": true
"required": true,
"overflow": "auto"
}
}
```
@@ -61,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| Type | Description | Auto-filled |
| ---------------- | ----------------------------------------- | ----------- |
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No |
| `INITIALS` | Recipient's initials | No |
| `NAME` | Recipient's full name | Yes |
| `EMAIL` | Recipient's email address | Yes |
@@ -134,10 +137,12 @@ POST /envelope/field/create-many
### Request Body
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------- |
| `documentId`| number | Yes | The document ID |
| `fields` | array | Yes | Array of field configurations |
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------------- |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field configurations |
Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`.
### Code Examples
@@ -148,32 +153,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"documentId": 123,
"fields": [
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"type": "SIGNATURE",
"recipientId": 456,
"pageNumber": 1,
"pageX": 10,
"pageY": 80,
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
},
{
"type": "DATE",
"recipientId": 456,
"pageNumber": 1,
"pageX": 50,
"pageY": 80,
"page": 1,
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
},
{
"type": "TEXT",
"recipientId": 456,
"pageNumber": 1,
"pageX": 10,
"pageY": 70,
"page": 1,
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4,
"fieldMeta": {
@@ -199,32 +204,32 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: 123,
fields: [
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
type: 'SIGNATURE',
recipientId: 456,
pageNumber: 1,
pageX: 10,
pageY: 80,
page: 1,
positionX: 10,
positionY: 80,
width: 30,
height: 5,
},
{
type: 'DATE',
recipientId: 456,
pageNumber: 1,
pageX: 50,
pageY: 80,
page: 1,
positionX: 50,
positionY: 80,
width: 20,
height: 3,
},
{
type: 'TEXT',
recipientId: 456,
pageNumber: 1,
pageX: 10,
pageY: 70,
page: 1,
positionX: 10,
positionY: 70,
width: 40,
height: 4,
fieldMeta: {
@@ -239,8 +244,8 @@ const response = await fetch(
}
);
const { fields } = await response.json();
console.log(`Created ${fields.length} fields`);
const { data } = await response.json();
console.log(`Created ${data.length} fields`);
````
</Tab>
@@ -250,36 +255,68 @@ console.log(`Created ${fields.length} fields`);
```json
{
"fields": [
"data": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
"positionX": "50",
"positionY": "80",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
},
{
"id": 103,
"secondaryId": "field_ghi789",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "TEXT",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4
"positionX": "10",
"positionY": "70",
"width": "40",
"height": "4",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "text",
"label": "Job Title",
"placeholder": "Enter your job title",
"required": true
}
}
]
}
@@ -299,8 +336,10 @@ POST /envelope/field/update-many
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------- |
| `documentId` | number | Yes | The document ID |
| `fields` | array | Yes | Array of field update objects |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field update objects |
Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates.
### Code Examples
@@ -311,17 +350,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"documentId": 123,
"fields": [
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"id": 101,
"type": "SIGNATURE",
"pageY": 85
"positionY": 85
},
{
"id": 102,
"type": "DATE",
"pageY": 85
"positionY": 85
}
]
}'
@@ -338,16 +377,16 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: 123,
fields: [
{ id: 101, type: 'SIGNATURE', pageY: 85 },
{ id: 102, type: 'DATE', pageY: 85 },
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{ id: 101, type: 'SIGNATURE', positionY: 85 },
{ id: 102, type: 'DATE', positionY: 85 },
],
}),
}
);
const { fields } = await response.json();
const { data } = await response.json();
````
</Tab>
@@ -357,9 +396,48 @@ const { fields } = await response.json();
```json
{
"fields": [
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
{ "id": 102, "type": "DATE", "positionY": 85 }
"data": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "85",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": "50",
"positionY": "85",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
}
]
}
````
@@ -443,8 +521,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
(0,0) ─────────────────────────── (100,0)
│ │
│ ┌─────────┐ │
│ │ Field │ (pageX: 10, │
│ │ │ pageY: 20, │
│ │ Field │ (positionX: 10, │
│ │ │ positionY: 20, │
│ └─────────┘ width: 30, │
│ height: 5) │
│ │
@@ -457,9 +535,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
const field = {
type: 'SIGNATURE',
recipientId: 123,
pageNumber: 1,
pageX: 60, // 60% from left
pageY: 85, // 85% from top (near bottom)
page: 1,
positionX: 60, // 60% from left
positionY: 85, // 85% from top (near bottom)
width: 30, // 30% of page width
height: 8, // 8% of page height
};
@@ -479,6 +557,33 @@ This approach is useful when generating PDFs programmatically or using templates
See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options.
### Placeholder Positioning via the API
`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass:
| Field | Type | Required | Description |
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. |
| `width` | number | No | Override the field width. Defaults to the width of the matched text. |
| `height` | number | No | Override the field height. Defaults to the height of the matched text. |
| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. |
```json
{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"type": "SIGNATURE",
"recipientId": 456,
"placeholder": "{{signature}}",
"matchAll": true
}
]
}
```
`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only.
---
## Field Meta Options
@@ -643,15 +748,15 @@ All field types support these base options:
Create a document with a signature block containing multiple field types:
```typescript
async function addSignatureBlock(documentId: number, recipientId: number) {
const fields = [
async function addSignatureBlock(envelopeId: string, recipientId: number) {
const data = [
// Signature
{
type: 'SIGNATURE',
recipientId,
pageNumber: 1,
pageX: 10,
pageY: 80,
page: 1,
positionX: 10,
positionY: 80,
width: 30,
height: 8,
fieldMeta: {
@@ -663,9 +768,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'NAME',
recipientId,
pageNumber: 1,
pageX: 10,
pageY: 90,
page: 1,
positionX: 10,
positionY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -677,9 +782,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'DATE',
recipientId,
pageNumber: 1,
pageX: 50,
pageY: 80,
page: 1,
positionX: 50,
positionY: 80,
width: 20,
height: 4,
fieldMeta: {
@@ -691,9 +796,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'TEXT',
recipientId,
pageNumber: 1,
pageX: 50,
pageY: 90,
page: 1,
positionX: 50,
positionY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -710,7 +815,7 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ documentId, fields }),
body: JSON.stringify({ envelopeId, data }),
});
return response.json();
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
href="/docs/developers/api/templates"
/>
<Card
title="Teams"
description="Manage teams and team members."
title="Team-scoped access"
description="Use team-scoped API tokens with envelope endpoints."
href="/docs/developers/api/teams"
/>
</Cards>
@@ -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.
---
@@ -6,6 +6,8 @@ description: Add and manage envelope recipients via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -16,7 +18,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
```json
{
"id": 123,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -134,7 +136,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"email": "signer@example.com",
@@ -164,7 +166,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'signer@example.com',
@@ -196,7 +198,7 @@ const { data: recipients } = await response.json();
"data": [
{
"id": 789,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -209,7 +211,7 @@ const { data: recipients } = await response.json();
},
{
"id": 790,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "approver@example.com",
"name": "Jane Smith",
"role": "APPROVER",
@@ -262,7 +264,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"id": 789,
@@ -284,7 +286,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
id: 789,
@@ -387,7 +389,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'approver@example.com',
@@ -462,7 +464,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'signer@example.com',
+34 -47
View File
@@ -1,44 +1,29 @@
---
title: Teams API
description: Manage team resources, documents, and templates with team-scoped API tokens.
title: Team-Scoped API Access
description: Use team-scoped API tokens with document and template envelopes.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Team Object
## Team Context
A team object contains the following properties:
<Callout type="info">
The V2 REST API does not expose `/team/*` endpoints. Create and manage teams, members, and team
settings in the Documenso web application. This page explains how a team-scoped token applies
that team context to supported API resources.
</Callout>
| Property | Type | Description |
| ----------------- | -------------- | --------------------------------------------------- |
| `id` | number | Unique team identifier |
| `name` | string | Team display name |
| `url` | string | Unique team URL slug |
| `createdAt` | string | ISO 8601 timestamp |
| `avatarImageId` | string \| null | ID of the team's avatar image |
| `organisationId` | string | ID of the parent organisation |
| `currentTeamRole` | string | Your role in the team: `ADMIN`, `MANAGER`, `MEMBER` |
### Example Team Object
```json
{
"id": 123,
"name": "Engineering",
"url": "engineering",
"createdAt": "2025-01-15T10:30:00.000Z",
"avatarImageId": null,
"organisationId": "org_abc123",
"currentTeamRole": "ADMIN"
}
```
The API resolves the team from your token. You do not pass a team ID when creating, listing, or
using envelopes. The token's team ID determines which resources the request can access.
## Team-Scoped API Tokens
@@ -95,7 +80,7 @@ Documents created with a team token belong to that team:
<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",
@@ -156,26 +141,26 @@ Retrieve all documents belonging to the team:
<Tab value="curl">
```bash
# List all team documents
curl -X GET "https://app.documenso.com/api/v2/envelope" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
},
});
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} team documents`);
const { data, count } = await response.json();
console.log(`Found ${count} team documents`);
````
</Tab>
@@ -190,10 +175,11 @@ Templates created with a team token are shared across the team.
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/template/create" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "TEMPLATE",
"title": "NDA Template",
"recipients": [
{
@@ -223,6 +209,7 @@ curl -X POST "https://app.documenso.com/api/v2/template/create" \
const form = new FormData();
const payload = {
type: 'TEMPLATE',
title: 'NDA Template',
recipients: [
{
@@ -249,7 +236,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
contentType: 'application/pdf',
});
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
method: 'POST',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -257,8 +244,8 @@ const response = await fetch('https://app.documenso.com/api/v2/template/create',
body: form,
});
const template = await response.json();
console.log('Created team template:', template.id);
const { id } = await response.json();
console.log('Created team template envelope:', id);
````
</Tab>
</Tabs>
@@ -268,14 +255,14 @@ console.log('Created team template:', template.id);
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/template', {
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -330,19 +317,19 @@ const SALES_TEAM_TOKEN = process.env.SALES_TEAM_API_TOKEN;
const LEGAL_TEAM_TOKEN = process.env.LEGAL_TEAM_API_TOKEN;
// Get pending documents from sales team
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=PENDING', {
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING', {
headers: { Authorization: SALES_TEAM_TOKEN },
});
const salesDocs = await salesResponse.json();
// Get completed documents from legal team
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', {
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=COMPLETED', {
headers: { Authorization: LEGAL_TEAM_TOKEN },
});
const legalDocs = await legalResponse.json();
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
console.log(`Sales team: ${salesDocs.count} pending`);
console.log(`Legal team: ${legalDocs.count} completed`);
```
## Error Responses
@@ -13,7 +13,194 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Template Object
## Use a Template Envelope
New integrations should create a document from a template envelope with the Envelope API.
```
POST /envelope/use
Content-Type: multipart/form-data
```
The request uses `multipart/form-data`:
| Part | Type | Required | Description |
| --------- | ------- | -------- | ------------------------------------------------------------------ |
| `payload` | JSON | Yes | Template envelope ID, recipient details, and document settings |
| `files` | File(s) | No | Replacement PDFs referenced by entries in `customDocumentData` |
### Payload Schema
| Field | Type | Required | Description |
| -------------------- | ------- | -------- | ------------------------------------------------------------------------ |
| `envelopeId` | string | Yes | ID of the template envelope |
| `externalId` | string | No | Your identifier for the created document envelope |
| `recipients` | array | No | Recipient details mapped to recipients in the template |
| `distributeDocument` | boolean | No | If `true`, create the document as pending and distribute it |
| `customDocumentData` | array | No | Maps uploaded replacement PDFs to template envelope items |
| `folderId` | string | No | Folder in which to create the document |
| `prefillFields` | array | No | Field values to prefill before distribution |
| `override` | object | No | Template values to override for the created document |
| `attachments` | array | No | Link attachments to add to the document |
| `formValues` | object | No | PDF form values to apply |
Each recipient entry accepts the following fields:
| Field | Type | Required | Description |
| -------------- | ------- | -------- | -------------------------------------------- |
| `id` | number | Yes | Recipient ID from the template envelope |
| `email` | string | Yes | Recipient email address |
| `name` | string | No | Recipient display name |
| `signingOrder` | number | No | Recipient position in sequential signing |
Each `customDocumentData` entry maps an uploaded file to a template item:
| Field | Type | Required | Description |
| ---------------- | ---------------- | -------- | --------------------------------------------------------------- |
| `identifier` | string \| number | Yes | Uploaded filename or zero-based file index |
| `envelopeItemId` | string | Yes | Template envelope item whose PDF the uploaded file replaces |
### Code Examples
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/use" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-F 'payload={
"envelopeId": "envelope_template123",
"externalId": "contract-2025-001",
"recipients": [
{
"id": 1,
"email": "john.doe@example.com",
"name": "John Doe"
}
],
"prefillFields": [
{
"id": 101,
"type": "text",
"value": "Senior Software Engineer"
}
],
"distributeDocument": false
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const form = new FormData();
form.append(
'payload',
JSON.stringify({
envelopeId: 'envelope_template123',
externalId: 'contract-2025-001',
recipients: [
{
id: 1,
email: 'john.doe@example.com',
name: 'John Doe',
},
],
prefillFields: [
{
id: 101,
type: 'text',
value: 'Senior Software Engineer',
},
],
distributeDocument: false,
}),
);
const response = await fetch('https://app.documenso.com/api/v2/envelope/use', {
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
body: form,
});
const document = await response.json();
console.log('Created document envelope:', document.id);
```
</Tab>
</Tabs>
### Response
```json
{
"id": "envelope_document123",
"recipients": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"token": "recipient_token",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/recipient_token"
}
]
}
```
### Distribute the Created Envelope
If you leave `distributeDocument` unset or set it to `false`, distribute the created document with
`POST /envelope/distribute`. Its response confirms delivery and includes each recipient's signing URL.
```typescript
const distributionResponse = await fetch(
'https://app.documenso.com/api/v2/envelope/distribute',
{
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.id,
}),
},
);
const distribution = await distributionResponse.json();
console.log('Signing URL:', distribution.recipients[0].signingUrl);
```
```json
{
"success": true,
"id": "envelope_document123",
"recipients": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"token": "recipient_token",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/recipient_token"
}
]
}
```
---
## Deprecated Template Endpoint Reference
<Callout type="warn">
Every `/template/*` endpoint below is deprecated. Use the Envelope API for new integrations and
follow [Migrating to Envelopes](/docs/developers/api/migrate-to-envelopes) to replace existing calls.
The legacy reference remains here to support migrations.
</Callout>
## Legacy Template Object
A template object contains the following properties:
@@ -91,7 +278,7 @@ A template object contains the following properties:
}
```
## List Templates
## List Templates (Deprecated)
Retrieve a paginated list of templates.
@@ -139,8 +326,8 @@ const response = await fetch(`${BASE_URL}/template`, {
},
});
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} templates`);
const { data, count } = await response.json();
console.log(`Found ${count} templates`);
// Filter by type
const privateResponse = await fetch(
@@ -181,18 +368,16 @@ const privateTemplates = await privateResponse.json();
]
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 3,
"totalItems": 25
}
"count": 25,
"currentPage": 1,
"perPage": 10,
"totalPages": 3
}
```
---
## Get Template
## Get Template (Deprecated)
Retrieve a single template by ID.
@@ -238,9 +423,9 @@ Returns the full template object including recipients, fields, and metadata.
---
## Create Document from Template
## Create Document from Template (Deprecated)
Create a new document using a template. This is the primary way to use templates programmatically.
Create a new document using the deprecated template endpoint.
<Callout type="info">
This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning.
@@ -415,32 +600,57 @@ const prefilledDocument = await prefillResponse.json();
### Response
Returns the created document object with recipients and signing URLs.
The endpoint returns the full legacy document object. The selected fields below show both the numeric
legacy `id` and canonical `envelopeId`. Recipient entries do not include a `signingUrl`.
```json
{
"id": "envelope_xyz789",
"type": "DOCUMENT",
"id": 789,
"envelopeId": "envelope_xyz789",
"status": "PENDING",
"title": "Employment Contract",
"source": "TEMPLATE",
"title": "Employment Contract",
"externalId": "contract-2025-001",
"recipients": [
{
"id": 1,
"envelopeId": "envelope_xyz789",
"documentId": 789,
"templateId": null,
"email": "john.doe@example.com",
"name": "John Doe",
"role": "SIGNER",
"signingStatus": "NOT_SIGNED",
"signingUrl": "https://app.documenso.com/sign/abc123"
"signingOrder": 1
}
]
}
````
```
To send a document created with `distributeDocument: false` and receive signing links, call
`POST /envelope/distribute` with its `envelopeId`:
```typescript
const document = await response.json();
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.envelopeId,
}),
});
const distribution = await distributionResponse.json();
console.log('Signing URL:', distribution.recipients[0].signingUrl);
```
---
## Override Template Settings
## Override Template Settings (Deprecated)
When creating a document from a template, you can override various settings:
@@ -488,7 +698,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Prefill Fields
## Prefill Fields (Deprecated)
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
@@ -577,7 +787,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Update Template
## Update Template (Deprecated)
Update a template's properties.
@@ -643,7 +853,7 @@ const template = await response.json();
---
## Duplicate Template
## Duplicate Template (Deprecated)
Create a copy of an existing template.
@@ -695,7 +905,7 @@ console.log('New template ID:', duplicatedTemplate.id);
---
## Delete Template
## Delete Template (Deprecated)
Delete a template.
@@ -754,7 +964,7 @@ const { success } = await response.json();
---
## Direct Link Templates
## Direct Link Templates (Deprecated)
Direct link templates allow recipients to create and sign documents without requiring you to explicitly create each document. When a recipient visits the direct link, a new document is automatically created from the template.
@@ -898,7 +1108,7 @@ const { success } = await response.json();
---
## Custom Document Data
## Custom Document Data (Deprecated)
When creating a document from a template, you can replace the template's PDF with a custom PDF by using the `customDocumentData` parameter. This is useful when you need to generate the PDF dynamically while reusing the template's recipient and field configuration.
@@ -913,7 +1123,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Template Types
## Template Types (Legacy)
| Type | Description |
| --------- | ------------------------------------------------------------------ |
@@ -922,7 +1132,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Complete Example: Contract Workflow
## Complete Legacy Example: Contract Workflow (Deprecated)
This example demonstrates a complete workflow for using templates to send contracts.
@@ -996,16 +1206,29 @@ async function sendEmploymentContract(employeeData: {
subject: `Employment Contract for ${employeeData.name}`,
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
},
distributeDocument: true,
distributeDocument: false,
externalId: `emp-contract-${Date.now()}`,
}),
});
const document = await documentResponse.json();
// 5. Distribute the envelope and get recipient signing links
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.envelopeId,
}),
});
const distribution = await distributionResponse.json();
return {
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
envelopeId: document.envelopeId,
signingUrl: distribution.recipients[0].signingUrl,
};
}
@@ -1018,7 +1241,7 @@ const result = await sendEmploymentContract({
startDate: '2025-03-01',
});
console.log('Document created:', result.documentId);
console.log('Document created:', result.envelopeId);
console.log('Signing URL:', result.signingUrl);
````
@@ -51,6 +51,7 @@ async function createAndSendDocument(
pdfBuffer: Buffer,
filename: string,
title: string,
externalId: string,
recipients: Recipient[],
): Promise<CreateAndSendResult> {
const recipientPayload = recipients.map((recipient, index) => ({
@@ -89,6 +90,7 @@ async function createAndSendDocument(
JSON.stringify({
type: 'DOCUMENT',
title,
externalId,
recipients: recipientPayload,
meta: {
subject: `Please sign: ${title}`,
@@ -145,6 +147,7 @@ const result = await createAndSendDocument(
pdfBuffer,
'contract.pdf',
'Service Agreement',
'nda-contract-ndac214',
[
{ email: 'client@example.com', name: 'John Smith', role: 'SIGNER' },
{ email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' },
@@ -172,6 +175,7 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \
-F 'payload={
"type": "DOCUMENT",
"title": "Service Agreement",
"externalId": "nda-contract-ndac214",
"recipients": [
{
"email": "client@example.com",
@@ -241,6 +245,8 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}'
</Tab>
</Tabs>
`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents).
---
## Workflow 2: Create Document from Template with Custom Data
@@ -256,8 +262,11 @@ Use templates for repeatable document workflows. This example creates an employm
Map template fields by label and build a <code>prefillFields</code> array
</Step>
<Step>
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
<code>distributeDocument: true</code>
Call <code>POST /template/use</code> with recipients and prefill data
</Step>
<Step>
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
links
</Step>
</Steps>
@@ -291,7 +300,7 @@ type TemplateRecipient = {
async function sendEmploymentContract(
templateId: number,
employee: EmployeeData,
): Promise<{ documentId: string; signingUrl: string }> {
): Promise<{ documentId: number; signingUrl: string }> {
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
headers: { Authorization: API_TOKEN },
});
@@ -368,7 +377,6 @@ async function sendEmploymentContract(
subject: `Your Employment Contract at ${employee.department}`,
message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`,
},
distributeDocument: true,
externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`,
}),
});
@@ -380,9 +388,25 @@ async function sendEmploymentContract(
const document = await createResponse.json();
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: document.envelopeId }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
throw new Error(`Failed to send document: ${error.message}`);
}
const distributeResult = await distributeResponse.json();
return {
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
signingUrl: distributeResult.recipients[0].signingUrl,
};
}
@@ -447,12 +471,17 @@ RESPONSE=$(curl -s -X POST "${BASE_URL}/template/use" \
\"subject\": \"Your Employment Contract\",
\"message\": \"Please review and sign your employment contract.\"
},
\"distributeDocument\": true,
\"externalId\": \"emp-$(date +%s)-alice\"
}")
echo "Document created:"
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
echo "Document created: $(echo $RESPONSE | jq -r '.id')"
echo "Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
````
</Tab>
@@ -470,8 +499,10 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
Fetch the template and get the signer recipient slot ID
</Step>
<Step>
For each recipient, call <code>POST /template/use</code> with{' '}
<code>distributeDocument: true</code>
For each recipient, call <code>POST /template/use</code>
</Step>
<Step>
Distribute each returned envelope via <code>POST /envelope/distribute</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
@@ -534,7 +565,6 @@ async function bulkSendFromTemplate(
recipients: [
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
],
distributeDocument: true,
externalId: `bulk-${Date.now()}-${recipient.email}`,
}),
});
@@ -545,10 +575,26 @@ async function bulkSendFromTemplate(
}
const document = await response.json();
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: document.envelopeId }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
throw new Error(error.message || 'Failed to distribute document');
}
const distributeResult = await distributeResponse.json();
return {
email: recipient.email,
envelopeId: document.id,
signingUrl: document.recipients[0].signingUrl,
envelopeId: document.envelopeId,
signingUrl: distributeResult.recipients[0].signingUrl,
};
}),
);
@@ -621,14 +667,24 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
\"email\": \"${EMAIL}\",
\"name\": \"${NAME}\"
}],
\"distributeDocument\": true,
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
}")
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
echo " Success: $(echo $RESPONSE | jq -r '.id')"
if echo $RESPONSE | jq -e '.envelopeId' > /dev/null 2>&1; then
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
if echo $DISTRIBUTE_RESPONSE | jq -e '.success' > /dev/null 2>&1; then
echo " Success: ${ENVELOPE_ID}"
echo " Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
else
echo " Failed to distribute: $(echo $DISTRIBUTE_RESPONSE | jq -r '.message')"
fi
else
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
fi
# Rate limiting delay
@@ -831,13 +887,15 @@ After a document is completed, download the signed PDF with all signatures embed
</Step>
</Steps>
The `version` query parameter accepts `original`, `pending`, or `signed`.
<Tabs items={['TypeScript', 'curl']}>
<Tab value="TypeScript">
```typescript
const API_TOKEN = process.env.DOCUMENSO_API_TOKEN;
const BASE_URL = 'https://app.documenso.com/api/v2';
type DownloadVersion = 'signed' | 'original';
type DownloadVersion = 'original' | 'pending' | 'signed';
async function downloadDocument(
envelopeId: string,
@@ -891,7 +949,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
{ headers: { Authorization: API_TOKEN } },
);
const { data, pagination } = await response.json();
const { data, count, currentPage, perPage, totalPages } = await response.json();
for (const envelope of data) {
try {
@@ -905,8 +963,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
}
hasMore = page < pagination.totalPages;
page++;
hasMore = currentPage < totalPages;
page = currentPage + 1;
}
}
@@ -1000,9 +1058,12 @@ async function fetchWithRetry(
// Retry on rate limit
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
const delay = retryAfter
? parseInt(retryAfter) * 1000
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
console.log(`Rate limited, waiting ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
@@ -24,20 +24,18 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
{/* prettier-ignore */}
<Steps>
<Step>
### Open settings
### Select a team
- Log in to your Documenso account
- Click your avatar in the top right corner
- Select **Settings** from the dropdown menu
![User dropdown menu](/public-api-images/documenso-user-dropdown-menu.webp)
Log in to Documenso and open the team that the integration should access. API tokens are scoped to a
team.
</Step>
<Step>
### Navigate to the API Tokens tab
### Open API Tokens
Go to **Settings** and open the **API Tokens** tab.
Go to **Team Settings** → **API Tokens**, or open
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
![API tokens page](/public-api-images/api-tokens-page-documenso.webp)
@@ -48,7 +46,7 @@ Go to **Settings** and open the **API Tokens** tab.
- Click **Create Token**
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
- Click **Create Token**
</Step>
@@ -75,21 +73,21 @@ Include the token in the `Authorization` header of your HTTP requests.
### cURL
```bash
curl https://app.documenso.com/api/v2/document \
curl https://app.documenso.com/api/v2/envelope \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
```
### JavaScript / TypeScript
```typescript
const response = await fetch('https://app.documenso.com/api/v2/document', {
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
method: 'GET',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
});
const documents = await response.json();
const envelopes = await response.json();
```
### Using the TypeScript SDK
@@ -129,7 +127,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
## Token Security
API tokens grant full access to your account. Follow these practices to keep them secure:
API tokens grant full API access to the team they were created for. Follow these practices to keep them secure:
- **Never commit tokens to version control.** Use environment variables instead.
- **Use descriptive names.** Names like `zapier-prod` or `backend-staging` help you identify token usage.
@@ -155,12 +153,11 @@ const client = new Documenso({
## Token Scope
API tokens have full access to your account, including:
API tokens have full API access to the team they were created for, including:
- Creating, reading, updating, and deleting documents
- Managing recipients and fields
- Accessing templates
- Managing team resources (if the token owner has team access)
There is currently no way to create tokens with limited scopes or permissions.
@@ -171,7 +168,7 @@ To revoke a token:
{/* prettier-ignore */}
<Steps>
<Step>
Go to **Settings** > **API Tokens**
Go to **Team Settings** → **API Tokens**
</Step>
<Step>
Find the token you want to revoke
@@ -194,7 +191,7 @@ Revoked tokens stop working immediately. Any integrations using that token will
</Accordion>
<Accordion title="401 Unauthorized — Expired token">Create a new token in settings.</Accordion>
<Accordion title="403 Forbidden — Token doesn't have access to the resource">
Ensure you're accessing resources owned by the token's account.
Ensure you're accessing resources owned by the token's team.
</Accordion>
</Accordions>
@@ -78,12 +78,10 @@ A successful response returns a list of your documents (envelopes):
"createdAt": "2025-01-15T10:30:00.000Z"
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 1,
"totalItems": 1
}
"count": 1,
"currentPage": 1,
"perPage": 10,
"totalPages": 1
}
````
@@ -228,9 +226,12 @@ After creating a document, it's in `DRAFT` status. To send it to recipients, use
<Tabs items={['curl', 'JavaScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
-H "Authorization: YOUR_API_TOKEN" \
-H "Content-Type: application/json"
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123"
}'
````
</Tab>
@@ -238,16 +239,14 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribu
```javascript
const envelopeId = 'envelope_abc123';
const response = await fetch(
`https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`,
{
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
);
body: JSON.stringify({ envelopeId }),
});
const data = await response.json();
console.log('Document sent:', data);
@@ -337,16 +336,14 @@ async function createAndSendDocument(pdfPath, recipientEmail, recipientName) {
console.log('Created envelope:', envelope.id);
// Step 2: Send the document for signing
const distributeResponse = await fetch(
`${BASE_URL}/envelope/${envelope.id}/distribute`,
{
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
}
);
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: envelope.id }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
@@ -422,9 +419,12 @@ echo "Created envelope: ${ENVELOPE_ID}"
# Step 2: Send the document for signing
echo "Sending document..."
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json"
-H "Content-Type: application/json" \
-d "{
\"envelopeId\": \"${ENVELOPE_ID}\"
}"
echo "Document sent for signing!"
@@ -441,7 +441,7 @@ The API returns standard HTTP status codes and JSON error responses:
| `400` | Bad request - check your request payload |
| `401` | Unauthorized - invalid or missing API token |
| `404` | Not found - resource doesn't exist |
| `429` | Rate limited - wait 60 seconds and retry |
| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,7 +485,9 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
The API has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically.
Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information.
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -493,8 +495,15 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
console.log('Rate limited, waiting 60 seconds...');
await new Promise((resolve) => setTimeout(resolve, 60000));
const retryAfter = response.headers.get('Retry-After');
if (!retryAfter) {
return response;
}
const retryAfterSeconds = Number.parseInt(retryAfter, 10);
console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`);
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
continue;
}
@@ -81,7 +81,7 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
@@ -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.3",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
+9 -128
View File
@@ -1,142 +1,23 @@
/**
* Multi purpose CORS lib.
* Note: Based on the `cors` package in npm but using only web APIs.
* Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts
* Apply the public statistics API's wildcard CORS policy, without credentials.
*/
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise<StaticOrigin>;
interface CorsOptions {
origin?: StaticOrigin | OriginFn;
methods?: string | string[];
allowedHeaders?: string | string[];
exposedHeaders?: string | string[];
credentials?: boolean;
maxAge?: number;
preflightContinue?: boolean;
optionsSuccessStatus?: number;
}
const defaultOptions: CorsOptions = {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204,
};
function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
return Array.isArray(allowed)
? allowed.some((o) => isOriginAllowed(origin, o))
: typeof allowed === 'string'
? origin === allowed
: allowed instanceof RegExp
? allowed.test(origin)
: !!allowed;
}
function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
const headers = new Headers();
if (origin === '*') {
headers.set('Access-Control-Allow-Origin', '*');
} else if (typeof origin === 'string') {
headers.set('Access-Control-Allow-Origin', origin);
headers.append('Vary', 'Origin');
} else {
const allowed = isOriginAllowed(reqOrigin ?? '', origin);
if (allowed && reqOrigin) {
headers.set('Access-Control-Allow-Origin', reqOrigin);
}
headers.append('Vary', 'Origin');
}
return headers;
}
async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) {
const reqOrigin = req.headers.get('Origin') || undefined;
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
if (!value) {
return;
}
return getOriginHeaders(reqOrigin, value);
}
function getAllowedHeaders(req: Request, allowed?: string | string[]) {
const headers = new Headers();
if (!allowed) {
allowed = req.headers.get('Access-Control-Request-Headers')!;
headers.append('Vary', 'Access-Control-Request-Headers');
} else if (Array.isArray(allowed)) {
allowed = allowed.join(',');
}
if (allowed) {
headers.set('Access-Control-Allow-Headers', allowed);
}
return headers;
}
export default async function cors(req: Request, res: Response, options?: CorsOptions) {
const opts = { ...defaultOptions, ...options };
export default async function cors(req: Request, res: Response): Promise<Response> {
const { headers } = res;
const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
const mergeHeaders = (v: string, k: string) => {
if (k === 'Vary') {
headers.append(k, v);
} else {
headers.set(k, v);
}
};
headers.set('Access-Control-Allow-Origin', '*');
// If there's no origin we won't touch the response
if (!originHeaders) {
return res;
}
originHeaders.forEach(mergeHeaders);
if (opts.credentials) {
headers.set('Access-Control-Allow-Credentials', 'true');
}
const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(',') : opts.exposedHeaders;
if (exposed) {
headers.set('Access-Control-Expose-Headers', exposed);
}
// Handle the preflight request
if (req.method === 'OPTIONS') {
if (opts.methods) {
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
headers.set('Vary', 'Access-Control-Request-Headers');
headers.set('Access-Control-Allow-Methods', methods);
}
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders);
if (typeof opts.maxAge === 'number') {
headers.set('Access-Control-Max-Age', String(opts.maxAge));
}
if (opts.preflightContinue) {
return res;
if (allowedHeaders) {
headers.set('Access-Control-Allow-Headers', allowedHeaders);
}
headers.set('Content-Length', '0');
return new Response(null, { status: opts.optionsSuccessStatus, headers });
return new Response(null, { status: 204, headers });
}
// If we got here, it's a normal request
return res;
}
export function initCors(options?: CorsOptions) {
return async (req: Request, res: Response) => cors(req, res, options);
}
+2 -2
View File
@@ -12,11 +12,11 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.2.6"
"next": "^16.3.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.27",
"@types/react": "^19.2.17",
"typescript": "5.6.2"
}
}
-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({
@@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}
if (result.failedIds.length > 0) {
@@ -290,15 +290,16 @@ 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>
)}
<fieldset disabled={isDownloading} className="space-y-4">
<fieldset disabled={isDownloading} className="min-w-0 space-y-4">
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
<div className="divide-y divide-border rounded-lg border border-border">
{envelopes.map((envelope) => {
@@ -96,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}
await onSuccess?.(data.folderId);
@@ -1,7 +1,7 @@
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
))}
<Link
to="https://documen.so/enterprise-cta"
to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
target="_blank"
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
>
@@ -1,4 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
@@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
@@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}
setOpen(value);
if (!value) {
setValidationError(null);
form.reset();
}
};
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
@@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};
const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);
try {
const csv = await values.file.text();
await uploadBulkSend({
const result = await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
if (!result.success) {
setValidationError(result.error);
return;
}
toast({
title: _(msg`Success`),
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
});
setOpen(false);
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
const error = AppError.parseError(err);
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
}
};
return (
<Dialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => onChange(null)}
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -218,6 +257,72 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
)}
/>
{validationError !== null && (
<Alert variant="destructive">
<AlertDescription className="max-h-32 overflow-y-auto">
{match(validationError)
.with({ type: 'PARSE_ERROR' }, () => (
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
))
.with({ type: 'EMPTY' }, () => (
<Trans>
The CSV does not contain any rows. Please add at least one row of recipient details.
</Trans>
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
<Plural value={rowCount} one="The CSV contains # row." other="The CSV contains # rows." />{' '}
<Plural
value={maxRows}
one="A maximum of # row is allowed per upload."
other="A maximum of # rows is allowed per upload."
/>
</Trans>
))
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
<>
<Trans>
The CSV is missing the following required columns. Please download the template CSV for the
correct format.
</Trans>
<ul className="mt-1 list-inside list-disc">
{missingColumns.map((column) => (
<li key={column} className="font-mono">
{column}
</li>
))}
</ul>
</>
))
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
<>
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
<ul className="mt-1 list-inside list-disc">
{rowErrors.map((rowError, index) => (
<li key={index}>
<Trans>
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
email or empty
</Trans>
</li>
))}
</ul>
</>
))
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
code === AppErrorCode.LIMIT_EXCEEDED ? (
<Trans>The CSV exceeds the maximum file size.</Trans>
) : (
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
),
)
.exhaustive()}
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="sendImmediately"
@@ -240,7 +345,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Trans>Cancel</Trans>
</Button>
@@ -8,6 +8,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -23,6 +24,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -38,27 +40,64 @@ import { useNavigate } from 'react-router';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.array(
const DOCUMENT_NAME_SOURCE = {
TEMPLATE: 'template',
UPLOAD: 'upload',
CUSTOM: 'custom',
} as const;
const getUploadedDocumentTitle = (file: File) => {
return file.name.replace(/\.[^/.]+$/, '').trim();
};
/**
* Whether the file name can be used as a document title.
*/
const isUploadedFileNameUsable = (file?: File): file is File => {
if (!file) {
return false;
}
const title = getUploadedDocumentTitle(file);
return title.length > 0 && title.length <= DOCUMENT_TITLE_MAX_LENGTH;
};
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
documentNameSource: z.enum([
DOCUMENT_NAME_SOURCE.TEMPLATE,
DOCUMENT_NAME_SOURCE.UPLOAD,
DOCUMENT_NAME_SOURCE.CUSTOM,
]),
customDocumentName: z
.string()
.trim()
.max(DOCUMENT_TITLE_MAX_LENGTH, { message: msg`Document name is too long`.id }),
customDocumentData: z
.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
)
.optional(),
recipients: z.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
),
})
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
message: msg`Document name is required`.id,
path: ['customDocumentName'],
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@@ -87,6 +126,7 @@ export function TemplateUseDialog({
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [lastUploadedFile, setLastUploadedFile] = useState<File>();
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
@@ -106,6 +146,8 @@ export function TemplateUseDialog({
return {
distributeDocument: false,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
@@ -140,11 +182,39 @@ export function TemplateUseDialog({
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
/**
* Track the most recently uploaded file so its name can be used as the document name.
* Files with an unusable name are ignored, and the document name source is reset if
* no usable file remains.
*/
const updateLastUploadedFile = (file?: File) => {
const usableFile = isUploadedFileNameUsable(file) ? file : undefined;
setLastUploadedFile(usableFile);
if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) {
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
}
};
const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => {
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
return data.customDocumentName;
}
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) {
return getUploadedDocumentTitle(lastUploadedFile);
}
return undefined;
};
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
const customFilesToUpload = (data.customDocumentData || []).filter(
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
const documentTitle = getDocumentTitle(data);
const customFilesToUpload = (data.customDocumentData ?? []).filter(
(item): item is typeof item & { data: File } => item.data !== undefined,
);
const customDocumentData = await Promise.all(
@@ -163,6 +233,7 @@ export function TemplateUseDialog({
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
...(documentTitle ? { override: { title: documentTitle } } : {}),
});
toast({
@@ -195,9 +266,14 @@ export function TemplateUseDialog({
name: 'recipients',
});
const useCustomDocument = form.watch('useCustomDocument');
const documentNameSource = form.watch('documentNameSource');
const canUseUploadedDocumentName = Boolean(lastUploadedFile);
useEffect(() => {
if (open) {
form.reset(generateDefaultFormValues());
setLastUploadedFile(undefined);
}
}, [open, form]);
@@ -238,9 +314,9 @@ export function TemplateUseDialog({
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
<form className="min-w-0" onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full min-w-0 flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] w-full min-w-0 max-w-full space-y-4 overflow-y-auto overflow-x-hidden p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
@@ -401,7 +477,17 @@ export function TemplateUseDialog({
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
const customDocumentData = form.getValues('customDocumentData');
form.setValue(
'customDocumentData',
customDocumentData?.map((item) => ({
...item,
data: undefined,
})),
);
form.clearErrors('customDocumentData');
updateLastUploadedFile(undefined);
}
}}
/>
@@ -428,7 +514,7 @@ export function TemplateUseDialog({
)}
/>
{form.watch('useCustomDocument') && (
{useCustomDocument && (
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
@@ -443,7 +529,7 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
className="flex w-full min-w-0 items-center gap-4 overflow-hidden rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
@@ -451,13 +537,15 @@ export function TemplateUseDialog({
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
<div className="min-w-0 flex-1 overflow-hidden">
<h4 className="truncate font-medium text-foreground text-sm">
{field.value ? getUploadedDocumentTitle(field.value) : item.title}
</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
{field.value ? (
<div>
<span>
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
</div>
</span>
) : (
<Trans>Default file</Trans>
)}
@@ -475,6 +563,18 @@ export function TemplateUseDialog({
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
if (field.value === lastUploadedFile) {
// Fall back to any other uploaded file so the option stays available.
const remainingUploadedFile = form
.getValues('customDocumentData')
?.find(
(item) =>
item.data !== field.value && isUploadedFileNameUsable(item.data),
)?.data;
updateLastUploadedFile(remainingUploadedFile);
}
}}
>
<X className="mr-2 h-4 w-4" />
@@ -517,7 +617,7 @@ export function TemplateUseDialog({
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
form.setError(`customDocumentData.${i}.data`, {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
@@ -526,7 +626,7 @@ export function TemplateUseDialog({
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
form.setError(`customDocumentData.${i}.data`, {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
@@ -537,6 +637,8 @@ export function TemplateUseDialog({
}
field.onChange(file);
form.clearErrors(`customDocumentData.${i}.data`);
updateLastUploadedFile(file);
}}
/>
</div>
@@ -550,6 +652,112 @@ export function TemplateUseDialog({
)}
</div>
)}
<FormField
control={form.control}
name="documentNameSource"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document name</Trans>
</FormLabel>
<FormControl>
<RadioGroup
aria-label={_(msg`Document name`)}
value={field.value}
onValueChange={field.onChange}
className="space-y-2"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
<label className="text-sm" htmlFor="document-name-source-template">
<Trans>Use template name</Trans>
</label>
</div>
<div className="flex items-start gap-2">
<RadioGroupItem
id="document-name-source-upload"
value={DOCUMENT_NAME_SOURCE.UPLOAD}
disabled={!canUseUploadedDocumentName}
className="mt-0.5"
/>
<div className="min-w-0">
<div className="flex items-center gap-1">
<label
className={cn('text-sm', {
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
})}
htmlFor="document-name-source-upload"
>
<Trans>Use uploaded file name</Trans>
</label>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={_(msg`About uploaded file naming`)}
className="text-muted-foreground"
>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-xs">
<Trans>
The document name will use the most recently uploaded file name without its
extension.
</Trans>
</TooltipContent>
</Tooltip>
</div>
{lastUploadedFile && (
<p
className="max-w-sm truncate text-muted-foreground text-xs"
title={lastUploadedFile.name}
>
{lastUploadedFile.name}
</p>
)}
{!canUseUploadedDocumentName && (
<p className="text-muted-foreground text-xs">
<Trans>Upload a custom document to use its file name.</Trans>
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
<label className="text-sm" htmlFor="document-name-source-custom">
<Trans>Enter custom document name</Trans>
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
<FormField
control={form.control}
name="customDocumentName"
render={({ field }) => (
<FormItem className="ml-6">
<FormControl>
<Input
{...field}
aria-label={_(msg`Custom document name`)}
placeholder={_(msg`Enter a document name`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<DialogFooter className="mt-4">
@@ -18,6 +18,8 @@ import { useCallback, useRef } from 'react';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { useCspNonce } from '~/utils/nonce';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps {
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const cspNonce = useCspNonce();
const $sensorApi = useRef<SensorAPI | null>(null);
@@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
/>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -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 {
@@ -0,0 +1,158 @@
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { Trans } from '@lingui/react/macro';
import type React from 'react';
import { useState } from 'react';
import { type FieldValues, type Path, useFormContext } from 'react-hook-form';
import { z } from 'zod';
/**
* Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`.
*/
export const ZTwoFactorCodeFieldSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TTwoFactorCodeFieldSchema = z.infer<typeof ZTwoFactorCodeFieldSchema>;
export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode;
type TwoFactorMethod = 'totp' | 'backup';
export type TwoFactorCodeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isSubmitting?: boolean;
submitLabel: React.ReactNode;
/**
* Called when the user submits the code. Typically the parent form's submit handler.
*/
onSubmit: () => void;
};
/**
* Collects a TOTP or backup code on top of an existing form, mirroring the
* sign in and disable 2FA dialogs.
*
* Must be rendered inside a `<Form>` whose values include `totpCode` and `backupCode`.
*/
export const TwoFactorCodeDialog = <T extends FieldValues & TTwoFactorCodeFieldSchema>({
open,
onOpenChange,
isSubmitting,
submitLabel,
onSubmit,
}: TwoFactorCodeDialogProps) => {
const form = useFormContext<T>();
const [method, setMethod] = useState<TwoFactorMethod>('totp');
const totpCodeName = 'totpCode' as Path<T>;
const backupCodeName = 'backupCode' as Path<T>;
const onToggleMethod = () => {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod((current) => (current === 'totp' ? 'backup' : 'totp'));
};
const handleOpenChange = (value: boolean) => {
if (isSubmitting) {
return;
}
if (!value) {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod('totp');
}
onOpenChange(value);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Two-Factor Authentication</Trans>
</DialogTitle>
<DialogDescription>
{method === 'totp' ? (
<Trans>Enter the code from your authenticator app to continue.</Trans>
) : (
<Trans>Enter one of your backup codes to continue.</Trans>
)}
</DialogDescription>
</DialogHeader>
<fieldset disabled={isSubmitting}>
{method === 'totp' && (
<FormField
control={form.control}
name={totpCodeName}
render={({ field }) => (
<FormItem>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6} autoFocus>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'backup' && (
<FormField
control={form.control}
name={backupCodeName}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" autoComplete="off" autoFocus {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={onToggleMethod}>
{method === 'totp' ? <Trans>Use Backup Code</Trans> : <Trans>Use Authenticator</Trans>}
</Button>
<Button type="button" loading={isSubmitting} onClick={onSubmit}>
{submitLabel}
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,53 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CheckIcon } from 'lucide-react';
import { match } from 'ts-pattern';
/**
* Compact "send me a setup link" button that reports via toast, for settings
* cards where the surrounding layout provides the explanation.
*/
export const PasswordSetupRequestButton = () => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({
onSuccess: () => {
toast({
title: _(msg`Check your email`),
description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`),
duration: 5000,
});
},
onError: (errorCode) => {
toast({
title: _(msg`An error occurred`),
description: match(errorCode)
.with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`))
.otherwise(() => _(msg`We were unable to send the email. Please try again later.`)),
variant: 'destructive',
});
},
});
if (isSuccess) {
return (
<Button variant="outline" className="flex-shrink-0 bg-background" disabled>
<CheckIcon className="mr-2 h-4 w-4" />
<Trans>Link sent</Trans>
</Button>
);
}
return (
<Button variant="outline" className="flex-shrink-0 bg-background" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
);
};
@@ -0,0 +1,61 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
export type PasswordSetupRequestProps = {
className?: string;
};
/**
* Inline "send me a setup link" control with its own sent/error states, for
* contexts like dialogs where a toast would be missed.
*/
export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => {
const { _ } = useLingui();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest();
if (isSuccess) {
return (
<Alert className={className} variant="neutral">
<AlertTitle>
<Trans>Check your email</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue.
</Trans>
</AlertDescription>
</Alert>
);
}
return (
<div className={className}>
{errorCode && (
<Alert className="mb-4" variant="destructive">
<AlertTitle>
<Trans>An error occurred</Trans>
</AlertTitle>
<AlertDescription>
{match(errorCode)
.with('SIGNIN_DISABLED', () =>
_(msg`Password sign in is disabled for this instance. Please contact support.`),
)
.otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))}
</AlertDescription>
</Alert>
)}
<Button type="button" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
</div>
);
};
+59 -18
View File
@@ -1,6 +1,6 @@
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -11,20 +11,21 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import type { z } from 'zod';
export const ZPasswordFormSchema = z
.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog';
export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
}).refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
@@ -33,29 +34,51 @@ export type PasswordFormProps = {
user: SessionUser;
};
export const PasswordForm = ({ className }: PasswordFormProps) => {
export const PasswordForm = ({ className, user }: PasswordFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false);
const form = useForm<TPasswordFormSchema>({
values: {
currentPassword: '',
password: '',
repeatedPassword: '',
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
const onFormSubmit = async (values: TPasswordFormSchema) => {
const { currentPassword, password, totpCode, backupCode } = values;
// Collect the 2FA code in a dialog once the password fields are valid.
if (user.twoFactorEnabled && !hasTwoFactorCode(values)) {
if (isTwoFactorDialogOpen) {
const message = _(msg`A code is required`);
form.setError('totpCode', { message });
form.setError('backupCode', { message });
}
setIsTwoFactorDialogOpen(true);
return;
}
try {
await authClient.emailPassword.updatePassword({
currentPassword,
password,
totpCode: totpCode || undefined,
backupCode: backupCode || undefined,
});
form.reset();
setIsTwoFactorDialogOpen(false);
toast({
title: _(msg`Password updated`),
@@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('NO_PASSWORD', () => msg`User has no password.`)
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
.with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`)
.with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`)
.with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`)
.with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`)
.with(
AppErrorCode.INCORRECT_TWO_FACTOR_CODE,
AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS,
() => msg`The two factor code you provided is invalid. Please try again.`,
)
.otherwise(
() => msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
);
@@ -83,7 +111,12 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
return (
<Form {...form}>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
{/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */}
<form
method="post"
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
@@ -140,6 +173,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
</Button>
</div>
</form>
<TwoFactorCodeDialog<TPasswordFormSchema>
open={isTwoFactorDialogOpen}
onOpenChange={setIsTwoFactorDialogOpen}
isSubmitting={isSubmitting}
submitLabel={<Trans>Update password</Trans>}
onSubmit={form.handleSubmit(onFormSubmit)}
/>
</Form>
);
};
+2 -1
View File
@@ -1,5 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { formatPath } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => {
return message;
};
const LOGIN_REDIRECT_PATH = '/';
const LOGIN_REDIRECT_PATH = formatPath('/');
export const ZSignInFormSchema = z.object({
email: zEmail().min(1),
@@ -0,0 +1,371 @@
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import { SearchIcon, UsersIcon } from 'lucide-react';
import type { MouseEvent, ReactNode } from 'react';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { formatRelativeDate } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */
export type AnalyticsActivityRow = {
key: string | number;
avatar: {
imageId: string | null;
fallback: string;
};
title: string;
subtitle?: string | null;
sent: number;
completed: number;
pending: number;
/** 0-100, null when nothing was sent. */
completionRate: number | null;
lastActiveAt: Date | null;
/** When set the whole row navigates here and the title becomes a link. */
href?: string;
};
export type AnalyticsActivityTableCardProps = {
query: AnalyticsQueryResult<unknown>;
/** Rows derived from `query.data`; empty while loading. */
rows: AnalyticsActivityRow[];
/** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */
rangeKey: string;
title: ReactNode;
description: ReactNode;
/** Header of the first column, e.g. "Member". */
columnLabel: ReactNode;
/** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */
renderSummary: (count: number, activeCount: number) => ReactNode;
/** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */
renderShowing: (visibleCount: number, totalCount: number) => ReactNode;
emptyLabel: ReactNode;
emptyIcon?: LucideIcon;
/** Placeholder for the search input, e.g. "Search members". */
searchPlaceholder: string;
/** Rendered when the search matches nothing, e.g. "No members match your search". */
noSearchResultsLabel: ReactNode;
/** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */
testIdPrefix: string;
className?: string;
};
export const AnalyticsActivityTableCard = ({
query,
rows,
rangeKey,
title,
description,
columnLabel,
renderSummary,
renderShowing,
emptyLabel,
emptyIcon: EmptyIcon = UsersIcon,
searchPlaceholder,
noSearchResultsLabel,
testIdPrefix,
className,
}: AnalyticsActivityTableCardProps) => {
const { i18n } = useLingui();
// Tracks which window "Show all" was pressed for, so it resets whenever the window changes.
const [expandedRangeKey, setExpandedRangeKey] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const isExpanded = expandedRangeKey === rangeKey;
const { data, isLoading, isError, refetch } = query;
const activeCount = rows.filter((row) => row.sent > 0).length;
const normalisedSearchTerm = searchTerm.trim().toLowerCase();
const isSearching = normalisedSearchTerm.length > 0;
// Search always shows every match; the preview limit only applies to the unfiltered list.
const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows;
const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT);
const hasHiddenRows = filteredRows.length > visibleRows.length;
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
return (
<Card className={className} data-testid={testId('activity')}>
<CardHeader className="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col space-y-1.5">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
{data !== undefined && rows.length > 0 && (
<p className="shrink-0 text-muted-foreground text-sm tabular-nums" data-testid={testId('summary')}>
{renderSummary(rows.length, activeCount)}
</p>
)}
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || data === undefined ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 4 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-10" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="h-4 w-28" />
<Skeleton className="hidden h-4 w-20 md:block" />
</li>
))}
</ul>
) : rows.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<EmptyIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">{emptyLabel}</p>
</div>
) : (
<div className="flex flex-col gap-y-3">
<div className="relative sm:max-w-xs">
<SearchIcon
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
className="pl-9"
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
data-testid={testId('search')}
/>
</div>
{filteredRows.length === 0 ? (
<p className="py-8 text-center text-muted-foreground text-sm" data-testid={testId('no-results')}>
{noSearchResultsLabel}
</p>
) : (
<>
{/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */}
<div className="-mx-6">
<Table className="[&_td]:px-2 md:[&_td]:px-4 [&_th]:px-2 md:[&_th]:px-4">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={FIRST_CELL_CLASS}>{columnLabel}</TableHead>
<TableHead className="text-right">
<Trans>Sent</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Completed</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Pending</Trans>
</TableHead>
<TableHead className={cn('text-right', LAST_CELL_ON_MOBILE_CLASS)}>
<Trans>Completion rate</Trans>
</TableHead>
<TableHead className={cn('hidden text-right md:table-cell', LAST_CELL_CLASS)}>
<Trans>Last active</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<ActivityRow key={row.key} row={row} locale={i18n.locale} testIdPrefix={testIdPrefix} />
))}
</TableBody>
</Table>
</div>
{hasHiddenRows && (
<div className="flex items-center justify-between gap-x-4 border-border border-t pt-3">
<p className="text-muted-foreground text-sm" data-testid={testId('showing')}>
{renderShowing(visibleRows.length, filteredRows.length)}
</p>
<Button
variant="ghost"
size="sm"
className="-mr-2"
onClick={() => setExpandedRangeKey(rangeKey)}
data-testid={testId('show-all')}
>
<Trans>Show all</Trans>
</Button>
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
};
type ActivityRowProps = {
row: AnalyticsActivityRow;
locale: string;
testIdPrefix: string;
};
const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => {
const { _ } = useLingui();
const navigate = useNavigate();
const isActive = row.sent > 0;
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
// Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout.
const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground');
const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums';
// role="img" so the aria-label is valid (a bare span has no role that supports it).
const notAvailable = (
<span role="img" aria-label={_(msg`Not available`)}>
—
</span>
);
/**
* The title link is the accessible target; clicking anywhere else on the row
* navigates too. Modifier clicks and clicks on the link itself are left to the
* browser so open-in-new-tab keeps working, and drag-selecting text does not
* navigate.
*/
const handleRowClick = (event: MouseEvent<HTMLTableRowElement>) => {
if (!row.href || event.defaultPrevented || event.button !== 0) {
return;
}
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
if (event.target instanceof Element && event.target.closest('a')) {
return;
}
if (window.getSelection()?.toString()) {
return;
}
void navigate(row.href);
};
return (
<TableRow
className={cn(row.href && 'cursor-pointer')}
onClick={handleRowClick}
data-testid={testId('row')}
data-active={isActive ? 'true' : 'false'}
>
{/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */}
<TableCell truncate={false} className={cn('w-full max-w-0', FIRST_CELL_CLASS)}>
<div className="flex min-w-0 items-center gap-x-3">
<Avatar className="h-9 w-9 shrink-0">
{row.avatar.imageId && <AvatarImage src={formatAvatarUrl(row.avatar.imageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{row.avatar.fallback}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
{row.href ? (
<Link
to={row.href}
className={cn(
'truncate font-medium text-sm hover:underline',
isActive ? 'text-foreground' : 'text-foreground/80',
)}
>
{row.title}
</Link>
) : (
<span className={cn('truncate font-medium text-sm', isActive ? 'text-foreground' : 'text-foreground/80')}>
{row.title}
</span>
)}
{row.subtitle && <span className="truncate text-muted-foreground text-xs">{row.subtitle}</span>}
</div>
</div>
</TableCell>
<TableCell className={secondaryNumberClass} data-testid={testId('sent')}>
{row.sent.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', primaryNumberClass)} data-testid={testId('completed')}>
{row.completed.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', secondaryNumberClass)} data-testid={testId('pending')}>
{row.pending.toLocaleString(locale)}
</TableCell>
<TableCell className={cn(primaryNumberClass, LAST_CELL_ON_MOBILE_CLASS)} data-testid={testId('completion-rate')}>
{row.completionRate === null ? (
notAvailable
) : (
<div className="flex items-center justify-end gap-x-2">
<div className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:block" aria-hidden="true">
<div className="h-full rounded-full bg-primary" style={{ width: `${row.completionRate}%` }} />
</div>
<span className="w-9 text-right">{Math.round(row.completionRate)}%</span>
</div>
)}
</TableCell>
<TableCell
className={cn('hidden md:table-cell', secondaryNumberClass, LAST_CELL_CLASS)}
data-testid={testId('last-active')}
>
{row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)}
</TableCell>
</TableRow>
);
};
const ROW_PREVIEW_LIMIT = 8;
const matchesSearch = (row: AnalyticsActivityRow, term: string) => {
return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term);
};
/**
* The table is pulled out to the card edge (-mx-6), so the outer cells get the
* card's px-6 gutter to line up with the header. "Last active" is hidden below
* md, so "Completion rate" takes the right gutter there.
*/
const FIRST_CELL_CLASS = '!pl-6';
const LAST_CELL_CLASS = '!pr-6';
const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4';
@@ -0,0 +1,203 @@
import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { BarChart3Icon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics';
import { getAnalyticsDateRangeDays } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsDocumentsOverTimeCardProps = {
range: AnalyticsRangeValue;
query: AnalyticsQueryResult<TGetTeamAnalyticsDocumentsOverTimeResponse>;
className?: string;
};
type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket'];
export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
// The backend decides the bucket, and it must match the points being rendered so
// the tick and tooltip formatting line up. Before data arrives it is guessed from
// the requested range.
const bucket: Bucket = data ? data.range.bucket : guessBucket(range);
const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0;
return (
<Card className={cn('flex flex-col', className)} data-testid="analytics-documents-over-time">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Documents created</Trans>
</CardTitle>
<CardDescription>{bucket === 'month' ? <Trans>Monthly</Trans> : <Trans>Daily</Trans>}</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap" data-testid="analytics-documents-over-time-total">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
{/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */}
<CardContent className="flex flex-1 flex-col justify-center">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<Skeleton className="w-full" style={{ height: CHART_HEIGHT }} />
) : data.total === 0 ? (
<div
className="flex flex-col items-center justify-center gap-y-3 text-center"
style={{ height: CHART_HEIGHT }}
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<BarChart3Icon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="text-muted-foreground text-sm">
<Trans>No documents created in this period</Trans>
</p>
</div>
) : (
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
<BarChart data={data.points} margin={{ top: 8, right: 0, bottom: 0, left: 0 }} barCategoryGap="20%">
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
interval={tickInterval}
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={(value: string) => formatTickLabel(value, bucket, i18n.locale)}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={36}
tickCount={4}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
/>
<Tooltip
content={<DocumentsOverTimeTooltip bucket={bucket} locale={i18n.locale} />}
cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={28}
background={{ fill: 'hsl(var(--muted) / 0.5)', radius: 4 }}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
};
type DocumentsOverTimeTooltipProps = {
active?: boolean;
payload?: Array<{ payload: { date: string; count: number } }>;
bucket: Bucket;
locale: string;
};
const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => {
const point = payload?.[0]?.payload;
if (!active || !point) {
return null;
}
const count = Number(point.count ?? 0);
return (
<div className="rounded-md border border-border bg-popover px-3 py-2 text-popover-foreground text-sm shadow-md">
<p className="text-muted-foreground text-xs">{formatTooltipLabel(point.date, bucket, locale)}</p>
<p className="mt-0.5 font-medium tabular-nums">
<Plural value={count} one="# document" other="# documents" />
</p>
</div>
);
};
const CHART_HEIGHT = 240;
const TARGET_DAILY_TICK_COUNT = 6;
/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */
const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92;
const guessBucket = (range: AnalyticsRangeValue): Bucket => {
if (range.range === '12m') {
return 'month';
}
if (range.range === 'custom') {
return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day';
}
return 'day';
};
/**
* Month buckets label every month (12 fit at the lg width) and let recharts drop
* overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels.
*/
const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => {
if (bucket === 'month') {
return 'preserveStartEnd';
}
if (pointCount <= TARGET_DAILY_TICK_COUNT) {
return 0;
}
return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1);
};
const formatTickLabel = (date: string, bucket: Bucket, locale: string) => {
const parsed = DateTime.fromISO(date).setLocale(locale);
if (bucket === 'month') {
return parsed.toLocaleString({ month: 'short' });
}
return parsed.toLocaleString({ month: 'short', day: 'numeric' });
};
const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => {
const parsed = DateTime.fromISO(date).setLocale(locale);
if (bucket === 'month') {
return parsed.toLocaleString({ month: 'long', year: 'numeric' });
}
return parsed.toLocaleString(DateTime.DATE_FULL);
};
@@ -0,0 +1,17 @@
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
/**
* Shown while the analytics route's `clientLoader` resolves the browser timezone
* during hydration.
*/
export const AnalyticsHydrateFallback = () => {
return (
<div role="status" aria-live="polite" data-testid="analytics-loading">
<SpinnerBox />
<span className="sr-only">
<Trans>Loading analytics</Trans>
</span>
</div>
);
};
@@ -0,0 +1,45 @@
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics';
export type AnalyticsNoActivityAlertProps = {
range: TTeamAnalyticsRange;
/** Invoked when the user asks to widen the range to the last 12 months. */
onShowLastYear: () => void;
};
export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => {
const { _ } = useLingui();
const canWidenRange = range !== '12m';
return (
<Alert variant="neutral" padding="tight" className="mt-6" data-testid="analytics-no-activity">
<AlertDescription className="flex min-h-9 flex-wrap items-center justify-between gap-x-4 gap-y-2">
<span className="flex items-center gap-x-2">
<InfoIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>
{_(ANALYTICS_NO_ACTIVITY_LABELS[range])}
{canWidenRange && (
<>
{' '}
<Trans>Try a longer range.</Trans>
</>
)}
</span>
</span>
{canWidenRange && (
<Button variant="ghost" size="sm" className="-mr-2" onClick={onShowLastYear}>
<Trans>Show last 12 months</Trans>
</Button>
)}
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,214 @@
import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { AnalyticsStatCard } from './analytics-stat-card';
/** The part of the overview response shared by the team and organisation procedures. */
export type AnalyticsOverviewData = Pick<TGetTeamAnalyticsOverviewResponse, 'sent' | 'completionRate'>;
/**
* The third card counts the scope's "entities" (team members, organisation teams)
* and how many of them were active in the period.
*/
export type AnalyticsOverviewEntityCard<TData> = {
icon: LucideIcon;
title: ReactNode;
/** Applied to the value element, e.g. `analytics-members`. */
testId: string;
select: (data: TData) => { active: number; total: number };
};
export type AnalyticsOverviewCardsProps<TData extends AnalyticsOverviewData> = {
query: AnalyticsQueryResult<TData>;
entity: AnalyticsOverviewEntityCard<TData>;
};
export const AnalyticsOverviewCards = <TData extends AnalyticsOverviewData>({
query,
entity,
}: AnalyticsOverviewCardsProps<TData>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
const entityCounts = data ? entity.select(data) : null;
const formatNumber = (value: number) => value.toLocaleString(i18n.locale);
const sharedProps = {
isLoading: isLoading || !data,
isError,
onRetry: refetch,
};
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<AnalyticsStatCard
{...sharedProps}
icon={SendIcon}
title={<Trans>Documents sent</Trans>}
value={data ? formatNumber(data.sent.current) : null}
badge={data ? <SentDeltaBadge current={data.sent.current} previous={data.sent.previous} /> : null}
description={<Trans>vs. previous period</Trans>}
testId="analytics-sent"
/>
<AnalyticsStatCard
{...sharedProps}
icon={CircleCheckIcon}
title={<Trans>Completion rate</Trans>}
value={data ? formatRate(data.completionRate.rate) : null}
badge={
data ? (
<CompletionRateDeltaBadge rate={data.completionRate.rate} previousRate={data.completionRate.previousRate} />
) : null
}
description={<Trans>of sent documents completed</Trans>}
testId="analytics-completion-rate"
/>
<AnalyticsStatCard
{...sharedProps}
icon={entity.icon}
title={entity.title}
value={entityCounts ? `${formatNumber(entityCounts.active)}/${formatNumber(entityCounts.total)}` : null}
description={
entityCounts ? (
<Trans>
{formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '}
inactive
</Trans>
) : null
}
testId={entity.testId}
/>
</div>
);
};
type SentDeltaBadgeProps = {
current: number;
previous: number;
};
const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => {
if (previous === 0 && current === 0) {
return null;
}
if (previous === 0) {
return (
<DeltaBadge tone="new" testId="analytics-sent-delta">
<Trans>New</Trans>
</DeltaBadge>
);
}
const delta = Math.round(((current - previous) / previous) * 100);
// Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display.
const label =
delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`;
return (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-sent-delta">
{label}
</DeltaBadge>
);
};
type CompletionRateDeltaBadgeProps = {
rate: number | null;
previousRate: number | null;
};
const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => {
if (rate === null || previousRate === null) {
return null;
}
// Compare the rounded values so the delta always agrees with the displayed rate.
const delta = Math.round(rate) - Math.round(previousRate);
return (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-completion-rate-delta">
{formatSignedNumber(delta)}%
</DeltaBadge>
);
};
type DeltaTone = 'positive' | 'negative' | 'zero' | 'new';
type DeltaBadgeProps = {
tone: DeltaTone;
testId: string;
children: ReactNode;
};
const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => {
const DeltaIcon = DELTA_TONE_ICONS[tone];
return (
<span
className={cn(
'inline-flex items-center gap-x-0.5 rounded-full px-1.5 py-0.5 font-medium text-xs tabular-nums leading-none',
DELTA_TONE_CLASSES[tone],
)}
data-testid={testId}
>
{DeltaIcon && <DeltaIcon className="-ml-0.5 h-3 w-3" aria-hidden="true" />}
{children}
</span>
);
};
const DELTA_TONE_CLASSES: Record<DeltaTone, string> = {
positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
negative: 'bg-red-500/10 text-red-600 dark:text-red-400',
zero: 'bg-muted text-muted-foreground',
new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
};
const MAX_DISPLAYED_DELTA_PERCENT = 999;
const DELTA_TONE_ICONS: Record<DeltaTone, typeof ArrowUpRightIcon | null> = {
positive: ArrowUpRightIcon,
negative: ArrowDownRightIcon,
zero: null,
new: null,
};
const formatRate = (rate: number | null) => {
if (rate === null) {
return '—';
}
return `${Math.round(rate)}%`;
};
const formatSignedNumber = (value: number) => {
if (value > 0) {
return `+${value}`;
}
return String(value);
};
const getDeltaTone = (delta: number): DeltaTone => {
if (delta > 0) {
return 'positive';
}
if (delta < 0) {
return 'negative';
}
return 'zero';
};
@@ -0,0 +1,67 @@
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ReactNode } from 'react';
import type { AnalyticsRangeValue } from '~/utils/analytics';
import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics';
import { AnalyticsRangePicker } from './analytics-range-picker';
export type AnalyticsPageHeaderProps = {
avatarImageId: string | null;
/** The team or organisation name. */
name: string;
range: AnalyticsRangeValue;
onRangeChange: (range: AnalyticsRangeValue) => void;
/** Rendered before the range picker, e.g. a link to a related analytics page. */
actions?: ReactNode;
className?: string;
};
export const AnalyticsPageHeader = ({
avatarImageId,
name,
range,
onRangeChange,
actions,
className,
}: AnalyticsPageHeaderProps) => {
const { _, i18n } = useLingui();
const rangeLabel =
range.range === 'custom'
? formatAnalyticsDateRange(range.from, range.to, i18n.locale)
: _(ANALYTICS_RANGE_LABELS[range.range]);
return (
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div>
<h2 className="font-semibold text-4xl">
<Trans>Analytics</Trans>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Usage overview for {name} · {rangeLabel}
</Trans>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{actions}
<AnalyticsRangePicker value={range} onValueChange={onRangeChange} />
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type AnalyticsQueryErrorProps = {
onRetry: () => Promise<unknown>;
className?: string;
};
export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => {
const [isRetrying, setIsRetrying] = useState(false);
const handleRetry = async () => {
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
};
return (
<Alert variant="neutral" padding="tight" className={className} data-testid="analytics-error">
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
<span>
<Trans>This data could not be loaded.</Trans>
</span>
<Button variant="outline" size="sm" onClick={() => void handleRetry()} loading={isRetrying}>
<Trans>Retry</Trans>
</Button>
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,264 @@
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Button } from '@documenso/ui/primitives/button';
import type { CalendarProps } from '@documenso/ui/primitives/calendar';
import { Calendar } from '@documenso/ui/primitives/calendar';
import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { useRef, useState } from 'react';
import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics';
import {
ANALYTICS_PRESET_RANGES,
ANALYTICS_RANGE_LABELS,
formatAnalyticsDate,
formatAnalyticsDateRange,
getAnalyticsDateRangeDays,
} from '~/utils/analytics';
export type AnalyticsRangePickerProps = {
value: AnalyticsRangeValue;
onValueChange: (value: AnalyticsRangeValue) => void;
};
/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */
type DraftRange = {
from: Date | undefined;
to: Date | undefined;
};
/** A single react-day-picker matcher, e.g. `{ after: Date }`. */
type DayMatcher = Exclude<CalendarProps['disabled'], undefined | unknown[]>;
/**
* A preset select with a "Custom range…" item that opens a two month range
* calendar anchored to the select. The custom window is only committed when
* "Apply" is pressed.
*/
export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => {
const { _, i18n } = useLingui();
const { width } = useWindowSize();
const triggerRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [draft, setDraft] = useState<DraftRange | undefined>();
const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1;
const today = DateTime.local().startOf('day');
const openPicker = () => {
setDraft(
value.range === 'custom'
? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() }
: undefined,
);
setIsPickerOpen(true);
};
const closePicker = () => {
setIsPickerOpen(false);
setDraft(undefined);
};
const handleSelectValueChange = (nextValue: string) => {
if (nextValue === CUSTOM_RANGE_VALUE) {
openPicker();
return;
}
const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue);
if (!preset) {
return;
}
onValueChange({ range: preset });
};
/**
* Picking a day starts a new window unless one end is already pending, in which
* case it completes it. This replaces react-day-picker's default, which extends
* a completed window instead of starting over.
*/
const handleDaySelect = (_nextRange: unknown, day: Date) => {
if (draft?.from && !draft.to) {
setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day });
return;
}
setDraft({ from: day, to: undefined });
};
const handleApply = () => {
if (!draft?.from || !draft.to) {
return;
}
onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) });
closePicker();
};
// Only the last year (plus a day) up to today is selectable.
const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }];
// Open on the month of the pending window (or today), keeping the current month
// as the right-most one so no fully disabled future month is shown.
const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month');
const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 });
const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate();
const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null;
const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null;
const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0;
const customLabel =
value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined;
return (
<Popover
open={isPickerOpen}
onOpenChange={(open) => {
if (!open) {
closePicker();
}
}}
>
{/*
* The select never holds "custom" as its value so choosing "Custom range…" always
* fires a change, letting an active custom window be adjusted. The trigger shows
* the formatted window through the placeholder instead.
*/}
<Select value={value.range === 'custom' ? '' : value.range} onValueChange={handleSelectValueChange}>
<PopoverAnchor asChild>
<SelectTrigger
ref={triggerRef}
className="w-full sm:w-auto sm:min-w-44"
aria-label={_(msg`Date range`)}
data-testid="analytics-range"
>
<SelectValue placeholder={customLabel} />
</SelectTrigger>
</PopoverAnchor>
<SelectContent position="popper">
{ANALYTICS_PRESET_OPTIONS.map(({ value: optionValue, label }) => (
<SelectItem key={optionValue} value={optionValue}>
{_(label)}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={CUSTOM_RANGE_VALUE} data-testid="analytics-range-custom">
<Trans>Custom range…</Trans>
</SelectItem>
</SelectContent>
</Select>
<PopoverContent
ref={contentRef}
align="end"
className="w-auto p-0"
// The select refocuses its trigger (asynchronously) as it closes, which
// would otherwise dismiss the popover that has just opened and strand
// keyboard focus outside it. Pointer interaction with the trigger still
// dismisses the popover so the select can be reopened.
onFocusOutside={(event) => {
if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) {
return;
}
event.preventDefault();
const content = contentRef.current;
const firstTabbable = content?.querySelector<HTMLElement>(TABBABLE_SELECTOR);
(firstTabbable ?? content)?.focus();
}}
// There is no popover trigger element, so hand focus back to the select.
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.focus();
}}
>
<div data-testid="analytics-range-calendar">
<Calendar
mode="range"
selected={draft}
onSelect={handleDaySelect}
numberOfMonths={numberOfMonths}
// Adjacent months would otherwise show the same days twice.
showOutsideDays={false}
defaultMonth={defaultMonth}
fromDate={earliestDay.toJSDate()}
toDate={today.toJSDate()}
disabled={disabledDays}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2">
<p className="text-muted-foreground text-sm" aria-live="polite">
{draftFrom && draftTo ? (
<>
{formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '}
<Plural value={draftDays} one="# day" other="# days" />
</>
) : draftFrom ? (
<Trans>Pick an end date</Trans>
) : (
<Trans>Pick a start date</Trans>
)}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={closePicker}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
size="sm"
onClick={handleApply}
disabled={!draftFrom || !draftTo}
data-testid="analytics-range-apply"
>
<Trans>Apply</Trans>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
const CUSTOM_RANGE_VALUE = 'custom';
/** Tailwind `sm` breakpoint; two months are shown from here up. */
const SM_BREAKPOINT = 640;
/** First element the popover should focus: the calendar's month navigation, then the days. */
const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]';
const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({
value,
label: ANALYTICS_RANGE_LABELS[value],
}));
@@ -0,0 +1,63 @@
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsStatCardProps = {
icon: LucideIcon;
title: ReactNode;
value: ReactNode;
description: ReactNode;
badge?: ReactNode;
isLoading: boolean;
isError: boolean;
onRetry: () => Promise<unknown>;
testId: string;
};
export const AnalyticsStatCard = ({
icon: Icon,
title,
value,
description,
badge,
isLoading,
isError,
onRetry,
testId,
}: AnalyticsStatCardProps) => {
return (
<Card>
<CardContent className="flex flex-col p-5">
<div className="flex items-center justify-between gap-x-3">
<h3 className="font-medium text-muted-foreground text-sm">{title}</h3>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</div>
{isError ? (
<AnalyticsQueryError onRetry={onRetry} className="mt-3" />
) : isLoading ? (
<div className="mt-3 flex flex-col gap-y-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-3.5 w-32" />
</div>
) : (
<>
<div className="mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
<p className="font-semibold text-3xl text-foreground tabular-nums tracking-tight" data-testid={testId}>
{value}
</p>
{badge}
</div>
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
</>
)}
</CardContent>
</Card>
);
};
@@ -0,0 +1,198 @@
import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsStatusBreakdownCardProps = {
query: AnalyticsQueryResult<TGetTeamAnalyticsStatusBreakdownResponse>;
className?: string;
};
export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => {
const { _, i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : [];
return (
<Card className={cn('flex flex-col', className)} data-testid="analytics-status-breakdown">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Status breakdown</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created in this period</Trans>
</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
<CardContent className="flex flex-1 flex-col">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<div className="flex flex-col gap-y-4">
<Skeleton className="h-2.5 w-full rounded-full" />
<div className="flex flex-col gap-y-2">
{STATUS_ROWS.slice(0, 3).map((row) => (
<Skeleton key={row.key} className="h-5 w-full" />
))}
</div>
</div>
) : data.total === 0 ? (
<div className="flex flex-1 flex-col gap-y-4">
<StatusBar segments={[]} label={_(msg`No documents in this period`)} />
<p className="flex flex-1 items-center justify-center text-center text-muted-foreground text-sm">
<Trans>No documents in this period</Trans>
</p>
</div>
) : (
<div className="flex flex-col gap-y-2">
<StatusBar segments={rows} label={_(msg`Document status distribution`)} />
<ul className="flex flex-col divide-y divide-border">
{rows.map((row) => (
<li key={row.key} className="flex items-center justify-between gap-x-3 py-2.5 text-sm">
<div className="flex min-w-0 items-center gap-x-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
aria-hidden="true"
/>
<span className="truncate text-foreground">{_(row.label)}</span>
</div>
<div className="flex shrink-0 items-baseline gap-x-2 tabular-nums">
<span className="font-medium text-foreground" data-testid={`analytics-status-${row.key}`}>
{row.count.toLocaleString(i18n.locale)}
</span>
<span className="w-10 text-right text-muted-foreground">{row.percent}%</span>
</div>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
};
type StatusBarProps = {
segments: Array<{ key: string; percent: number; color: string }>;
label: string;
};
/**
* Stacked horizontal bar. Segment widths come from the largest-remainder
* percentages so they always add up to the full width; an empty list renders
* the muted track on its own.
*/
const StatusBar = ({ segments, label }: StatusBarProps) => {
return (
<div className="flex h-2.5 w-full gap-px overflow-hidden rounded-full bg-muted" role="img" aria-label={label}>
{segments.map((segment) => (
<div
key={segment.key}
className="h-full"
style={{ width: `${segment.percent}%`, backgroundColor: segment.color }}
/>
))}
</div>
);
};
type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled';
type StatusRow = {
key: StatusKey;
label: MessageDescriptor;
color: string;
};
/**
* Single source of truth for status colours so the bar and the legend cannot drift.
*/
const STATUS_ROWS: StatusRow[] = [
{ key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' },
{ key: 'pending', label: msg`Pending`, color: '#f59e0b' },
{ key: 'rejected', label: msg`Rejected`, color: '#ef4444' },
{ key: 'cancelled', label: msg`Cancelled`, color: '#f97316' },
{ key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' },
];
/**
* Assign integer percentages to the non-zero rows using largest-remainder
* allocation so the values always sum to exactly 100, with every non-zero row
* shown as at least 1%.
*/
const allocatePercentages = <T extends { count: number }>(rows: T[]): Array<T & { percent: number }> => {
const visibleRows = rows.filter((row) => row.count > 0);
const total = visibleRows.reduce((sum, row) => sum + row.count, 0);
if (total === 0) {
return [];
}
const allocations = visibleRows.map((row, index) => {
const exact = (row.count / total) * 100;
const floored = Math.floor(exact);
return { index, percent: floored, remainder: exact - floored };
});
let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0);
const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index);
for (const allocation of byRemainder) {
if (remaining <= 0) {
break;
}
allocation.percent += 1;
remaining -= 1;
}
// Every non-zero row must display at least 1%; take the difference from the largest rows.
const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index);
for (const allocation of allocations) {
if (allocation.percent > 0) {
continue;
}
allocation.percent = 1;
const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1);
if (donor) {
donor.percent -= 1;
}
}
return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent }));
};
@@ -0,0 +1,145 @@
import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { FileTextIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { formatRelativeDate } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
/** The template shape shared by the team and organisation procedures. */
export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number];
export type AnalyticsTemplateUsageCardProps<TTemplate extends AnalyticsTemplate> = {
query: AnalyticsQueryResult<{ templates: TTemplate[] }>;
/** Where the template title links to. Return null to render a plain title. */
getTemplateHref: (template: TTemplate) => string | null;
/** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */
renderTemplateMeta?: (template: TTemplate) => ReactNode;
/** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */
templatesHref?: string;
className?: string;
};
export const AnalyticsTemplateUsageCard = <TTemplate extends AnalyticsTemplate>({
query,
getTemplateHref,
renderTemplateMeta,
templatesHref,
className,
}: AnalyticsTemplateUsageCardProps<TTemplate>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
return (
<Card className={className} data-testid="analytics-template-usage">
<CardHeader>
<CardTitle>
<Trans>Template usage</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created from templates</Trans>
</CardDescription>
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 3 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-16" />
</li>
))}
</ul>
) : data.templates.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<FileTextIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">
<Trans>No documents were created from templates in this period</Trans>
</p>
{templatesHref && (
<Button variant="outline" size="sm" asChild>
<Link to={templatesHref}>
<Trans>View templates</Trans>
</Link>
</Button>
)}
</div>
) : (
<ol className="flex flex-col divide-y divide-border">
{data.templates.map((template, index) => {
const href = template.title === null ? null : getTemplateHref(template);
const meta = renderTemplateMeta?.(template);
return (
<li
key={template.id}
className="flex items-center gap-x-3 py-3 first:pt-0 last:pb-0"
data-testid="analytics-template-row"
>
<span className="w-5 shrink-0 text-muted-foreground text-xs tabular-nums" aria-hidden="true">
{index + 1}
</span>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
<FileTextIcon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
{template.title === null ? (
<span className="truncate text-muted-foreground text-sm">
<Trans>Unavailable template</Trans>
</span>
) : href !== null ? (
<Link to={href} className="truncate font-medium text-foreground text-sm hover:underline">
{template.title}
</Link>
) : (
<span className="truncate font-medium text-foreground text-sm">{template.title}</span>
)}
{(meta || template.updatedAt !== null) && (
<span className="truncate text-muted-foreground text-xs">
{meta}
{meta && template.updatedAt !== null && ' · '}
{template.updatedAt !== null && (
<Trans>Updated {formatRelativeDate(template.updatedAt, i18n.locale)}</Trans>
)}
</span>
)}
</div>
<span className="shrink-0 rounded-md border bg-muted px-2 py-0.5 font-medium text-foreground text-xs tabular-nums">
<Plural value={template.count} one="# use" other="# uses" />
</span>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
};
@@ -1,5 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatPath } from '@documenso/lib/constants/app';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
DOCUMENTS_PAGE_SHORTCUT,
@@ -16,9 +17,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { commandScore } from 'cmdk/dist/command-score';
import { defaultFilter as commandScore } from 'cmdk';
import {
ArrowLeftIcon,
CheckIcon,
@@ -595,9 +596,13 @@ 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>
@@ -862,7 +867,7 @@ const PromptLanguageCommands = ({
formData.append('lang', lang);
const response = await fetch('/api/locale', {
const response = await fetch(formatPath('/api/locale'), {
method: 'post',
body: formData,
});
@@ -1,6 +1,9 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -22,6 +25,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const { organisations } = useSession();
const currentTeam = useOptionalCurrentTeam();
const currentOrganisation = useOptionalCurrentOrganisation();
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
{
@@ -37,18 +41,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
};
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
const navigationTeam =
currentTeam ??
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
if (!navigationTeam) {
return [
{
href: '/inbox',
text: t`Inbox`,
},
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
@@ -56,6 +61,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
const teamUrl = navigationTeam.url;
return [
{
href: `/t/${teamUrl}/documents`,
@@ -69,12 +76,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: '/inbox',
text: t`Inbox`,
},
...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole)
? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
},
];
}, [currentTeam, organisations]);
}, [currentTeam, currentOrganisation, organisations, t]);
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -1,66 +0,0 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus } from '@prisma/client';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: TRecipientLite;
documentStatus: DocumentStatus;
};
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
return (
<div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken,
})}
role={signingToken ? 'button' : undefined}
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
onClick={onRecipientClick}
>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div
className="text-muted-foreground text-sm"
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</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,4 +1,5 @@
import { authClient } from '@documenso/auth/client';
import { formatPath } from '@documenso/lib/constants/app';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
await authClient.signOut({
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
redirectPath: formatPath(
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
),
});
} catch {
setIsSigningOut(false);
@@ -1,4 +1,5 @@
import { authClient } from '@documenso/auth/client';
import { formatPath } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
@@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
try {
setIsSigningOut(true);
let redirectPath = '/signin';
let redirectPath = formatPath('/signin');
if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
}
await authClient.signOut({
@@ -1,5 +1,7 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -7,11 +9,13 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password';
export type DocumentSigningAuthPasswordProps = {
open: boolean;
@@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({
}: DocumentSigningAuthPasswordProps) => {
const { t } = useLingui();
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Fetched on demand since this is only needed once the user opts for password auth.
const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, {
enabled: !!user,
});
const form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
@@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
// If the query fails we fall through to the regular password form rather than blocking.
const isPasswordSetupRequired =
!!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD);
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
@@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
@@ -79,9 +89,22 @@ export const DocumentSigningAuthPassword = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (user && isAuthMethodsPending) {
return (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (isPasswordSetupRequired) {
return <DocumentSigningAuthSetPassword onOpenChange={onOpenChange} />;
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{/* method="post" so a pre-hydration native submit can't leak the password into the URL. */}
<form method="post" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{formErrorCode && (
@@ -0,0 +1,63 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { PasswordSetupRequest } from '~/components/forms/password-setup-request';
export type DocumentSigningAuthSetPasswordProps = {
onOpenChange: (value: boolean) => void;
};
/**
* Shown in place of the password reauth form when the signed in user has no
* password (e.g. they signed up via OAuth or a passkey).
*
* Password based action auth is meant to prove more than possession of a session,
* so rather than letting the session set a password inline we send the user the
* verified reset link and ask them to come back.
*/
export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => {
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
return (
<div className="space-y-4">
{isEmailPasswordSigninEnabled ? (
<>
<Alert variant="neutral">
<AlertTitle>
<Trans>No password set</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Signing this field requires a password, but your account does not have one. We can email you a link to
set one. Once done, sign in again and return to this document to continue.
</Trans>
</AlertDescription>
</Alert>
<PasswordSetupRequest />
</>
) : (
<Alert variant="warning">
<AlertTitle>
<Trans>Password authentication unavailable</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Your account does not have a password and password sign in is disabled for this instance. Please contact
the document sender to use a different authentication method.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
};
@@ -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.`),
@@ -28,6 +28,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type DocumentEditFormProps = {
@@ -42,6 +43,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const cspNonce = useCspNonce();
const navigate = useNavigate();
@@ -473,6 +475,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddFieldsFormPartial
@@ -2,7 +2,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useQueryState } from 'nuqs';
import { useQueryStates } from 'nuqs';
import { useEffect, useState } from 'react';
import { documentsSearchParams } from '~/utils/documents-search-params';
@@ -10,16 +10,26 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
const { _ } = useLingui();
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [{ query }, setSearchParams] = useQueryStates(
{
query: documentsSearchParams.query,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
void setQuery(debouncedSearchTerm || null);
// Reset pagination so a new search never lands on an empty page.
void setSearchParams({
query: debouncedSearchTerm || null,
page: null,
});
}
}, [debouncedSearchTerm, query, setQuery]);
}, [debouncedSearchTerm, query, setSearchParams]);
return (
<Input
@@ -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 { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
@@ -25,6 +26,7 @@ import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
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';
@@ -32,6 +34,7 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -84,6 +87,7 @@ export const EnvelopeEditorFieldsPage = () => {
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
const { toast } = useToast();
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
@@ -96,6 +100,34 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
const [copiedField, setCopiedField] = useState<TLocalField | null>(null);
useHotkeys(['ctrl+c', 'meta+c'], (event) => {
// Keep the native copy when the user has selected text.
if (!selectedField || window.getSelection()?.toString()) {
return;
}
event.preventDefault();
setCopiedField(selectedField);
toast({
title: _(msg`Copied field`),
description: _(msg`Copied field to clipboard`),
});
});
useHotkeys(['ctrl+v', 'meta+v'], (event) => {
if (!copiedField) {
return;
}
event.preventDefault();
// Paste the next copy offset from the last one, so repeated pastes do not stack.
setCopiedField(editorFields.duplicateField(copiedField));
});
/**
* Debounce the fields used for overlap detection so we don't recompute on every
* small drag/resize movement, which is expensive on large field counts and can
@@ -45,6 +45,7 @@ import { isDeepEqual } from 'remeda';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
@@ -52,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const cspNonce = useCspNonce();
const { t } = useLingui();
const { toast } = useToast();
@@ -795,6 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -16,7 +16,7 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { canAccessTeamDocument, DocumentSignatureType, extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
@@ -97,7 +97,7 @@ export const ZAddSettingsFormSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
.refine((value) => value === undefined || value === '' || isHttpUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
language: z

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