Compare commits

..
Author SHA1 Message Date
ephraimduncan f9a4cc42fd fix: scale signature images to fill signature fields
The field renderer decreased large signature images to the field size, but it did not
increase small images. Thus a low-resolution image, for example an upload from a different
tool, showed at its native pixel size and was too small. This change removes the cap on the
scale factor in the Konva field renderer and in the v1 and legacy PDF insert paths. The image
scales in the two directions to fill the field, and the aspect ratio stays the same.
2026-08-11 15:37:40 +00:00
113 changed files with 6704 additions and 5875 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.
-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
@@ -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 |
---
@@ -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;
}
@@ -81,7 +81,7 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
+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"
}
}
@@ -1,7 +1,7 @@
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
))}
<Link
to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
to="https://documen.so/enterprise-cta"
target="_blank"
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
>
@@ -1,7 +1,6 @@
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';
@@ -33,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';
@@ -164,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',
});
}
@@ -26,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';
@@ -142,12 +141,9 @@ export const MultiSignDocumentSigningView = ({
} catch (err) {
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 {
+1 -2
View File
@@ -1,6 +1,5 @@
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { formatPath } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -45,7 +44,7 @@ const handleFallbackErrorMessages = (code: string) => {
return message;
};
const LOGIN_REDIRECT_PATH = formatPath('/');
const LOGIN_REDIRECT_PATH = '/';
export const ZSignInFormSchema = z.object({
email: zEmail().min(1),
@@ -1,6 +1,5 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatPath } from '@documenso/lib/constants/app';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
DOCUMENTS_PAGE_SHORTCUT,
@@ -19,7 +18,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
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,
@@ -863,7 +862,7 @@ const PromptLanguageCommands = ({
formData.append('lang', lang);
const response = await fetch(formatPath('/api/locale'), {
const response = await fetch('/api/locale', {
method: 'post',
body: formData,
});
@@ -1,5 +1,4 @@
import { authClient } from '@documenso/auth/client';
import { formatPath } from '@documenso/lib/constants/app';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -35,9 +34,7 @@ export const DocumentSigningAuthAccount = ({
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
await authClient.signOut({
redirectPath: formatPath(
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
),
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
});
} catch {
setIsSigningOut(false);
@@ -1,5 +1,4 @@
import { authClient } from '@documenso/auth/client';
import { formatPath } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
@@ -22,10 +21,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
try {
setIsSigningOut(true);
let redirectPath = formatPath('/signin');
let redirectPath = '/signin';
if (email) {
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
}
await authClient.signOut({
@@ -14,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';
@@ -28,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 = {
@@ -88,8 +85,7 @@ export const DocumentSigningCompleteDialog = ({
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const { t, i18n } = useLingui();
const { toast } = useToast();
const { t } = useLingui();
const [showDialog, setShowDialog] = useState(false);
@@ -178,18 +174,6 @@ export const DocumentSigningCompleteDialog = ({
return;
}
// 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 { 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',
});
@@ -146,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 && (
@@ -170,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 && (
@@ -148,11 +148,15 @@ export const EnvelopeSignerCompleteDialog = () => {
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
}
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
// toast a specific completion error message.
throw err;
}
};
@@ -220,11 +224,14 @@ export const EnvelopeSignerCompleteDialog = () => {
}
} catch (err) {
console.log('err', err);
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
// Rethrow so DocumentSigningCompleteDialog can toast a specific
// completion error message.
throw err;
}
};
@@ -215,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;
@@ -1,195 +0,0 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { Trans } from '@lingui/react/macro';
import { motion, useReducedMotion } from 'framer-motion';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
const DEMO_BRANDS = [
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#A2E771',
ink: '#162C07',
tint: '#F2FBEA',
sheen: 'rgba(162, 231, 113, 0.32)',
},
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#387BC7',
ink: '#ffffff',
tint: '#EDF3FA',
sheen: 'rgba(56, 123, 199, 0.28)',
},
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#9747F5',
ink: '#ffffff',
tint: '#F4EDFE',
sheen: 'rgba(151, 71, 245, 0.26)',
},
];
/**
* Milliseconds each brand is shown before cycling to the next.
*/
const BRAND_CYCLE_INTERVAL_MS = 2400;
export const BrandingUpsell = () => {
const organisation = useCurrentOrganisation();
const isReducedMotion = useReducedMotion();
const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS));
const isStatic = isReducedMotion ?? false;
const brand = DEMO_BRANDS[brandIndex];
return (
<SettingsUpsellCard
planLabel={<Trans>Teams</Trans>}
title={<Trans>Unlock Branding Preferences</Trans>}
description={
<Trans>Put your own brand on every document you send. Branding is available on the Teams plan and above.</Trans>
}
features={[
<Trans key="logo">Your logo on signing pages and emails</Trans>,
<Trans key="details">Company details and website in email footers</Trans>,
<Trans key="teams">Separate branding per team</Trans>,
]}
preview={
<div className="mx-auto w-full max-w-xs">
<div className="flex h-8 items-center justify-between px-1">
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
<Trans>Brand accent</Trans>
</span>
<div className="flex shrink-0 items-center gap-2">
{DEMO_BRANDS.map((dotBrand, index) => (
<motion.div
key={index}
initial={isStatic ? false : undefined}
animate={{
scale: index === brandIndex ? 1.25 : 1,
opacity: index === brandIndex ? 1 : 0.42,
boxShadow:
index === brandIndex ? '0 0 0 3px rgba(15, 23, 42, 0.08)' : '0 0 0 0 rgba(15, 23, 42, 0)',
}}
transition={SPRING}
className="h-[13px] w-[13px] rounded-full"
style={{ backgroundColor: dotBrand.accent }}
/>
))}
</div>
</div>
<div className="relative mt-3 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.tint }}
transition={{ duration: 0.45, ease: EASE }}
className="flex items-center gap-2.5 border-b px-4 py-3"
>
{/* The sender identity never changes — only the tile colours tween per brand. */}
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent, color: brand.ink }}
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg font-semibold text-sm"
>
{brand.letter}
</motion.div>
{/*
* Hardcoded inks (not theme tokens): this row sits on the
* hardcoded light `tint` band, so it pairs with hardcoded ink
* colours the same way the email sibling pairs its hardcoded
* avatar surfaces (hardcoded surface => hardcoded ink).
*/}
<div className="min-w-0">
<div className="font-medium text-[#0f172a] text-sm">
<p className="truncate">{brand.name}</p>
</div>
<div className="font-mono text-[#64748b] text-xs">
<p className="truncate">{brand.domain}</p>
</div>
</div>
</motion.div>
<div className="px-4 py-3.5">
<p className="font-medium text-sm">
<Trans>Please sign: Example.pdf</Trans>
</p>
<p className="mt-1 text-muted-foreground text-xs">
<Trans>{organisation.name} has invited you to sign this document.</Trans>
</p>
{/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */}
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent, color: brand.ink }}
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
className="mt-3 inline-block rounded-md px-3 py-1.5 font-medium text-xs"
>
<motion.span
key={brandIndex}
initial={isStatic ? false : { scale: 0.96 }}
animate={{ scale: 1 }}
transition={{ ...POP, delay: 0.06 }}
className="inline-block"
>
<Trans>Sign</Trans>
</motion.span>
</motion.div>
</div>
<div className="mt-auto flex items-center gap-2.5 border-t bg-muted px-4 py-2.5">
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
<Trans>Company details</Trans>
</span>
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent }}
transition={{ duration: 0.4 }}
className="h-1.5 w-[54px] rounded-full"
style={{ opacity: 0.45 }}
/>
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent }}
transition={{ duration: 0.4 }}
className="h-1.5 w-[34px] rounded-full"
style={{ opacity: 0.22 }}
/>
</div>
{/*
* Keyed remount replays the sweep per brand. No opacity envelope —
* keyframe arrays are unreliable on strict-mode remounts; both
* endpoints sit outside the overflow-hidden card, so the clip
* provides the fade in/out instead.
*/}
<motion.div
key={`sheen-${brandIndex}`}
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '240%' }}
transition={{ duration: 1.15, ease: 'easeOut' }}
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
style={{ background: `linear-gradient(105deg, transparent, ${brand.sheen}, transparent)` }}
/>
</div>
</div>
}
/>
);
};
@@ -1,213 +0,0 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { BadgeCheckIcon, MailIcon } from 'lucide-react';
import { BrandingLogoIcon } from '../branding-logo-icon';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
/**
* Named sender identities cycled through while the preview is in its branded
* state — one per branded cycle step, shown as the sender name and address.
*/
const BRANDED_SENDERS = [
{ name: 'Support', email: 'support@example.com' },
{ name: 'Team', email: 'hello@example.com' },
{ name: 'Sales', email: 'sales@example.com' },
{ name: 'Example', email: 'noreply@example.com' },
];
/**
* How long the initial unbranded (Documenso default) state is shown before
* the first flip starts. Shown exactly once — the cycle never returns to it.
*/
const INITIAL_STATE_DURATION_MS = 2500;
/**
* How long each branded sender identity is shown before cycling to the next,
* giving the viewer time to read the changed address.
*/
const BRANDED_STATE_DURATION_MS = 5000;
/**
* One duration per cycle step: the unbranded state first, then one step per
* named sender identity, derived from the identity count so the two cannot
* drift.
*/
const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)];
export const EmailDomainsUpsell = () => {
const organisation = useCurrentOrganisation();
const isReducedMotion = useReducedMotion();
// Loop from index 1: the unbranded Documenso intro plays exactly once,
// then the cycle rotates through the branded senders only.
const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1);
const isBranded = cycleIndex > 0;
const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0];
const isStatic = isReducedMotion ?? false;
return (
<SettingsUpsellCard
planLabel={<Trans>Enterprise</Trans>}
title={<Trans>Unlock Email Domains</Trans>}
description={
<Trans>Send documents from your own domain. Email domains are available on the Enterprise plan.</Trans>
}
features={[
<Trans key="journey">Send emails to recipients from your domain</Trans>,
<Trans key="dns">Easy DNS setup with auto-generated DKIM and SPF records</Trans>,
<Trans key="senders">Named senders with defaults per team, template or document</Trans>,
]}
ctaLabel={<Trans>Contact Sales</Trans>}
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
ctaExternal
preview={
<div className="mx-auto w-full max-w-xs">
<div className="relative h-8">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={isBranded ? 'chip-on' : 'chip-off'}
initial={isStatic ? false : { opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={SPRING}
className={cn(
'absolute inset-0 flex items-center gap-2 rounded-full border bg-background px-3 font-mono text-xs',
isBranded ? 'border-documenso-300 text-documenso-800' : 'text-muted-foreground',
)}
>
{isBranded ? (
<BadgeCheckIcon className="h-3.5 w-3.5 shrink-0 text-documenso-700" />
) : (
<MailIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">
{isBranded ? <Trans>Sending from your domain</Trans> : <Trans>Sending from app.documenso.com</Trans>}
</span>
</motion.div>
</AnimatePresence>
</div>
<div className="relative mt-4 overflow-hidden rounded-lg border bg-background shadow-sm">
<div className="flex items-center gap-2 border-b p-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-semibold text-sm">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={`logo-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22 }}
>
{/*
* Remounts with its keyed parent on every cycle step so the
* pop replays each change. Single-value spring (POP
* overshoots past 1) instead of scale keyframes — keyframe
* arrays are unreliable on strict-mode remounts.
*/}
<motion.span
initial={isStatic ? false : { scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ scale: POP }}
className="inline-block"
>
{isBranded ? (
<Avatar className="h-8 w-8 border border-solid">
{organisation.avatarImageId && (
<AvatarImage src={formatAvatarUrl(organisation.avatarImageId)} />
)}
<AvatarFallback className="text-sm">{brandedSender.name[0]}</AvatarFallback>
</Avatar>
) : (
<BrandingLogoIcon className="h-8 w-8" />
)}
</motion.span>
</motion.span>
</AnimatePresence>
</div>
<div className="min-w-0">
<div className="font-medium text-sm">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`name-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.28, ease: EASE }}
className="flex min-w-0 items-center gap-1.5"
>
<span className="min-w-0 truncate">{isBranded ? brandedSender.name : 'Documenso'}</span>
{/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */}
{isBranded && (
<motion.span
initial={isStatic ? false : { scale: 0, rotate: -40 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ ...POP, delay: 0.12 }}
className="shrink-0"
>
<BadgeCheckIcon className="h-3.5 w-3.5 text-documenso-700" />
</motion.span>
)}
</motion.div>
</AnimatePresence>
</div>
<div className="font-mono text-muted-foreground text-xs">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`addr-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.28, ease: EASE }}
className="truncate"
>
{isBranded ? brandedSender.email : 'noreply@app.documenso.com'}
</motion.p>
</AnimatePresence>
</div>
</div>
</div>
<div className="p-4">
<p className="font-medium text-sm">
<Trans>Please sign: Example.pdf</Trans>
</p>
<p className="mt-1 text-muted-foreground text-xs">
<Trans>{organisation.name} has invited you to sign this document.</Trans>
</p>
</div>
{/*
* Keyed remount replays the sweep on every cycle step. No opacity
* envelope — keyframe arrays are unreliable on strict-mode
* remounts; both endpoints sit outside the overflow-hidden card,
* so the clip provides the fade in/out instead.
*/}
<motion.div
key={`sheen-${cycleIndex}`}
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '240%' }}
transition={{ duration: 1.2, ease: 'easeOut' }}
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.32), transparent)' }}
/>
</div>
</div>
}
/>
);
};
@@ -1,9 +0,0 @@
/**
* Shared motion vocabulary for the settings upsell previews. Values ported
* from the design prototype (`design/SSO Upsell.dc.html`).
*/
export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const;
export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const;
export const EASE = [0.22, 0.61, 0.36, 1] as const;
@@ -1,118 +0,0 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
export type SettingsUpsellCardProps = {
planLabel: ReactNode;
title: ReactNode;
description: ReactNode;
features: ReactNode[];
preview: ReactNode;
/**
* CTA label. Defaults to "Upgrade Plan".
*/
ctaLabel?: ReactNode;
/**
* CTA destination. Defaults to the organisation billing settings page.
*/
ctaTo?: string;
/**
* Render the CTA as an external link (new tab) instead of an internal route.
*/
ctaExternal?: boolean;
};
/**
* Shared split-card layout for claim-gated settings upsells on Documenso
* Cloud. The left pane pitches the feature (plan badge, title, description,
* feature list, upgrade CTA); the right pane renders a decorative scenario
* preview supplied by the caller.
*
* Callers decide *when* to render this (cloud + missing claim flag).
*/
export const SettingsUpsellCard = ({
planLabel,
title,
description,
features,
preview,
ctaLabel,
ctaTo,
ctaExternal = false,
}: SettingsUpsellCardProps) => {
const organisation = useCurrentOrganisation();
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`;
const ctaContent = (
<>
{ctaLabel ?? <Trans>Upgrade Plan</Trans>}
<ArrowRightIcon className="ml-2 h-4 w-4" />
</>
);
return (
<div className="mt-8 overflow-hidden rounded-xl border-2 ring-4 ring-muted/70 md:grid md:grid-cols-[1.08fr_0.92fr] xl:-mx-8">
{/*
* `min-w-0` on both grid items: `fr` tracks have an `auto` content
* minimum, so long preview content (e.g. a wide mono domain line) would
* otherwise widen the right track beyond its 0.92fr share — and
* re-balance the whole grid on every preview cycle (layout shift).
*/}
<div className="flex min-w-0 flex-col items-start p-6 md:p-8">
<Badge size="small">
<LockIcon className="mr-1 h-3 w-3" />
<span className="uppercase">{planLabel}</span>
</Badge>
<h3 className="mt-4 font-semibold text-xl">{title}</h3>
<p className="mt-2 max-w-[40ch] text-muted-foreground text-sm">{description}</p>
<ul className="mt-6 space-y-3">
{features.map((feature, index) => (
<li key={index} className="flex items-start gap-2.5 text-sm">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-documenso-200">
<CheckIcon className="h-3 w-3 text-documenso-800" strokeWidth={2.5} />
</span>
{feature}
</li>
))}
</ul>
{canManageBilling ? (
<Button className="mt-8" asChild>
{ctaExternal ? (
<a href={ctaHref} target="_blank" rel="noreferrer">
{ctaContent}
</a>
) : (
<Link to={ctaHref}>{ctaContent}</Link>
)}
</Button>
) : (
<p className="mt-8 text-muted-foreground text-xs">
<Trans>Contact your organisation owner to upgrade plans.</Trans>
</p>
)}
</div>
<div
aria-hidden="true"
className="flex min-w-0 flex-col justify-center gap-3 border-t bg-muted p-6 md:border-t-0 md:border-l md:p-8"
>
{preview}
</div>
</div>
);
};
@@ -1,260 +0,0 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { FingerprintIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
export const SsoPortalUpsell = () => {
const isReducedMotion = useReducedMotion();
const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS);
return (
<SettingsUpsellCard
planLabel={<Trans>Enterprise</Trans>}
title={<Trans>Unlock the Organisation SSO Portal</Trans>}
description={
<Trans>
Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan.
</Trans>
}
features={[
<Trans key="oidc">Works with any OIDC provider Okta, Entra ID, Google and more</Trans>,
<Trans key="jit">Accounts are automatically added to your organisation on sign-in</Trans>,
<Trans key="control">Restrict sign-ins by email domain and choose the default role</Trans>,
]}
ctaLabel={<Trans>Contact Sales</Trans>}
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
ctaExternal
preview={
<div className="mx-auto w-full max-w-xs">
<div className="relative h-[236px]">
<AnimatePresence mode="wait" initial={false}>
{sceneIndex === 0 && <PortalScene key="portal" isStatic={isReducedMotion ?? false} />}
{sceneIndex === 1 && <RedirectScene key="redirect" />}
{sceneIndex === 2 && <SuccessScene key="success" />}
</AnimatePresence>
</div>
</div>
}
/>
);
};
/**
* Absolute-positioned panel each scene renders in, handling the shared
* slide-and-fade transition between scenes.
*/
const ScenePanel = ({ children }: { children: ReactNode }) => {
return (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -12, scale: 0.98 }}
transition={{ duration: 0.34, ease: EASE }}
className="absolute inset-0 flex flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-6 text-center shadow-sm"
>
{children}
</motion.div>
);
};
/**
* Scene 1: the organisation's SSO portal, with a timed faux press on the
* "Continue with SSO" button (cursor flies in, button dips, sheen sweeps).
*
* When `isStatic` is set (reduced motion) every element renders with
* `initial={false}`, skipping entrance and press animations.
*/
const PortalScene = ({ isStatic }: { isStatic: boolean }) => {
const organisation = useCurrentOrganisation();
const rise = (delay: number) => ({
initial: isStatic ? false : { y: 10, opacity: 0 },
animate: { y: 0, opacity: 1 },
transition: { ...SPRING, delay },
});
return (
<ScenePanel>
<motion.div
initial={isStatic ? false : { scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ ...POP, delay: 0.04 }}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-documenso-200 font-semibold text-documenso-900">
{([...organisation.name][0] ?? 'D').toUpperCase()}
</div>
</motion.div>
<motion.p {...rise(0.12)} className="mt-3.5 font-semibold text-sm">
<Trans>Welcome to {organisation.name}</Trans>
</motion.p>
<motion.p {...rise(0.18)} className="mt-1 text-muted-foreground text-xs">
<Trans>Single sign-on</Trans>
</motion.p>
<div className="relative mt-4 w-full">
<motion.div
initial={isStatic ? false : { y: 10, opacity: 0, scale: 1 }}
animate={{ y: 0, opacity: 1, scale: [1, 1, 0.955, 1] }}
transition={{
y: { ...SPRING, delay: 0.24 },
opacity: { duration: 0.3, delay: 0.24 },
scale: { duration: 2.6, times: [0, 0.63, 0.72, 0.84], ease: 'easeOut' },
}}
className="relative flex h-[38px] w-full items-center justify-center overflow-hidden rounded-md bg-foreground font-semibold text-background text-sm"
>
<span>
<Trans>Continue with SSO</Trans>
</span>
<motion.div
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '150%' }}
transition={{ duration: 0.85, delay: 1.75, ease: 'easeOut' }}
className="absolute top-0 bottom-0 left-[20%] w-3/5"
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.45), transparent)' }}
/>
</motion.div>
<motion.svg
initial={isStatic ? false : { x: 30, y: 30, opacity: 0, scale: 1 }}
animate={{
x: [30, 30, 0, 0, 0],
y: [30, 30, 0, 0, 0],
opacity: [0, 1, 1, 1, 0],
scale: [1, 1, 1, 0.82, 1],
}}
transition={{ duration: 2.6, times: [0, 0.3, 0.63, 0.72, 0.94], ease: EASE }}
width={17}
height={17}
viewBox="0 0 24 24"
strokeWidth={1.4}
strokeLinejoin="round"
className="absolute right-[26px] -bottom-2.5 fill-foreground stroke-background"
>
<path d="M4 2.5 19 12l-6.6 1.4L9.7 19.6z" />
</motion.svg>
</div>
</ScenePanel>
);
};
/**
* Scene 2: redirecting to the identity provider, with a rotating ring around
* a fingerprint tile and a filling progress bar.
*/
const RedirectScene = () => {
return (
<ScenePanel>
<div className="relative flex h-[46px] w-[46px] items-center justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 0.95, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
className="absolute inset-0 rounded-full border-2"
style={{ borderTopColor: '#A2E771' }}
/>
<div className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-muted">
<FingerprintIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.6} />
</div>
</div>
<motion.p
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.08 }}
className="mt-3.5 max-w-[22ch] text-sm"
>
<Trans>Redirecting to your identity provider</Trans>
</motion.p>
<div className="mt-4 h-1 w-[140px] overflow-hidden rounded-full bg-border">
<motion.div
initial={{ width: '0%' }}
animate={{ width: '100%' }}
transition={{ duration: 1.35, ease: 'easeInOut' }}
className="h-full rounded-full bg-documenso"
/>
</div>
</ScenePanel>
);
};
/**
* Scene 3: signed in, with an expanding pulse ring, a popping green circle,
* a drawn checkmark and the signed-in member's email.
*/
const SuccessScene = () => {
const { user } = useSession();
return (
<ScenePanel>
<div className="relative h-10 w-10">
<motion.div
initial={{ scale: 0.7, opacity: 0.85 }}
animate={{ scale: 2.1, opacity: 0 }}
transition={{ duration: 1.1, ease: 'easeOut' }}
className="absolute inset-0 rounded-full border-2"
style={{ borderColor: '#A2E771' }}
/>
<motion.div
initial={{ scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={POP}
className="absolute inset-0 flex items-center justify-center rounded-full bg-documenso-200"
>
<svg
width={19}
height={19}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
className="text-documenso-900"
>
<motion.path
d="M20 6 9 17l-5-5"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.12, ease: 'easeOut' }}
/>
</svg>
</motion.div>
</div>
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.16 }}
className="mt-3.5 font-semibold text-sm"
>
<Trans>Signed in</Trans>
</motion.p>
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.24 }}
className="mt-1 font-mono text-muted-foreground text-xs"
>
{user.email}
</motion.p>
</ScenePanel>
);
};
/**
* Milliseconds each scene is shown before advancing: portal, redirect,
* success.
*/
const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000];
@@ -1,47 +0,0 @@
import { useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
/**
* Cycles an index through `durations.length` steps, waiting `durations[i]`
* milliseconds on step `i` before advancing to the next.
*
* When the cycle wraps past the last step it continues from `loopStartIndex`
* (default `0`), letting consumers play intro-only steps exactly once and
* then loop through the remaining steps forever.
*
* Under `prefers-reduced-motion` the cycle never starts and the index stays
* at 0, so consumers render their initial state statically.
*
* Pass module-level constants for `durations` and `loopStartIndex` — their
* identities are intentionally not dependencies.
*/
export const useTimedCycle = (durations: number[], loopStartIndex = 0) => {
const [index, setIndex] = useState(0);
const isReducedMotion = useReducedMotion();
useEffect(() => {
if (isReducedMotion || durations.length === 0) {
setIndex(0);
return;
}
let current = 0;
let timeout: ReturnType<typeof setTimeout>;
const tick = () => {
const next = current + 1;
current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next;
setIndex(current);
timeout = setTimeout(tick, durations[current]);
};
timeout = setTimeout(tick, durations[0]);
return () => clearTimeout(timeout);
}, [isReducedMotion]);
return index;
};
-21
View File
@@ -31,26 +31,6 @@ function initPosthog() {
}
}
/**
* 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';
@@ -64,7 +44,6 @@ async function main() {
<HydratedRouter />
</I18nProvider>
</StrictMode>,
{ onRecoverableError },
);
});
+7 -10
View File
@@ -1,6 +1,5 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { getBasePath } from '@documenso/lib/constants/app';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { createPublicEnv } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
@@ -21,6 +20,7 @@ import {
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
@@ -68,7 +68,6 @@ export async function loader({ context, request }: Route.LoaderArgs) {
lang,
theme: getTheme(),
disableAnimations,
basePath: getBasePath(),
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
@@ -91,10 +90,10 @@ export async function loader({ context, request }: Route.LoaderArgs) {
}
export function Layout({ children }: { children: React.ReactNode }) {
const { theme, basePath } = useLoaderData<typeof loader>() || {};
const { theme } = useLoaderData<typeof loader>() || {};
return (
<ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
<LayoutContent>{children}</LayoutContent>
</ThemeProvider>
);
@@ -112,8 +111,6 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const [theme] = useTheme();
const basePath = data.basePath ?? '';
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
// <style> block from `RecipientBranding` applies to BOTH the main tree and
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
@@ -129,11 +126,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
<link rel="manifest" href="/site.webmanifest" />
<meta name="google" content="notranslate" />
<Meta />
<Links nonce={nonce(cspNonce)} />
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -17,7 +17,6 @@ import {
type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -122,18 +121,11 @@ export default function OrganisationSettingsBrandingPage() {
? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
const brandingPreferencesFormEnabled =
organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
return (
<div>
<SettingsHeader
title={settingsHeaderText}
subtitle={settingsHeaderSubtitle}
hideDivider={!brandingPreferencesFormEnabled}
/>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
{brandingPreferencesFormEnabled ? (
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
<section>
<BrandingPreferencesForm
context="Organisation"
@@ -168,8 +160,6 @@ export default function OrganisationSettingsBrandingPage() {
</Alert>
)}
</section>
) : IS_DOCUMENSO_CLOUD() ? (
<BrandingUpsell />
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { trpc } from '@documenso/trpc/react';
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
@@ -27,9 +27,8 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
const { t } = useLingui();
@@ -97,23 +96,10 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
}, [organisation]);
const pageHeader = t`Email Domain Settings`;
const pageSubtitle = t`Manage your email domain settings.`;
if (!IS_BILLING_ENABLED()) {
return null;
}
if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) {
return (
<div>
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
<EmailDomainsUpsell />
</div>
);
}
if (isLoadingEmailDomain) {
return <SpinnerBox className="py-32" />;
}
@@ -146,7 +132,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
return (
<div>
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
<SettingsHeader hideDivider title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
</SettingsHeader>
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -9,7 +9,6 @@ import { Link } from 'react-router';
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
import { appMetaTags } from '~/utils/meta';
@@ -42,8 +41,6 @@ export default function OrganisationSettingsEmailDomains() {
<section>
<OrganisationEmailDomainsDataTable />
</section>
) : IS_DOCUMENSO_CLOUD() ? (
<EmailDomainsUpsell />
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -1,5 +1,4 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import {
@@ -29,7 +28,6 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { SettingsHeader } from '~/components/general/settings-header';
import { SsoPortalUpsell } from '~/components/general/settings-upsell/sso-portal-upsell';
import { appMetaTags } from '~/utils/meta';
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
@@ -65,33 +63,10 @@ export default function OrganisationSettingSSOLoginPage() {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
const isAuthenticationPortalEnabled = organisation.organisationClaim.flags.authenticationPortal === true;
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.get.useQuery(
{
organisationId: organisation.id,
},
{
// The endpoint rejects orgs without the claim flag, so don't fire
// requests that are guaranteed to error.
enabled: isAuthenticationPortalEnabled,
},
);
if (!isAuthenticationPortalEnabled && IS_DOCUMENSO_CLOUD()) {
return (
<div>
<SettingsHeader
hideDivider
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
<SsoPortalUpsell />
</div>
);
}
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
organisationId: organisation.id,
});
if (isLoadingAuthenticationPortal || !authenticationPortal) {
return <SpinnerBox className="py-32" />;
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -17,7 +17,6 @@ import {
type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {
@@ -156,8 +155,6 @@ export default function TeamsSettingsPage() {
</Alert>
)}
</section>
) : IS_DOCUMENSO_CLOUD() ? (
<BrandingUpsell />
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
+2 -3
View File
@@ -5,9 +5,8 @@
*
* No translations required.
*/
import { formatPath } from '@documenso/lib/constants/app';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
export const loader = () => {
@@ -148,7 +147,7 @@ export default function EmbedPlaygroundPage() {
return inputToken;
}
const response = await fetch(formatPath('/api/v2/embedding/create-presign-token'), {
const response = await fetch('/api/v2/embedding/create-presign-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${inputToken}`,
@@ -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)
+7 -7
View File
@@ -44,7 +44,7 @@
"autoprefixer": "^10.4.22",
"colord": "^2.9.3",
"content-disposition": "^1.0.1",
"framer-motion": "^12.43.0",
"framer-motion": "^12.23.24",
"hono": "^4.12.14",
"hono-react-router-adapter": "^0.6.5",
"input-otp": "^1.4.2",
@@ -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.17.0"
"version": "2.16.0"
}
+2 -2
View File
@@ -3,12 +3,12 @@
"short_name": "Documenso",
"icons": [
{
"src": "./android-chrome-192x192.png",
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./android-chrome-512x512.png",
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
-5
View File
@@ -3,9 +3,4 @@ import type { Config } from '@react-router/dev/config';
export default {
appDirectory: 'app',
ssr: true,
// Must never be undefined and must start with the raw Vite `base` value,
// otherwise @react-router/dev crashes / exits on `react-router dev`. Both are
// 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(/\/$/, '') : '/',
} satisfies Config;
@@ -1,4 +1,3 @@
import { formatPath } from '@documenso/lib/constants/app';
import { z } from 'zod';
import { type TDetectFieldsRequest, ZNormalizedFieldWithContextSchema } from './detect-fields.types';
@@ -70,7 +69,7 @@ export const detectFields = async ({
onError,
signal,
}: DetectFieldsOptions): Promise<void> => {
const response = await fetch(formatPath('/api/ai/detect-fields'), {
const response = await fetch('/api/ai/detect-fields', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1,4 +1,3 @@
import { formatPath } from '@documenso/lib/constants/app';
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { z } from 'zod';
@@ -71,7 +70,7 @@ export const detectRecipients = async ({
onError,
signal,
}: DetectRecipientsOptions): Promise<void> => {
const response = await fetch(formatPath('/api/ai/detect-recipients'), {
const response = await fetch('/api/ai/detect-recipients', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
-13
View File
@@ -14,22 +14,9 @@ import { getLoadContext } from './hono/server/load-context.js';
import server from './hono/server/router.js';
import * as build from './index.js';
// Sub-path the app is served under (e.g. "/ESign"). Empty = root.
// Must match the basePath used by the Hono router and the Vite `base`/RR
// `basename` so that hashed asset URLs like `/ESign/assets/app-xxx.css`
// resolve to files on disk at `build/client/assets/app-xxx.css`.
const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '');
server.use(
serveStatic({
root: 'build/client',
rewriteRequestPath: (path) => {
if (basePath && (path === basePath || path.startsWith(`${basePath}/`))) {
const stripped = path.slice(basePath.length);
return stripped === '' ? '/' : stripped;
}
return path;
},
onFound: (path, c) => {
if (path.startsWith('build/client/assets')) {
// Hard cache assets with hashed file names.
+1 -3
View File
@@ -46,9 +46,7 @@ export interface HonoEnv {
};
}
const basePath = (env('NEXT_PUBLIC_BASE_PATH') ?? '').replace(/\/$/, '');
const app = new Hono<HonoEnv>().basePath(basePath || '/');
const app = new Hono<HonoEnv>();
/**
* Database-backed rate limiting for API routes.
+2 -4
View File
@@ -1,4 +1,4 @@
import { API_V2_BETA_URL, API_V2_URL, formatPath } from '@documenso/lib/constants/app';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
@@ -11,10 +11,8 @@ type OpenApiTrpcServerHandlerOptions = {
};
export const openApiTrpcServerHandler = async (c: Context, { isBeta }: OpenApiTrpcServerHandlerOptions) => {
const endpoint = formatPath(isBeta ? API_V2_BETA_URL : API_V2_URL) as `/${string}`;
return createOpenApiFetchHandler<typeof appRouter>({
endpoint,
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
router: appRouter,
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
req: c.req.raw,
+1 -6
View File
@@ -1,4 +1,3 @@
import { formatPath } from '@documenso/lib/constants/app';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
@@ -6,14 +5,10 @@ import { trpcServer } from '@hono/trpc-server';
/**
* Trpc server for internal routes like /api/trpc/*
*
* `endpoint` must include the sub-path prefix (e.g. "/ESign") because the
* @hono/trpc-server adapter slices the prefix off the full URL pathname to
* compute the procedure name. Hono's `basePath` doesn't rewrite the URL.
*/
export const reactRouterTrpcServer = trpcServer({
router: appRouter,
endpoint: formatPath('/api/trpc'),
endpoint: '/api/trpc',
createContext: async (_, c) => createTrpcContext({ c, requestSource: 'app' }),
onError: (opts) => handleTrpcRouterError(opts, 'trpc'),
});
+1 -5
View File
@@ -23,10 +23,6 @@ const cMapsDir = normalizePath(path.join(pdfjsDistPath, 'cmaps'));
* Do not configure any envs here.
*/
export default defineConfig({
// No trailing slash: the React Router dev server requires its `basename` to
// start with this raw value (see react-router.config.ts). Vite normalizes
// and joins asset URLs correctly either way.
base: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
css: {
postcss: {
plugins: [tailwindcss, autoprefixer],
@@ -121,7 +117,7 @@ export default defineConfig({
'nodemailer',
/playwright/,
'@playwright/browser-chromium',
'@documenso/skia-canvas',
'skia-canvas',
],
},
},
+3 -6
View File
@@ -50,12 +50,6 @@ ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
# Sub-path the app is served under (e.g. "/ESign"). Empty = root.
# Baked into the client bundle by Vite/React Router at build time; also
# required at runtime for SSR so window.__ENV__ exposes it to the client.
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH="$NEXT_PUBLIC_BASE_PATH"
# Telemetry credentials (optional, baked into image at build time)
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
ENV NEXT_PRIVATE_TELEMETRY_KEY="$NEXT_PRIVATE_TELEMETRY_KEY"
@@ -76,6 +70,7 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/package-lock.json ./package-lock.json
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
COPY --from=builder /app/patches ./patches
RUN npm ci
@@ -114,6 +109,8 @@ WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
# Copy the tailwind config files across
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --only=production
+1 -1
View File
@@ -7,7 +7,7 @@ services:
volumes:
- documenso_database:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
+1 -1
View File
@@ -8,7 +8,7 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
+1 -1
View File
@@ -8,7 +8,7 @@ services:
- POSTGRES_PASSWORD=password
- POSTGRES_DB=documenso
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
test: ['CMD-SHELL', 'pg_isready -U documenso']
interval: 1s
timeout: 5s
retries: 5
+4806 -3873
View File
File diff suppressed because it is too large Load Diff
+8 -45
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.17.0",
"version": "2.16.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -48,23 +48,20 @@
},
"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.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": {
+4 -2
View File
@@ -1,4 +1,4 @@
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import type { ClientResponse, InferRequestType } from 'hono/client';
import { hc } from 'hono/client';
@@ -36,6 +36,8 @@ type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$
export class AuthClient {
public client: AuthClientType;
private signOutredirectPath: string = '/signin';
constructor(options: { baseUrl: string }) {
this.client = hc<AuthAppType>(options.baseUrl);
}
@@ -43,7 +45,7 @@ export class AuthClient {
public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
await this.client.signout.$post();
window.location.href = redirectPath ?? formatPath('/signin');
window.location.href = redirectPath ?? this.signOutredirectPath;
}
public async signOutAllSessions() {
@@ -1,4 +1,4 @@
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
@@ -121,7 +121,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// Check if signups are disabled for this provider.
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
@@ -130,7 +130,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// Check domain restriction for new SSO users.
if (!isEmailDomainAllowedForSignup(email)) {
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
@@ -141,7 +141,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
@@ -213,18 +213,15 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
// eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
// The sub-path aware root, e.g. "/" or "/ESign/".
const defaultRedirectPath = formatPath('/');
if (redirectState !== storedState || !redirectPath) {
redirectPath = defaultRedirectPath;
redirectPath = '/';
}
if (!isValidReturnTo(redirectPath)) {
redirectPath = defaultRedirectPath;
redirectPath = '/';
}
redirectPath = normalizeReturnTo(redirectPath) || defaultRedirectPath;
redirectPath = normalizeReturnTo(redirectPath) || '/';
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
@@ -1,5 +1,4 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { formatPath } from '@documenso/lib/constants/app';
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
@@ -57,7 +56,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
return c.redirect(`/o/${orgUrl}`, 302);
}
let userToLink = await prisma.user.findFirst({
+7 -28
View File
@@ -1,25 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* Derive the default redirect target ("/" at the root, "/ESign/" when served under a sub-path).
*/
const getDefaultRedirect = () => {
try {
const pathname = new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
return `${pathname}/`;
} catch {
return '/';
}
};
const getWebAppOrigin = () => {
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
} catch {
return NEXT_PUBLIC_WEBAPP_URL();
}
};
/**
* Handle an optional redirect path.
*/
@@ -30,20 +10,19 @@ export const handleRequestRedirect = (redirectUrl?: string) => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== getWebAppOrigin()) {
window.location.href = getDefaultRedirect();
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/';
} else {
window.location.href = redirectUrl;
}
};
export const handleSignInRedirect = (redirectUrl?: string) => {
const target = redirectUrl ?? getDefaultRedirect();
const url = new URL(target, NEXT_PUBLIC_WEBAPP_URL());
export const handleSignInRedirect = (redirectUrl: string = '/') => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== getWebAppOrigin()) {
window.location.href = getDefaultRedirect();
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/';
} else {
window.location.href = target;
window.location.href = redirectUrl;
}
};
+1 -1
View File
@@ -19,7 +19,7 @@
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^19.2.7",
"react": "^18",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}
+1 -4
View File
@@ -12,10 +12,7 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
const requestHeaders = headers ?? {};
// Note: the path must be appended rather than passed as the `new URL()` path
// argument, since a leading-slash path replaces the sub-path that
// NEXT_PUBLIC_WEBAPP_URL may carry (e.g. https://host/ESign).
const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/limits`);
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
if (teamId) {
requestHeaders['team-id'] = teamId.toString();
+17 -19
View File
@@ -1,19 +1,17 @@
export {
Body,
Button,
Column,
Container,
Font,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
render,
Section,
Tailwind,
Text,
} from 'react-email';
export { Body } from '@react-email/body';
export { Button } from '@react-email/button';
export { Column } from '@react-email/column';
export { Container } from '@react-email/container';
export { Font } from '@react-email/font';
export { Head } from '@react-email/head';
export { Heading } from '@react-email/heading';
export { Hr } from '@react-email/hr';
export { Html } from '@react-email/html';
export { Img } from '@react-email/img';
export { Link } from '@react-email/link';
export { Preview } from '@react-email/preview';
export { render } from '@react-email/render';
export { Row } from '@react-email/row';
export { Section } from '@react-email/section';
export { Tailwind } from '@react-email/tailwind';
export { Text } from '@react-email/text';
+20 -2
View File
@@ -19,9 +19,27 @@
"dependencies": {
"@documenso/nodemailer-resend": "5.0.0",
"@documenso/tailwind-config": "*",
"@react-email/render": "2.1.0",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
"@react-email/code-block": "0.2.0",
"@react-email/code-inline": "0.0.5",
"@react-email/column": "0.0.13",
"@react-email/container": "0.0.15",
"@react-email/font": "0.0.9",
"@react-email/head": "0.0.12",
"@react-email/heading": "0.0.15",
"@react-email/hr": "0.0.11",
"@react-email/html": "0.0.11",
"@react-email/img": "0.0.11",
"@react-email/link": "0.0.12",
"@react-email/preview": "0.0.13",
"@react-email/render": "2.0.0",
"@react-email/row": "0.0.12",
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"nodemailer": "^9.0.0",
"react-email": "^6.9.0",
"react-email": "^5.0.6",
"resend": "^6.5.2"
},
"devDependencies": {
@@ -8,7 +8,7 @@ type SaveRequest<T, R> = {
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
const isProcessingRef = useRef(false);
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
@@ -107,39 +107,7 @@ export const EnvelopeEditorProvider = ({
const [_searchParams, setSearchParams] = useSearchParams();
/**
* The envelope is kept in a ref-backed external store instead of useState so
* that async consumers (debounced autosave callbacks, flushAutosave, resetForms)
* can synchronously read the latest value via `getEnvelope`.
*
* React subscribes to the store through useSyncExternalStore, keeping renders in
* sync without maintaining a separate copy of the state.
*/
const envelopeStoreRef = useRef(initialEnvelope);
const envelopeStoreSubscribersRef = useRef(new Set<() => void>());
const subscribeToEnvelopeStore = useCallback((onStoreChange: () => void) => {
envelopeStoreSubscribersRef.current.add(onStoreChange);
return () => {
envelopeStoreSubscribersRef.current.delete(onStoreChange);
};
}, []);
const getEnvelope = useCallback(() => envelopeStoreRef.current, []);
const setEnvelope = useCallback((action: React.SetStateAction<TEditorEnvelope>) => {
const next = typeof action === 'function' ? action(envelopeStoreRef.current) : action;
envelopeStoreRef.current = next;
for (const onStoreChange of envelopeStoreSubscribersRef.current) {
onStoreChange();
}
}, []);
const envelope = useSyncExternalStore(subscribeToEnvelopeStore, getEnvelope, getEnvelope);
const [envelope, _setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const isCscMode = IS_INSTANCE_CSC_MODE();
@@ -167,6 +135,8 @@ export const EnvelopeEditorProvider = ({
};
}, [isCscMode, providedEditorConfig]);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
@@ -186,6 +156,14 @@ export const EnvelopeEditorProvider = ({
});
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embedded !== undefined;
const editorFields = useEditorFields({
@@ -214,18 +192,16 @@ export const EnvelopeEditorProvider = ({
try {
let recipients: TEditorEnvelope['recipients'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients: localRecipients,
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
}
setEnvelope((prev) => ({
@@ -235,7 +211,9 @@ export const EnvelopeEditorProvider = ({
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(getEnvelope().fields);
editorFields.resetForm(
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
);
setAutosaveError(false);
} catch (err) {
@@ -270,18 +248,16 @@ export const EnvelopeEditorProvider = ({
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope: currentEnvelope, localFields });
fields = mapLocalFieldsToFields({ envelope, localFields });
}
setEnvelope((prev) => ({
@@ -333,7 +309,7 @@ export const EnvelopeEditorProvider = ({
try {
const response = !isEmbedded
? await updateEnvelopeMutation.mutateAsync({
envelopeId: getEnvelope().id,
envelopeId: envelope.id,
data,
meta,
})
@@ -491,14 +467,12 @@ export const EnvelopeEditorProvider = ({
};
const resetForms = () => {
const currentEnvelope = getEnvelope();
editorRecipients.resetForm({
recipients: currentEnvelope.recipients,
documentMeta: currentEnvelope.documentMeta,
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
});
editorFields.resetForm(currentEnvelope.fields);
editorFields.resetForm(envelopeRef.current.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
@@ -514,7 +488,7 @@ export const EnvelopeEditorProvider = ({
await Promise.allSettled(Array.from(pendingMutationsRef.current));
}
return getEnvelope();
return envelopeRef.current;
};
return (
-46
View File
@@ -6,42 +6,6 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_S
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
/**
* The sub-path the app is served under (no trailing slash), e.g. "/ESign".
* Returns an empty string when served at root.
*
* Prefers the explicit NEXT_PUBLIC_BASE_PATH (which is the same value baked
* into the Vite/React Router build). Falls back to the pathname of
* NEXT_PUBLIC_WEBAPP_URL so the function still works in dev when the env
* variable is unset.
*
* Avoid using this to build URLs, use {@link formatPath} instead. Reserve this
* for cases where the raw prefix itself is needed, such as path comparisons.
*/
export const getBasePath = (): string => {
const explicit = env('NEXT_PUBLIC_BASE_PATH');
if (explicit) {
return explicit.replace(/\/$/, '');
}
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
} catch {
return '';
}
};
/**
* Prefix a root-relative path with the app's base path.
*
* `formatPath('/api/trpc')` -> `/ESign/api/trpc` under sub-path hosting,
* `/api/trpc` otherwise.
*/
export const formatPath = (path: string): string => {
return `${getBasePath()}${path}`;
};
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
@@ -53,14 +17,6 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
/**
* Whether this instance is Documenso Cloud (managed SaaS).
*
* Used so we can show a different UI for Documenso Cloud and self-hosted instances since
* there are things like billing, upsells, documenso links, etc that don't make sense for self-hosted instances.
*/
export const IS_DOCUMENSO_CLOUD = () => env('NEXT_PUBLIC_IS_DOCUMENSO_CLOUD') === 'true';
export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
@@ -143,5 +99,3 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
return value;
};
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
-18
View File
@@ -43,20 +43,6 @@ export enum AppErrorCode {
*/
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
/**
* A completion request was made for a recipient that still has required
* fields which have not been inserted. Usually indicates the client's field
* state is out of sync with the server (e.g. a field insert failed to
* persist before submission).
*/
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
/**
* A completion request was made by a recipient in a sequential signing flow
* before the preceding recipients have signed.
*/
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
@@ -113,8 +99,6 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -323,8 +307,6 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
AppErrorCode.RECIPIENT_OUT_OF_TURN,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
+5 -5
View File
@@ -15,7 +15,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@ai-sdk/google-vertex": "3.0.81",
"@aws-sdk/client-s3": "^3.998.0",
"@aws-sdk/client-sesv2": "^3.998.0",
"@aws-sdk/cloudfront-signer": "^3.998.0",
@@ -29,7 +29,6 @@
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/core": "^5.6.0",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
@@ -43,7 +42,7 @@
"@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"ai": "^7.0.58",
"ai": "^5.0.104",
"bullmq": "^5.71.1",
"colord": "^2.9.3",
"csv-parse": "^6.1.0",
@@ -65,9 +64,10 @@
"postcss-selector-parser": "^7.1.4",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react": "^18",
"remeda": "^2.32.0",
"sharp": "0.35.3",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Canvas, Image, Path2D } from '@documenso/skia-canvas';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Canvas, Image, Path2D } from 'skia-canvas';
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D;
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
recipients: {
@@ -78,23 +78,10 @@ export const completeDocumentWithToken = async ({
},
});
// The most common cause is a stale signing page: the document was deleted,
// or the recipient was removed, after the link was opened. Surface a
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.recipients.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document ${envelope.id} has no recipient with the provided token`,
statusCode: 404,
});
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
}
const [recipient] = envelope.recipients;
@@ -111,19 +98,7 @@ export const completeDocumentWithToken = async ({
}
if (envelope.status !== DocumentStatus.PENDING) {
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
// Unreachable: guarded by the status check above.
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
};
throw new AppError(envelopeStatusErrorCode[envelope.status], {
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
statusCode: 400,
});
throw new Error(`Document ${envelope.id} must be pending`);
}
assertRecipientNotExpired(recipient);
@@ -141,10 +116,7 @@ export const completeDocumentWithToken = async ({
});
if (!isRecipientsTurn) {
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
statusCode: 400,
});
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
}
}
@@ -307,10 +279,7 @@ export const completeDocumentWithToken = async ({
}
if (fieldsContainUnsignedRequiredField(fields)) {
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
message: `Recipient ${recipient.id} has unsigned fields`,
statusCode: 400,
});
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
await prisma.$transaction(async (tx) => {
@@ -2,9 +2,8 @@
* !: This is a workaround to fix the memory leak in the skia-canvas library.
* !: Internals are ported from the original `konva/skia-backend.js` file.
*/
import { Canvas, DOMMatrix, Image, Path2D } from '@documenso/skia-canvas';
import { Konva } from 'konva/lib/_CoreInternals';
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
// @ts-expect-error skia-canvas satisfies the requirements
global.DOMMatrix = DOMMatrix;
@@ -38,6 +37,6 @@ Konva.Util.createImageElement = () => {
return node as unknown as HTMLImageElement;
};
Konva._renderBackend = '@documenso/skia-canvas';
Konva._renderBackend = 'skia-canvas';
export default Konva;
+1 -1
View File
@@ -1,8 +1,8 @@
import path from 'node:path';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FontLibrary } from '@documenso/skia-canvas';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { FontLibrary } from 'skia-canvas';
import { match } from 'ts-pattern';
/**
@@ -135,7 +135,7 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
let imageWidth = image.width;
let imageHeight = image.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
@@ -2,8 +2,8 @@
import '../konva/skia-backend';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Canvas } from '@documenso/skia-canvas';
import Konva from 'konva';
import type { Canvas } from 'skia-canvas';
import { renderField } from '../../universal/field-renderer/render-field';
import { ensureFontLibrary } from './helpers';
@@ -128,7 +128,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
let imageWidth = image.width;
let imageHeight = image.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
@@ -1,16 +1,14 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match, P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
@@ -1,16 +1,14 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, RecipientRole, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ let SkiaImage: any;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('@documenso/skia-canvas');
const mod = await import('skia-canvas');
SkiaImage = mod.Image;
}
})();
@@ -23,7 +23,7 @@ const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeig
let imageWidth = img.width;
let imageHeight = img.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
+2 -2
View File
@@ -1,10 +1,10 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { NEXT_PUBLIC_WEBAPP_URL, getBasePath } from '../constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { env } from '../utils/env';
export const getBaseUrl = () => {
if (typeof window !== 'undefined') {
return getBasePath();
return '';
}
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
+1 -3
View File
@@ -2,8 +2,6 @@ import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { formatPath } from '../../constants/app';
export type GetFileOptions = {
type: DocumentDataType;
data: string;
@@ -38,7 +36,7 @@ const getFileFromBytes64 = (data: string) => {
};
const getFileFromS3 = async (key: string) => {
const getPresignedUrlResponse = await fetch(formatPath('/api/files/presigned-get-url'), {
const getPresignedUrlResponse = await fetch(`/api/files/presigned-get-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+1 -2
View File
@@ -1,6 +1,5 @@
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { formatPath } from '../../constants/app';
import { AppError } from '../../errors/app-error';
type File = {
@@ -39,7 +38,7 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
formData.append('file', properFile);
const response = await fetch(formatPath('/api/files/upload-pdf'), {
const response = await fetch('/api/files/upload-pdf', {
method: 'POST',
headers: buildUploadAuthHeaders(options),
body: formData,
+3 -27
View File
@@ -1,15 +1,4 @@
import { getBasePath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* The origin of the web app, ignoring any sub-path NEXT_PUBLIC_WEBAPP_URL carries.
*/
const getWebAppOrigin = () => {
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
} catch {
return NEXT_PUBLIC_WEBAPP_URL();
}
};
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
export const isValidReturnTo = (returnTo?: string) => {
if (!returnTo) {
@@ -21,10 +10,7 @@ export const isValidReturnTo = (returnTo?: string) => {
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
// Compare against the origin, not the raw env value: when the app is served
// under a sub-path NEXT_PUBLIC_WEBAPP_URL is e.g. "https://host/ESign", which
// never equals a URL's origin ("https://host").
if (returnToUrl.origin !== getWebAppOrigin()) {
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
return false;
}
@@ -44,17 +30,7 @@ export const normalizeReturnTo = (returnTo?: string) => {
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
const basePath = getBasePath();
let pathname = returnToUrl.pathname;
// A root-relative returnTo ("/inbox") resolves to a pathname without the
// sub-path, so re-apply it when it is missing.
if (basePath && pathname !== basePath && !pathname.startsWith(`${basePath}/`)) {
pathname = `${basePath}${pathname}`;
}
return `${pathname}${returnToUrl.search}${returnToUrl.hash}`;
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
} catch {
return undefined;
}
+3 -4
View File
@@ -18,7 +18,7 @@ import {
} from 'lucide-react';
import type { ComponentType } from 'react';
import { FaUsers } from 'react-icons/fa6';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '../constants/app';
import { IS_BILLING_ENABLED } from '../constants/app';
import { canExecuteOrganisationAction } from './organisations';
import { canExecuteTeamAction } from './teams';
@@ -73,7 +73,6 @@ export const getSettingsNavGroups = ({
hasManageableBillingOrgs,
}: GetSettingsNavGroupsArgs): SettingsNavGroups => {
const isBillingEnabled = IS_BILLING_ENABLED();
const isDocumensoCloud = IS_DOCUMENSO_CLOUD();
const canManageOrg =
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
@@ -127,7 +126,7 @@ export const getSettingsNavGroups = ({
label: msg`Certificates`,
isSubNav: true,
},
...((isBillingEnabled && organisation.organisationClaim.flags.emailDomains) || isDocumensoCloud
...(isBillingEnabled && organisation.organisationClaim.flags.emailDomains
? [
{
key: 'email-domains',
@@ -155,7 +154,7 @@ export const getSettingsNavGroups = ({
label: msg`Groups`,
icon: GroupIcon,
},
...((isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal) || isDocumensoCloud
...(isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal
? [
{
key: 'sso',
+3 -3
View File
@@ -12,9 +12,9 @@
"@documenso/prisma": "*",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
"@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",
"formidable": "^3.5.4",
"luxon": "^3.7.2",
@@ -8,14 +8,13 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/cancel',
summary: 'Cancel envelope',
description: 'Cancel a pending envelope',
tags: ['Envelope'],
},
};
export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
envelopeId: z.string(),
reason: z.string().optional(),
});
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -8,13 +8,12 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/delete',
summary: 'Delete envelope',
description: 'Delete an envelope',
tags: ['Envelope'],
},
};
export const ZDeleteEnvelopeRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to delete.'),
envelopeId: z.string(),
});
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -12,32 +12,24 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/update',
summary: 'Update envelope',
description: 'Update envelope properties and settings',
tags: ['Envelope'],
},
};
export const ZUpdateEnvelopeRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to update.'),
envelopeId: z.string(),
data: z
.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z
.array(ZDocumentAccessAuthTypesSchema)
.describe('The authentication methods required to access the envelope.')
.optional(),
globalActionAuth: z
.array(ZDocumentActionAuthTypesSchema)
.describe('The authentication methods required to sign the envelope.')
.optional(),
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
templateType: z.nativeEnum(TemplateType).optional(),
})
.describe('The envelope properties to update.')
.optional(),
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
meta: ZDocumentMetaUpdateSchema.optional(),
});
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
@@ -603,7 +603,7 @@ export const recipientRouter = router({
// can't complete via this route — they go through the CSC sync sign
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
// for the credential-scope OAuth round-trip.
const envelope = await prisma.envelope.findFirst({
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
recipients: { some: { token } },
@@ -611,16 +611,6 @@ export const recipientRouter = router({
select: { signatureLevel: true, internalVersion: true },
});
// The most common cause is a stale signing page: the document was
// deleted, or the recipient was removed, after the link was opened.
// Surface a NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
if (isTspEnvelope(envelope)) {
return await prepareCscRecipientSigning({
recipientToken: token,

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