Compare commits

..
190 changed files with 7682 additions and 10021 deletions
@@ -0,0 +1,56 @@
---
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
@@ -0,0 +1,56 @@
---
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
@@ -0,0 +1,56 @@
---
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.
@@ -1,61 +0,0 @@
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: v24.x
default: v22.x
runs:
using: 'composite'
-2
View File
@@ -15,7 +15,6 @@ jobs:
build_app:
name: Build App
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -33,7 +32,6 @@ 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,7 +11,6 @@ jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
actions: read
contents: read
-1
View File
@@ -8,7 +8,6 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout code
-1
View File
@@ -7,7 +7,6 @@ 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,7 +7,6 @@ on:
jobs:
label_issues:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
issues: write
steps:
-1
View File
@@ -13,7 +13,6 @@ jobs:
contents: read
pull-requests: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/labeler@v4
with:
-2
View File
@@ -14,7 +14,6 @@ jobs:
build_and_publish_platform_containers:
name: Build and publish platform containers
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -79,7 +78,6 @@ 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,7 +15,6 @@ 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,7 +7,6 @@ on:
jobs:
stale:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
issues: write
pull-requests: write
@@ -18,7 +18,6 @@ jobs:
pull_translations:
name: Force pull translations
runs-on: ubuntu-latest
timeout-minutes: 10
environment: Translations
permissions:
contents: write
-1
View File
@@ -16,7 +16,6 @@ jobs:
pull_translations:
name: Pull translations
runs-on: ubuntu-latest
timeout-minutes: 10
environment: Translations
permissions:
contents: write
@@ -14,7 +14,6 @@ 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
+15 -31
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 (v24 or above)
- Node.js (v22 or above)
- Postgres SQL Database
- Docker (optional)
@@ -186,37 +186,21 @@ For full instructions, requirements, and configuration details, see the [Self Ho
### One-Click Deploys
> [!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.
#### Railway
<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>
[![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)
## Security
@@ -28,62 +28,35 @@ 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`) |
| `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.
| 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 |
### Example Document Object
```json
{
"id": "envelope_abc123xyz",
"secondaryId": "document_123",
"internalVersion": 2,
"type": "DOCUMENT",
"status": "PENDING",
"source": "DOCUMENT",
"source": "API",
"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,
@@ -100,41 +73,23 @@ Documents created through the API have `source: "DOCUMENT"` — there is no sepa
],
"fields": [
{
"id": 123,
"secondaryId": "field_abc123",
"id": "field_123",
"type": "SIGNATURE",
"recipientId": 1,
"envelopeId": "envelope_abc123xyz",
"envelopeItemId": "envelope_item_xyz",
"page": 1,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": null
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5,
"recipientId": 1
}
],
"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.",
@@ -144,8 +99,6 @@ Documents created through the API have `source: "DOCUMENT"` — there is no sepa
}
```
Field position and size values are stored as decimals and serialized as strings in API responses.
## List Documents
Retrieve a paginated list of documents.
@@ -161,7 +114,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`, `CANCELLED` |
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` |
| `source` | string | Filter by creation source |
| `folderId` | string | Filter by folder ID |
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
@@ -201,8 +154,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
},
});
const { data, count } = await response.json();
console.log(`Found ${count} documents`);
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} documents`);
// Filter by status
const pendingResponse = await fetch(
@@ -244,10 +197,12 @@ const pendingDocs = await pendingResponse.json();
]
}
],
"count": 42,
"currentPage": 1,
"perPage": 10,
"totalPages": 5
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 5,
"totalItems": 42
}
}
```
@@ -673,72 +628,6 @@ 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.
@@ -781,7 +670,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
const { success } = await response.json();
```
````
</Tab>
</Tabs>
@@ -791,7 +680,7 @@ const { success } = await response.json();
{
"success": true
}
```
````
---
@@ -805,11 +694,9 @@ POST /envelope/get-many
### Request Body
| 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` |
| Field | Type | Required | Description |
| ------------- | ----- | -------- | --------------------- |
| `envelopeIds` | array | Yes | Array of document IDs |
### Code Examples
@@ -820,17 +707,12 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"ids": {
"type": "envelopeId",
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
}
"envelopeIds": ["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: {
@@ -838,36 +720,16 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: {
type: 'envelopeId',
ids: requestedIds,
},
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
}),
});
const { data } = await response.json();
const documents = 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
@@ -878,7 +740,6 @@ The endpoint silently omits envelopes you cannot access instead of returning `40
| `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
@@ -886,13 +747,11 @@ The endpoint silently omits envelopes you cannot access instead of returning `40
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.
@@ -914,8 +773,8 @@ flowchart LR
| Parameter | Values | Description |
| ---------- | ------------------------------------------- | ------------------------- |
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
| `folderId` | string | Filter by folder |
### Sorting
@@ -941,10 +800,10 @@ async function getAllPendingDocuments() {
},
);
const { data, currentPage, totalPages } = await response.json();
const { data, pagination } = await response.json();
documents.push(...data);
hasMore = currentPage < totalPages;
hasMore = page < pagination.totalPages;
page++;
}
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
| ------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v2/document` | `GET /api/v2/envelope` |
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) |
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
| `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` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) |
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
| `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,12 +11,6 @@ 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
@@ -25,7 +19,7 @@ The rate limit applies to:
this value, in which case you can be rate-limited before reaching the global limit.
</Callout>
### Global per-IP 429 Response
### Rate Limit Response
```json
{
@@ -33,22 +27,10 @@ The rate limit applies to:
}
```
### 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).
<Callout type="warn">
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying.
</Callout>
## Resource Limits
@@ -62,55 +44,24 @@ 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 |
### Organisation Limit 429 Responses
### Error Response
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:
When you exceed a resource limit:
```json
{
"message": "Too many requests, please try again later. Contact support if you require higher limits."
"error": "You have reached your document limit for this month. Please upgrade your plan.",
"code": "LIMIT_EXCEEDED",
"statusCode": 400
}
```
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 | 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.
| Code | Status | Description |
| ------------------- | ------ | ----------------------------- |
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
---
@@ -95,7 +95,7 @@ Documents created with a team token belong to that team:
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "DOCUMENT",
@@ -157,11 +157,11 @@ Retrieve all documents belonging to the team:
```bash
# List all team documents
curl -X GET "https://app.documenso.com/api/v2/envelope" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -191,7 +191,7 @@ Templates created with a team token are shared across the team.
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/template/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"title": "NDA Template",
@@ -269,7 +269,7 @@ console.log('Created team template:', template.id);
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -1000,12 +1000,9 @@ async function fetchWithRetry(
// Retry on rate limit
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
// 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);
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
console.log(`Rate limited, waiting ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
continue;
}
@@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| Variable | Description | Default |
| ------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
| `PORT` | Port the application listens on | `3000` |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — 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_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` |
| `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,7 +136,6 @@ 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" \
@@ -155,12 +154,6 @@ 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">
@@ -185,7 +178,6 @@ 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
@@ -211,12 +203,6 @@ 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 24 or later
- npm 11.17 or later
- Node.js 22 or later
- npm 11 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 | 24+ |
| npm | 11.17+ |
| Node.js | 22+ |
| npm | 11+ |
---
@@ -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 < 24 | Modern JavaScript features required |
| Node.js < 22 | 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** | 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. |
| **Delegate Document Ownership** | Allow team API tokens to delegate document 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.
+5 -4
View File
@@ -10,12 +10,13 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"fumadocs-core": "16.14.3",
"fumadocs-mdx": "15.2.3",
"fumadocs-ui": "16.14.3",
"@radix-ui/react-tabs": "^1.1.13",
"fumadocs-core": "16.5.0",
"fumadocs-mdx": "14.2.6",
"fumadocs-ui": "16.5.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.3.0",
"next": "16.2.6",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
+2 -2
View File
@@ -12,11 +12,11 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.3.0"
"next": "16.2.6"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19.2.17",
"@types/react": "18.3.27",
"typescript": "5.6.2"
}
}
+25
View File
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,26 @@
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,4 +1,3 @@
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';
@@ -72,7 +71,6 @@ 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);
@@ -202,12 +200,6 @@ 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,4 +1,3 @@
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';
@@ -52,7 +51,6 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
const { toast } = useToast();
const { t, i18n } = useLingui();
const analytics = useAnalytics();
const [isOpen, setIsOpen] = useState(false);
@@ -97,13 +95,6 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'redistribute_document',
envelopeId: envelope.id,
});
const errorMessage = getDistributeErrorMessage(error.code);
toast({
@@ -290,11 +290,10 @@ export const EnvelopesBulkDownloadDialog = ({
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<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."
/>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
</AlertDescription>
</Alert>
)}
@@ -1,7 +1,4 @@
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 {
@@ -20,9 +17,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -34,8 +29,6 @@ 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 }>;
@@ -49,9 +42,6 @@ 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: {
@@ -61,20 +51,6 @@ 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`]);
@@ -95,44 +71,36 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};
const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);
try {
const csv = await values.file.text();
const result = await uploadBulkSend({
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);
const error = AppError.parseError(err);
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -206,10 +174,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -230,11 +195,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -257,67 +218,6 @@ 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>
The CSV contains {rowCount} rows. A maximum of {maxRows} 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"
@@ -340,12 +240,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Trans>Cancel</Trans>
</Button>
@@ -1,4 +1,3 @@
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';
@@ -78,7 +77,6 @@ export const EmbedDirectTemplateClientPage = ({
}: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [searchParams] = useSearchParams();
@@ -266,13 +264,6 @@ 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),
@@ -317,14 +308,6 @@ export const EmbedDirectTemplateClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
envelopeId,
});
setHasFinishedInit(true);
}
@@ -1,8 +1,6 @@
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';
@@ -34,7 +32,6 @@ 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';
@@ -76,7 +73,6 @@ export const EmbedSignDocumentV1ClientPage = ({
}: EmbedSignDocumentV1ClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
@@ -156,14 +152,6 @@ 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(
{
@@ -174,12 +162,9 @@ export const EmbedSignDocumentV1ClientPage = ({
);
}
const error = AppError.parseError(err);
const toastMessage = getSigningCompletionErrorMessage(error.code);
toast({
title: _(toastMessage.title),
description: _(toastMessage.description),
title: _(msg`Something went wrong`),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
variant: 'destructive',
});
}
@@ -246,15 +231,6 @@ export const EmbedSignDocumentV1ClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
documentId,
envelopeId,
});
setHasFinishedInit(true);
}
@@ -1,4 +1,3 @@
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';
@@ -26,7 +25,6 @@ export const EmbedSignDocumentV2ClientPage = ({
allowWhitelabelling = false,
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const analytics = useAnalytics();
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
useRequiredEnvelopeSigningContext();
@@ -172,14 +170,6 @@ 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,4 +1,3 @@
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';
@@ -27,7 +26,6 @@ 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';
@@ -58,7 +56,6 @@ export const MultiSignDocumentSigningView = ({
}: MultiSignDocumentSigningViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
@@ -103,13 +100,6 @@ 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.`),
@@ -129,13 +119,6 @@ export const MultiSignDocumentSigningView = ({
}
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'remove_field',
recipientId,
documentId: document?.id,
});
}
};
@@ -156,21 +139,11 @@ 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: _(toastMessage.title),
description: _(toastMessage.description),
title: _(msg`Error`),
description: _(msg`Failed to complete the document. Please try again.`),
variant: 'destructive',
});
} finally {
@@ -17,9 +17,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { defaultFilter as commandScore } from 'cmdk';
import { commandScore } from 'cmdk/dist/command-score';
import {
ArrowLeftIcon,
CheckIcon,
@@ -596,21 +596,9 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
<span className="ml-auto text-muted-foreground text-xs">
{hasValidSearch ? (
isVisibleCountCapped ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural
value={totalVisibleCount}
one="# result"
other="# results"
/>
)
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural
value={totalVisibleCount}
one="# item"
other="# items"
/>
<Trans>{totalVisibleCount} items</Trans>
)}
</span>
</div>
@@ -1,4 +1,3 @@
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';
@@ -42,7 +41,6 @@ export const DirectTemplatePageView = ({
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
@@ -126,13 +124,6 @@ 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,3 @@
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';
@@ -40,7 +39,6 @@ export const DocumentSigningCheckboxField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -128,13 +126,6 @@ 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
@@ -166,13 +157,6 @@ 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.`),
@@ -232,13 +216,6 @@ 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,4 +1,3 @@
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';
@@ -15,7 +14,6 @@ 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';
@@ -29,8 +27,6 @@ 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 = {
@@ -89,9 +85,7 @@ export const DocumentSigningCompleteDialog = ({
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const analytics = useAnalytics();
const { t, i18n } = useLingui();
const { toast } = useToast();
const { t } = useLingui();
const [showDialog, setShowDialog] = useState(false);
@@ -180,23 +174,6 @@ 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,4 +1,3 @@
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';
@@ -40,7 +39,6 @@ export const DocumentSigningDateField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -87,13 +85,6 @@ 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
@@ -122,13 +113,6 @@ 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,4 +1,3 @@
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';
@@ -36,7 +35,6 @@ export const DocumentSigningDropdownField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -88,13 +86,6 @@ 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
@@ -129,13 +120,6 @@ 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,4 +1,3 @@
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';
@@ -34,7 +33,6 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { email: providedEmail } = useRequiredDocumentSigningContext();
@@ -80,13 +78,6 @@ 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
@@ -115,13 +106,6 @@ 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,4 +1,3 @@
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';
@@ -20,8 +19,6 @@ 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';
@@ -103,12 +100,9 @@ export const DocumentSigningForm = ({
try {
await completeDocument({ nextSigner });
} catch (err) {
const error = AppError.parseError(err);
const toastMessage = getSigningCompletionErrorMessage(error.code);
toast({
title: _(toastMessage.title),
description: _(toastMessage.description),
title: _(msg`Error`),
description: _(msg`An error occurred while completing the document. Please try again.`),
variant: 'destructive',
});
@@ -1,4 +1,3 @@
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,7 +38,6 @@ export const DocumentSigningInitialsField = ({
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { fullName } = useRequiredDocumentSigningContext();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -86,13 +84,6 @@ 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
@@ -121,13 +112,6 @@ 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,4 +1,3 @@
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';
@@ -40,7 +39,6 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
@@ -118,13 +116,6 @@ 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
@@ -153,13 +144,6 @@ 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,5 +1,4 @@
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';
@@ -48,7 +47,6 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -144,13 +142,6 @@ 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
@@ -202,13 +193,6 @@ 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,3 +1,4 @@
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';
@@ -82,6 +83,8 @@ 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);
@@ -115,6 +118,12 @@ 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,4 +1,3 @@
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';
@@ -32,7 +31,6 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -93,13 +91,6 @@ 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
@@ -129,13 +120,6 @@ 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.`),
@@ -162,17 +146,14 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<RadioGroup
value={selectedOption}
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
<RadioGroup 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 && (
@@ -186,13 +167,14 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
)}
{field.inserted && (
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
<RadioGroup 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,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -42,7 +41,6 @@ export function DocumentSigningRejectDialog({
}: DocumentSigningRejectDialogProps) {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@@ -78,12 +76,6 @@ 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,4 +1,3 @@
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';
@@ -48,7 +47,6 @@ export const DocumentSigningSignatureField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient } = useDocumentSigningRecipientContext();
@@ -159,13 +157,6 @@ 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.`),
@@ -192,13 +183,6 @@ 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,5 +1,4 @@
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';
@@ -51,7 +50,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -172,13 +170,6 @@ 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
@@ -209,13 +200,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,5 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -37,6 +38,7 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
const team = useCurrentTeam();
const navigate = useNavigate();
const analytics = useAnalytics();
const organisation = useCurrentOrganisation();
const userTimezone =
@@ -101,6 +103,12 @@ 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,4 +1,3 @@
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';
@@ -38,12 +37,8 @@ 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();
@@ -281,13 +276,6 @@ 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);
}
};
@@ -362,9 +350,6 @@ 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,5 +1,4 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
@@ -46,7 +45,6 @@ export const EnvelopeEditorUploadPage = () => {
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const analytics = useAnalytics();
const {
envelope,
@@ -215,12 +213,6 @@ export const EnvelopeEditorUploadPage = () => {
const { data } = await createPromise.catch((error) => {
console.error(error);
analytics.captureException(error, {
source: isEmbedded ? 'embed' : 'editor',
location: 'create_envelope_items',
envelopeId: envelope.id,
});
// Set error state on files in batch upload.
setLocalFiles((prev) =>
prev.map((uploadingFile) =>
@@ -298,12 +290,6 @@ export const EnvelopeEditorUploadPage = () => {
} catch (error) {
console.error(error);
analytics.captureException(error, {
source: isEmbedded ? 'embed' : 'editor',
location: 'replace_pdf',
envelopeId: envelope.id,
});
toast({
title: t`Replace failed`,
description: t`Something went wrong while replacing the PDF`,
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
@@ -19,7 +18,6 @@ type GenericLocalField = TEnvelope['fields'][number] & {
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const {
envelopeStatus,
@@ -116,13 +114,6 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: currentEnvelopeItem?.envelopeId,
});
setRenderError(true);
}
};
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
@@ -54,7 +53,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const analytics = useAnalytics();
const {
envelopeData,
@@ -428,14 +426,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'page_render',
recipientId: recipient.id,
envelopeId: envelope.id,
});
setRenderError(true);
}
};
@@ -517,14 +507,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: payload.type,
recipientId: recipient.id,
envelopeId: envelope.id,
});
toast({
title: t`Error`,
description: t`An error occurred while signing the field.`,
@@ -118,6 +118,12 @@ export const EnvelopeSignerCompleteDialog = () => {
title: t`Document already signed`,
description: t`This document was already signed and no further action was taken.`,
});
} else {
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
}
if (onDocumentCompleted) {
@@ -142,18 +148,15 @@ export const EnvelopeSignerCompleteDialog = () => {
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
analytics.captureException(err, {
source: 'signing',
location: 'complete_document',
recipientId: recipient.id,
envelopeId: envelope.id,
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
}
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
// toast a specific completion error message.
throw err;
}
};
@@ -221,18 +224,14 @@ export const EnvelopeSignerCompleteDialog = () => {
}
} catch (err) {
console.log('err', err);
analytics.captureException(err, {
source: 'signing',
location: 'complete_document_next_signer',
recipientId: recipient.id,
envelopeId: envelope.id,
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
// Rethrow so DocumentSigningCompleteDialog can toast a specific
// completion error message.
throw err;
}
};
@@ -93,6 +93,14 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
duration: 5000,
});
if (type === EnvelopeType.DOCUMENT) {
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
@@ -101,11 +109,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'upload_document',
});
const errorMessage = getUploadErrorMessage(error.code);
toast({
@@ -1,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 { TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -35,7 +34,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const analytics = useAnalytics();
const team = useCurrentTeam();
@@ -114,11 +112,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'upload_document',
});
const errorMessage = getUploadErrorMessage(error.code);
toast({
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
@@ -69,7 +68,6 @@ export default function PDFViewer({
}: PDFViewerProps) {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const $el = useRef<HTMLDivElement>(null);
@@ -152,11 +150,6 @@ export default function PDFViewer({
console.error(err);
setLoadingState('error');
analytics.captureException(err, {
source: 'pdf_viewer',
location: 'pdf_load',
});
toast({
title: t`Error`,
description: t`An error occurred while loading the document.`,
@@ -222,7 +215,7 @@ export default function PDFViewer({
type VirtualizedPageListProps = {
scrollParentRef: ScrollTarget;
constraintRef: React.RefObject<HTMLDivElement | null>;
constraintRef: React.RefObject<HTMLDivElement>;
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
@@ -373,8 +366,6 @@ const PdfViewerPage = ({
* Manages rendering a page from a pdf.
*/
const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => {
const analytics = useAnalytics();
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
const [imageUrl, setImageUrl] = useState('');
@@ -466,12 +457,6 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
if (!isCancelled) {
console.error(err);
analytics.captureException(err, {
source: 'pdf_viewer',
location: 'pdf_page_render',
});
setImageLoadingState('error');
}
} finally {
@@ -39,14 +39,13 @@ const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
/**
* Admin list pages which support prefilling their search from the URL, used
* for the "View all results" links on capped groups. Teams and
* for the "View all results" links on capped groups. Teams, recipients and
* subscriptions have no admin list pages.
*/
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
};
export type UseAdminSearchCategoriesOptions = {
-65
View File
@@ -18,23 +18,6 @@ import './utils/polyfills/promise-with-resolvers';
* the page early, leaving dead event handlers (broken dropdowns, native form
* submits).
*/
/**
* Signing and direct template URLs contain recipient tokens which must never
* be sent to PostHog. Recipient context is attached explicitly via
* `recipientId` where needed instead.
*/
const redactTokensFromUrl = (value: string) => {
return value.replace(/(\/(?:sign|d|direct)\/)([^/?#]+)/g, '$1:token');
};
const URL_EVENT_PROPERTIES = [
'$current_url',
'$pathname',
'$referrer',
'$initial_referrer',
'$prev_pageview_pathname',
] as const;
function initPosthog() {
const postHogConfig = extractPostHogConfig();
@@ -42,59 +25,12 @@ function initPosthog() {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
// Only create person profiles for identified (authenticated) users,
// anonymous recipients on signing pages stay anonymous.
person_profiles: 'identified_only',
// Explicit events only, autocapture on signing pages blows up usage
// without providing actionable data.
autocapture: false,
capture_pageview: true,
capture_pageleave: false,
capture_exceptions: true,
before_send: (event) => {
if (!event) {
return null;
}
for (const property of URL_EVENT_PROPERTIES) {
const value = event.properties?.[property];
if (typeof value === 'string') {
event.properties[property] = redactTokensFromUrl(value);
}
}
if (event.$set_once && typeof event.$set_once['$initial_current_url'] === 'string') {
event.$set_once['$initial_current_url'] = redactTokensFromUrl(event.$set_once['$initial_current_url']);
}
return event;
},
});
});
}
}
/**
* Surfaces hydration recoveries (React 19 discards the server HTML and
* re-renders on the client instead of dying) so we can track how often
* extensions/early clicks interfere with hydration in the wild.
*/
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
if (extractPostHogConfig()) {
void import('posthog-js').then(({ default: posthog }) => {
if (posthog.__loaded) {
posthog.capture('$hydration_recoverable_error', {
message: error instanceof Error ? error.message : String(error),
componentStack: errorInfo.componentStack,
});
}
});
}
}
async function main() {
const locale = detect(fromHtmlTag('lang')) || 'en';
@@ -108,7 +44,6 @@ async function main() {
<HydratedRouter />
</I18nProvider>
</StrictMode>,
{ onRecoverableError },
);
});
+3 -4
View File
@@ -7,11 +7,10 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { EntryContext, RouterContextProvider } from 'react-router';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import { langCookie } from './storage/lang-cookie.server';
import { nonceContext } from './utils/nonce';
export const streamTimeout = 5_000;
@@ -20,7 +19,7 @@ export default async function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: RouterContextProvider,
loadContext: AppLoadContext,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -34,7 +33,7 @@ export default async function handleRequest(
// scripts it injects (route manifest, hydration data, module preloads).
// The same nonce is also exposed to the React tree via the root loader so
// our own inline scripts/styles can carry it.
const nonce = loadContext.get(nonceContext) || undefined;
const nonce = loadContext.nonce || undefined;
return new Promise((resolve, reject) => {
let shellRendered = false;
-13
View File
@@ -1,13 +0,0 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { type MiddlewareFunction, redirect } from 'react-router';
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
const { user } = await getOptionalSession(request);
if (!user || !isAdmin(user)) {
throw redirect('/');
}
return next();
};
-8
View File
@@ -1,8 +0,0 @@
import type { MiddlewareFunction } from 'react-router';
import { getRequestNonce } from '../../server/load-context';
import { nonceContext } from '../utils/nonce';
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
context.set(nonceContext, getRequestNonce());
};
+2 -15
View File
@@ -1,5 +1,4 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { getBasePath } from '@documenso/lib/constants/app';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
@@ -10,7 +9,6 @@ import { getOrganisationSession } from '@documenso/trpc/server/organisation-rout
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { useEffect } from 'react';
import {
data,
isRouteErrorResponse,
@@ -23,16 +21,13 @@ import {
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import { nonceMiddleware } from '~/middleware/nonce';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta';
import { nonce, nonceContext } from './utils/nonce';
export const middleware = [nonceMiddleware];
import { nonce } from './utils/nonce';
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
@@ -77,7 +72,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
nonce: context.get(nonceContext),
nonce: context.nonce,
session: session.isAuthenticated
? {
user: session.user,
@@ -205,19 +200,11 @@ export default function App() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error);
}
useEffect(() => {
if (errorCode !== 404) {
analytics.captureException(error, { source: 'app', location: 'root_boundary' });
}
}, [error]);
return <GenericErrorLayout errorCode={errorCode} />;
}
@@ -1,4 +1,4 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,18 +20,16 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import { adminMiddleware } from '~/middleware/admin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Admin`);
}
export const middleware = [adminMiddleware];
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { user } = await getSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
@@ -12,7 +12,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -30,12 +30,6 @@ export default function AdminDocumentsPage() {
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const urlTerm = searchParams?.get?.('term') ?? '';
useEffect(() => {
setTerm(urlTerm);
}, [urlTerm]);
const { data: findDocumentsData, isPending: isFindDocumentsLoading } = trpc.admin.document.find.useQuery(
{
query: debouncedTerm,
@@ -137,7 +131,7 @@ export default function AdminDocumentsPage() {
<div>
<Input
type="search"
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
placeholder={_(msg`Search by document title, team:123 or user:123`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
@@ -1,5 +1,4 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -8,7 +7,6 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { ShouldRevalidateFunctionArgs } from 'react-router';
import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router';
@@ -68,18 +66,8 @@ export default function DocumentsLayout() {
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'editor',
location: 'editor_layout_boundary',
envelopeId: params.id,
});
}, [error]);
const errorCodeMap = {
404: {
subHeading: msg`404 Document not found`,
@@ -68,20 +68,6 @@ export default function ApiTokensPage() {
return i18n.date(row.original.expires);
},
},
{
header: t`Last Used`,
cell: ({ row }) => {
if (!row.original.lastUsedAt) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
return <span className="text-foreground">{i18n.date(row.original.lastUsedAt)}</span>;
},
},
{
header: t`Actions`,
cell: ({ row }) => (
@@ -160,9 +146,6 @@ export default function ApiTokensPage() {
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
@@ -1,5 +1,4 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -9,7 +8,6 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useEffect } from 'react';
import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -86,18 +84,8 @@ export default function TemplatesLayout() {
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'editor',
location: 'editor_layout_boundary',
envelopeId: params.id,
});
}, [error]);
const errorCodeMap = {
404: {
subHeading: msg`404 Template not found`,
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -6,7 +5,6 @@ import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { useEffect } from 'react';
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -50,14 +48,8 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, { source: 'signing', location: 'recipient_layout_boundary' });
}, [error]);
return (
<GenericErrorLayout
errorCode={errorCode}
@@ -1,8 +1,8 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
export const loader = async () => {
export const loader = () => {
try {
const certStatus = await getCertificateStatus();
const certStatus = getCertificateStatus();
return Response.json({
isAvailable: certStatus.isAvailable,
+1 -1
View File
@@ -22,7 +22,7 @@ export const loader = async () => {
}
try {
const certStatus = await getCertificateStatus();
const certStatus = getCertificateStatus();
if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' };
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
@@ -6,7 +5,6 @@ import {
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { Trans } from '@lingui/react/macro';
import { useEffect } from 'react';
import { isRouteErrorResponse, Outlet, useRouteError } from 'react-router';
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
@@ -49,30 +47,10 @@ export default function Layout() {
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
const analytics = useAnalytics();
const error = useRouteError();
console.log({ routeError: error });
useEffect(() => {
const isExpectedEmbedResponse =
isRouteErrorResponse(error) &&
[
'embed-authentication-required',
'embed-paywall',
'embed-waiting-for-turn',
'embed-recipient-expired',
'embed-document-rejected',
'embed-document-completed',
].includes(error.data?.type);
if (isExpectedEmbedResponse) {
return;
}
analytics.captureException(error, { source: 'embed', location: 'embed_layout_boundary' });
}, [error]);
if (isRouteErrorResponse(error)) {
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
return (
@@ -2,13 +2,11 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { data } from 'react-router';
@@ -95,30 +93,6 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: template.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: template.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: template.envelopeId,
},
});
});
return {
token,
user,
@@ -220,30 +194,6 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
);
}
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: envelope.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: envelope.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: envelope.id,
},
});
});
return {
token,
user,
@@ -2,7 +2,6 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
@@ -14,7 +13,6 @@ import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
@@ -141,30 +139,6 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
})
: [];
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: document.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: document.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: document.envelopeId,
},
});
});
return {
token,
user,
@@ -298,30 +272,6 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: envelope.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: envelope.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: envelope.id,
},
});
});
return {
token,
user,
@@ -1,11 +1,8 @@
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { prisma } from '@documenso/prisma';
import { TrpcProvider } from '@documenso/trpc/react';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
@@ -38,40 +35,6 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
});
allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
// Derive the kind of authoring session from the child route path, e.g.
// /embed/v1/authoring/document/create -> resource 'document', mode 'create'.
// Completed and error pages are not new sessions and are skipped.
const [resource, mode] = url.pathname.split('/').filter(Boolean).slice(3);
const isAuthoringSession =
(resource === 'document' || resource === 'template') && (mode === 'create' || mode === 'edit');
if (isAuthoringSession) {
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: result.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: team?.organisationId,
teamId: result.teamId,
properties: {
type: 'authoring',
version: 'v1',
resource,
mode,
},
});
});
}
}
return {
@@ -1,11 +1,8 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { prisma } from '@documenso/prisma';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { useEffect, useLayoutEffect, useState } from 'react';
@@ -45,15 +42,6 @@ export async function loader({ request }: Route.LoaderArgs) {
const firstDocument = envelopes[0]?.document;
if (!firstDocument) {
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
properties: {
type: 'signing',
version: 'v1',
},
});
return superLoaderJson({
envelopes,
user,
@@ -67,29 +55,6 @@ export async function loader({ request }: Route.LoaderArgs) {
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: firstDocument.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: firstDocument.teamId,
properties: {
type: 'signing',
version: 'v1',
envelopeId: firstDocument.envelopeId,
},
});
});
return superLoaderJson({
envelopes,
user,
@@ -1,6 +1,5 @@
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
@@ -13,7 +12,7 @@ import type { OrganisationSession } from '@documenso/trpc/server/organisation-ro
import { Spinner } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
import { useEffect, useLayoutEffect, useState } from 'react';
import { useLayoutEffect, useState } from 'react';
import { isRouteErrorResponse, Outlet, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
@@ -167,17 +166,8 @@ export default function AuthoringLayout() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'embed',
location: 'embed_authoring_boundary',
});
}, [error]);
return (
<div>
{match(errorCode)
@@ -1,6 +1,5 @@
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
@@ -11,7 +10,6 @@ import {
ZEmbedCreateEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
@@ -78,30 +76,6 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
},
});
fireAndForget(async () => {
const team = result.teamId
? await prisma.team.findFirst({
where: {
id: result.teamId,
},
select: {
organisationId: true,
},
})
: null;
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: team?.organisationId,
teamId: result.teamId ?? undefined,
properties: {
type: 'authoring',
version: 'v2',
},
});
});
return superLoaderJson({
token,
tokenUserId: result.userId,
@@ -1,6 +1,5 @@
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
@@ -95,18 +94,6 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
brandingLogo = settings.brandingLogo;
}
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: envelope.team.organisationId,
teamId: result.teamId ?? undefined,
properties: {
type: 'authoring',
version: 'v2',
envelopeId: envelope.id,
},
});
return superLoaderJson({
token,
envelope,
+1 -7
View File
@@ -1,10 +1,4 @@
import { createContext, useRouteLoaderData } from 'react-router';
/**
* Per-request CSP nonce. Set by the root route middleware, read with
* `context.get(nonceContext)` in loaders/actions and `entry.server`.
*/
export const nonceContext = createContext<string>('');
import { useRouteLoaderData } from 'react-router';
/**
* Returns the supplied CSP nonce only when rendering on the server.
@@ -42,51 +42,6 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
}));
};
/**
* Toast messages for errors thrown while a recipient attempts to complete
* (sign) a document, so the user knows whether retrying can help and what to
* do next.
*/
export const getSigningCompletionErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with(AppErrorCode.NOT_FOUND, () => ({
title: msg`Document no longer available`,
description: msg`This document can no longer be signed. It may have been removed by the sender, or your signing access may have been revoked. Please contact the sender for a new signing link.`,
}))
.with(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, () => ({
title: msg`Some fields were not saved`,
description: msg`One or more of your required fields have not been saved. Please refresh the page, complete any empty required fields, and try again.`,
}))
.with(AppErrorCode.RECIPIENT_OUT_OF_TURN, () => ({
title: msg`It's not your turn to sign yet`,
description: msg`This document is signed in a set order and other recipients must sign before you. You will receive an email when it is your turn.`,
}))
.with(AppErrorCode.RECIPIENT_EXPIRED, () => ({
title: msg`Signing link expired`,
description: msg`Your signing link has expired. Please contact the sender to request a new one.`,
}))
.with(AppErrorCode.ENVELOPE_COMPLETED, () => ({
title: msg`Document already completed`,
description: msg`This document has already been completed and no further signatures can be added.`,
}))
.with(AppErrorCode.ENVELOPE_REJECTED, () => ({
title: msg`Document rejected`,
description: msg`This document has been rejected by a recipient and can no longer be signed.`,
}))
.with(AppErrorCode.ENVELOPE_CANCELLED, () => ({
title: msg`Document cancelled`,
description: msg`This document has been cancelled by the sender and can no longer be signed. Please contact the sender if you believe this is a mistake.`,
}))
.with(AppErrorCode.ENVELOPE_DRAFT, () => ({
title: msg`Document not ready`,
description: msg`This document has not been sent for signing yet. Please wait for the sender to send it before signing.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
}));
};
export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
+6 -6
View File
@@ -57,9 +57,9 @@
"papaparse": "^5.5.3",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react": "^18",
"react-call": "^1.8.1",
"react-dom": "^19.2.7",
"react-dom": "^18",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-hotkeys-hook": "^4.6.2",
@@ -93,11 +93,11 @@
"@types/luxon": "^3.7.1",
"@types/node": "^20",
"@types/papaparse": "^5.5.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"cross-env": "^10.1.0",
"esbuild": "^0.28.1",
"esbuild": "^0.27.0",
"remix-flat-routes": "^0.8.5",
"rollup": "^4.53.3",
"tsx": "^4.23.1",
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.18.0"
"version": "2.16.0"
}
-3
View File
@@ -8,7 +8,4 @@ export default {
// kept without a trailing slash so they match exactly, and so the bare
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
future: {
v8_middleware: true,
},
} satisfies Config;
+3 -2
View File
@@ -24,11 +24,12 @@ export const appContext = async (c: Context, next: Next) => {
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return await next();
return next();
}
// Add context to any pages you want here.
return await next();
return next();
};
const setAppContext = (c: Context, context: AppContext) => {
+23 -6
View File
@@ -1,16 +1,33 @@
import { getContext } from 'hono/context-storage';
import { RouterContextProvider } from 'react-router';
import type { AppLoadContext } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
* `hono/context-storage` (enabled in `server/router.ts`).
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
*/
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
declare module 'react-router' {
interface AppLoadContext {
/**
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
* so it can be threaded into `<ServerRouter nonce>` and root loader
* data, which then feeds `<Scripts>`, `<Links>`, etc.
*/
nonce: string;
}
}
/**
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
* production (`hono-react-router-adapter/node`).
*
* The Hono context isn't passed directly by the adapter, so we read it via
* `hono/context-storage`, which is enabled in `server/router.ts`.
*/
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
+2 -3
View File
@@ -5,7 +5,6 @@ import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import type { AppLoadContext } from 'react-router';
import tailwindcss from 'tailwindcss';
import { defineConfig, normalizePath } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
@@ -54,7 +53,7 @@ export default defineConfig({
entry: 'server/router.ts',
getLoadContext: async () => {
const { getLoadContext } = await import('./server/load-context');
return getLoadContext() as unknown as AppLoadContext;
return getLoadContext();
},
exclude: [
// Spread the defaults but replace the /.css$/ rule so that Bull
@@ -122,7 +121,7 @@ export default defineConfig({
'nodemailer',
/playwright/,
'@playwright/browser-chromium',
'@documenso/skia-canvas',
'skia-canvas',
],
},
},
+12 -25
View File
@@ -1,7 +1,7 @@
###########################
# BASE CONTAINER #
###########################
FROM node:24-alpine3.23 AS base
FROM node:22-alpine3.22 AS base
RUN apk add --no-cache openssl
RUN apk add --no-cache font-freefont
@@ -19,10 +19,7 @@ WORKDIR /app
COPY . .
# Install the exact turbo version resolved in the lockfile, without installing
# the rest of the dependency tree (prune must run before any npm ci).
RUN TURBO_VERSION="$(jq -r '.packages["node_modules/turbo"].version' package-lock.json)" \
&& npm install -g "turbo@${TURBO_VERSION}"
RUN npm install -g "turbo@^2.10.0"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@@ -42,9 +39,9 @@ RUN apk add --no-cache make cmake g++ openssl bash
WORKDIR /app
# Disable husky from installing hooks
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
ENV NEXT_TELEMETRY_DISABLED=1
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
# Encryption keys
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
@@ -88,17 +85,17 @@ COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
ENV NODE_OPTIONS="--max-old-space-size=8192"
RUN npm install -g "turbo@^2.10.0"
RUN npx turbo run build --filter=@documenso/remix...
RUN turbo run build --filter=@documenso/remix...
###########################
# RUNNER CONTAINER #
###########################
FROM base AS runner
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
# Telemetry credentials (baked into image at build time, can be disabled at runtime)
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
@@ -121,16 +118,7 @@ COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
# Strip build-time residue that ships as production dependencies but is never
# executed at runtime.
RUN rm -rf \
node_modules/react-email/dist/cli \
node_modules/esbuild \
node_modules/@esbuild \
node_modules/.bin/esbuild \
node_modules/.bin/email
RUN npm ci --only=production
# Automatically leverage output traces to reduce image size
# https://nodejs.org/docs/advanced-features/output-file-tracing
@@ -141,9 +129,8 @@ COPY --from=installer --chown=nodejs:nodejs /app/apps/remix/public ./apps/remix/
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/schema.prisma ./packages/prisma/schema.prisma
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./packages/prisma/migrations
# Generate the prisma client again, this time only targeting the client generator
RUN npx prisma generate --schema ./packages/prisma/schema.prisma --generator client \
&& npm cache clean --force
# Generate the prisma client again
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
# Get the start script from docker/
+4803 -3891
View File
File diff suppressed because it is too large Load Diff
+12 -49
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.18.0",
"version": "2.16.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -41,30 +41,27 @@
"translate:extract": "lingui extract --clean",
"translate:compile": "lingui compile"
},
"packageManager": "npm@11.19.1",
"packageManager": "npm@11.11.0",
"engines": {
"npm": ">=11.17.0",
"node": ">=24.0.0"
"npm": ">=11.11.0",
"node": ">=22.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@datadog/pprof": "^5.13.5",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/cli": "^5.6.0",
"@prisma/client": "^6.19.0",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"@trpc/client": "11.8.1",
"@trpc/react-query": "11.8.1",
"@trpc/server": "11.8.1",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.28.1",
"esbuild": "^0.27.0",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
@@ -89,61 +86,27 @@
"zod-prisma-types": "3.3.5"
},
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.2",
"@libpdf/core": "^0.4.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-alert-dialog": "^1.1.19",
"@radix-ui/react-aspect-ratio": "^1.1.11",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-context-menu": "^2.3.3",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-menubar": "^1.1.20",
"@radix-ui/react-navigation-menu": "^1.2.18",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-toast": "^1.2.19",
"@radix-ui/react-toggle": "^1.1.14",
"@radix-ui/react-toggle-group": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.12",
"ai": "^7.0.58",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"sharp": "0.35.3",
"react": "^18",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
},
"overrides": {
"lodash": "4.18.1",
"brace-expansion@1": "^1.1.18",
"pdfjs-dist": "5.4.296",
"postcss": "^8.5.19",
"react": "$react",
"react-dom": "$react-dom",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"typescript": "5.6.2",
"zod": "$zod",
"fumadocs-mdx": {
@@ -10,7 +10,7 @@ test.describe.configure({ mode: 'parallel' });
const nanoid = customAlphabet('1234567890abcdef', 10);
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organizations…';
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
@@ -437,98 +437,3 @@ test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without e
expect(adminSearchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: capped recipients group links to the admin documents page', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientPrefix = `viewall-recipient-${nanoid()}`;
// Seed 5 documents, each with one recipient email sharing the prefix, to
// hit the 5 result cap on the recipients group.
const documents = [];
for (let i = 0; i < 5; i++) {
documents.push(
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-viewall-${nanoid()}` },
}),
);
}
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
// Only the recipients group matches the prefix, so this is its link.
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
'href',
`/admin/documents?term=${encodeURIComponent(`recipient:${recipientPrefix}`)}`,
);
await viewAllOption.click();
await page.waitForURL((url) => url.pathname === '/admin/documents');
// The term input is prefilled with the recipient query and the matching
// documents are listed.
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
for (const document of documents) {
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
}
});
test('[ADMIN][GLOBAL_SEARCH]: view all results updates the documents page when already on it', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientPrefix = `viewall-live-${nanoid()}`;
// Seed 5 documents with recipients matching the prefix to hit the cap, and
// one control document whose recipient does not match: with a stale
// (unfiltered) query the control would show, with the filter it must not.
const documents = [];
for (let i = 0; i < 5; i++) {
documents.push(
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-live-${nanoid()}` },
}),
);
}
const controlDocument = await seedPendingDocument(sender, team.id, [`control-${nanoid()}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-live-control-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
// Start ON the admin documents page: the buggy state initializer has
// already run with an empty term.
await page.goto('/admin/documents');
await expect(page.getByPlaceholder(/Search by document title/)).toBeVisible();
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: 'View all results' }).first().click();
await page.waitForURL((url) => url.searchParams.get('term') === `recipient:${recipientPrefix}`);
// The same-route navigation must update both the input and the results.
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
await expect(page.getByRole('link', { name: documents[0].title })).toBeVisible();
await expect(page.getByRole('link', { name: controlDocument.title })).toHaveCount(0);
});
@@ -411,7 +411,7 @@ test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', asyn
});
// They should NOT see the organisation settings heading for this org.
await expect(page.getByText('Organization Settings')).not.toBeVisible();
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
});
// ─── Access control: UI ──────────────────────────────────────────────────────
@@ -50,9 +50,9 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
});
// Verify we're on the admin organisation page
await expect(page.getByText(`Manage organization`)).toBeVisible();
await expect(page.getByText(`Manage organisation`)).toBeVisible();
await expect(page.getByLabel('Organization Name')).toHaveValue(organisation.name);
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
// Check that the organisation members table shows the correct roles
const ownerRow = page.getByRole('row', { name: ownerUser.email });
@@ -356,7 +356,7 @@ test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
});
// Should show 404 error
await expect(page.getByRole('heading', { name: 'Organization not found' })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
timeout: 10_000,
});
});
@@ -525,8 +525,8 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
// Should be able to access organisation settings
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeEnabled();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();

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