mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 23:32:29 +10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9825ea88b1 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -15,7 +15,6 @@ jobs:
|
|||||||
build_app:
|
build_app:
|
||||||
name: Build App
|
name: Build App
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -33,7 +32,6 @@ jobs:
|
|||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ jobs:
|
|||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
label-when-assigned:
|
label-when-assigned:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
steps:
|
||||||
- name: Label issue
|
- name: Label issue
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
label_issues:
|
label_issues:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v4
|
- uses: actions/labeler@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ jobs:
|
|||||||
build_and_publish_platform_containers:
|
build_and_publish_platform_containers:
|
||||||
name: Build and publish platform containers
|
name: Build and publish platform containers
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 60
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -79,7 +78,6 @@ jobs:
|
|||||||
create_and_publish_manifest:
|
create_and_publish_manifest:
|
||||||
name: Create and publish manifest
|
name: Create and publish manifest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
|
||||||
needs: build_and_publish_platform_containers
|
needs: build_and_publish_platform_containers
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ jobs:
|
|||||||
validate-pr:
|
validate-pr:
|
||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
id: lint_pr_title
|
id: lint_pr_title
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ jobs:
|
|||||||
pull_translations:
|
pull_translations:
|
||||||
name: Force pull translations
|
name: Force pull translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ jobs:
|
|||||||
pull_translations:
|
pull_translations:
|
||||||
name: Pull translations
|
name: Pull translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ jobs:
|
|||||||
extract_translations:
|
extract_translations:
|
||||||
name: Extract and upload translations
|
name: Extract and upload translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -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:
|
A document object contains the following properties:
|
||||||
|
|
||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
| ------------------- | -------------- | -------------------------------------------------------------------------------------------- |
|
| --------------- | -------------- | -------------------------------------------------------------- |
|
||||||
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
||||||
| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) |
|
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
||||||
| `internalVersion` | number | Internal envelope schema version |
|
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` |
|
||||||
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
| `title` | string | Document title |
|
||||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
|
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
|
||||||
| `title` | string | Document title |
|
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
||||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
|
| `externalId` | string \| null | Your custom identifier for the document |
|
||||||
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
| `createdAt` | string | ISO 8601 timestamp |
|
||||||
| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) |
|
| `updatedAt` | string | ISO 8601 timestamp |
|
||||||
| `externalId` | string \| null | Your custom identifier for the document |
|
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
|
||||||
| `userId` | number | ID of the user who owns the document |
|
| `deletedAt` | string \| null | Timestamp if soft-deleted |
|
||||||
| `teamId` | number | ID of the team the document belongs to |
|
| `recipients` | array | List of recipients and their signing status |
|
||||||
| `folderId` | string \| null | ID of the folder containing the document |
|
| `fields` | array | Signature and form fields on the document |
|
||||||
| `templateId` | number \| null | Legacy ID of the template this document was created from |
|
| `envelopeItems` | array | PDF files attached to the document |
|
||||||
| `authOptions` | object \| null | Access and action authentication requirements |
|
| `documentMeta` | object | Email settings, redirect URL, signing options |
|
||||||
| `formValues` | object \| null | Pre-filled form values |
|
|
||||||
| `publicTitle` | string | Public title shown on profile and direct-link pages |
|
|
||||||
| `publicDescription` | string | Public description shown on profile and direct-link pages |
|
|
||||||
| `createdAt` | string | ISO 8601 timestamp |
|
|
||||||
| `updatedAt` | string | ISO 8601 timestamp |
|
|
||||||
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
|
|
||||||
| `deletedAt` | string \| null | Timestamp if soft-deleted |
|
|
||||||
| `recipients` | array | List of recipients and their signing status |
|
|
||||||
| `fields` | array | Signature and form fields on the document |
|
|
||||||
| `envelopeItems` | array | PDF files attached to the document |
|
|
||||||
| `directLink` | object \| null | Direct-link signing configuration (`id`, `token`, `enabled`, `directTemplateRecipientId`) |
|
|
||||||
| `team` | object | Owning team (`id`, `url`) |
|
|
||||||
| `user` | object | Document owner (`id`, `name`, `email`) |
|
|
||||||
| `documentMeta` | object | Email settings, redirect URL, signing options |
|
|
||||||
|
|
||||||
Documents created through the API have `source: "DOCUMENT"` — there is no separate `API` source value. To tag documents created by your integration, set `externalId` when creating them.
|
|
||||||
|
|
||||||
### Example Document Object
|
### Example Document Object
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "envelope_abc123xyz",
|
"id": "envelope_abc123xyz",
|
||||||
"secondaryId": "document_123",
|
|
||||||
"internalVersion": 2,
|
|
||||||
"type": "DOCUMENT",
|
"type": "DOCUMENT",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"source": "DOCUMENT",
|
"source": "API",
|
||||||
"visibility": "EVERYONE",
|
"visibility": "EVERYONE",
|
||||||
"templateType": "PRIVATE",
|
|
||||||
"title": "Service Agreement",
|
"title": "Service Agreement",
|
||||||
"externalId": "contract-2025-001",
|
"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",
|
"createdAt": "2025-01-15T10:30:00.000Z",
|
||||||
"updatedAt": "2025-01-15T10:35:00.000Z",
|
"updatedAt": "2025-01-15T10:35:00.000Z",
|
||||||
"completedAt": null,
|
"completedAt": null,
|
||||||
@@ -100,41 +73,23 @@ Documents created through the API have `source: "DOCUMENT"` — there is no sepa
|
|||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"id": 123,
|
"id": "field_123",
|
||||||
"secondaryId": "field_abc123",
|
|
||||||
"type": "SIGNATURE",
|
"type": "SIGNATURE",
|
||||||
"recipientId": 1,
|
|
||||||
"envelopeId": "envelope_abc123xyz",
|
|
||||||
"envelopeItemId": "envelope_item_xyz",
|
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"positionX": "10",
|
"positionX": 10,
|
||||||
"positionY": "80",
|
"positionY": 80,
|
||||||
"width": "30",
|
"width": 30,
|
||||||
"height": "5",
|
"height": 5,
|
||||||
"customText": "",
|
"recipientId": 1
|
||||||
"inserted": false,
|
|
||||||
"fieldMeta": null
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"envelopeItems": [
|
"envelopeItems": [
|
||||||
{
|
{
|
||||||
"id": "envelope_item_xyz",
|
"id": "envelope_item_xyz",
|
||||||
"envelopeId": "envelope_abc123xyz",
|
|
||||||
"documentDataId": "doc_data_abc123",
|
|
||||||
"title": "contract.pdf",
|
"title": "contract.pdf",
|
||||||
"order": 1
|
"order": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"directLink": null,
|
|
||||||
"team": {
|
|
||||||
"id": 1,
|
|
||||||
"url": "your-team"
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Jane Smith",
|
|
||||||
"email": "jane@example.com"
|
|
||||||
},
|
|
||||||
"documentMeta": {
|
"documentMeta": {
|
||||||
"subject": "Please sign this document",
|
"subject": "Please sign this document",
|
||||||
"message": "Hi, please review and sign this agreement.",
|
"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
|
## List Documents
|
||||||
|
|
||||||
Retrieve a paginated list of documents.
|
Retrieve a paginated list of documents.
|
||||||
@@ -161,7 +114,7 @@ GET /envelope
|
|||||||
| `page` | integer | Page number (default: 1) |
|
| `page` | integer | Page number (default: 1) |
|
||||||
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
||||||
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
|
| `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 |
|
| `source` | string | Filter by creation source |
|
||||||
| `folderId` | string | Filter by folder ID |
|
| `folderId` | string | Filter by folder ID |
|
||||||
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
||||||
@@ -201,8 +154,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, count } = await response.json();
|
const { data, pagination } = await response.json();
|
||||||
console.log(`Found ${count} documents`);
|
console.log(`Found ${pagination.totalItems} documents`);
|
||||||
|
|
||||||
// Filter by status
|
// Filter by status
|
||||||
const pendingResponse = await fetch(
|
const pendingResponse = await fetch(
|
||||||
@@ -244,10 +197,12 @@ const pendingDocs = await pendingResponse.json();
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"count": 42,
|
"pagination": {
|
||||||
"currentPage": 1,
|
"page": 1,
|
||||||
"perPage": 10,
|
"perPage": 10,
|
||||||
"totalPages": 5
|
"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 Document
|
||||||
|
|
||||||
Delete a document. Completed documents cannot be deleted.
|
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();
|
const { success } = await response.json();
|
||||||
|
|
||||||
```
|
````
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -791,7 +680,7 @@ const { success } = await response.json();
|
|||||||
{
|
{
|
||||||
"success": true
|
"success": true
|
||||||
}
|
}
|
||||||
```
|
````
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -805,11 +694,9 @@ POST /envelope/get-many
|
|||||||
|
|
||||||
### Request Body
|
### Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
|
| ------------- | ----- | -------- | --------------------- |
|
||||||
| `ids` | object | Yes | ID selector containing `type` and `ids` |
|
| `envelopeIds` | array | Yes | Array of document IDs |
|
||||||
| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` |
|
|
||||||
| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` |
|
|
||||||
|
|
||||||
### Code Examples
|
### Code Examples
|
||||||
|
|
||||||
@@ -820,17 +707,12 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
|
|||||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"ids": {
|
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||||
"type": "envelopeId",
|
|
||||||
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
|
||||||
}
|
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value="TypeScript">
|
<Tab value="TypeScript">
|
||||||
```typescript
|
```typescript
|
||||||
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
|
|
||||||
|
|
||||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -838,36 +720,16 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ids: {
|
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
|
||||||
type: 'envelopeId',
|
|
||||||
ids: requestedIds,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data } = await response.json();
|
const documents = await response.json();
|
||||||
|
|
||||||
```
|
````
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</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
|
## 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. |
|
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
|
||||||
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
||||||
| `REJECTED` | A recipient rejected the document. |
|
| `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
|
### Status Transitions
|
||||||
|
|
||||||
@@ -886,13 +747,11 @@ The endpoint silently omits envelopes you cannot access instead of returning `40
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
DRAFT --> PENDING --> COMPLETED
|
DRAFT --> PENDING --> COMPLETED
|
||||||
PENDING --> REJECTED
|
PENDING --> REJECTED
|
||||||
PENDING --> CANCELLED
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **DRAFT to PENDING**: Call the distribute endpoint
|
- **DRAFT to PENDING**: Call the distribute endpoint
|
||||||
- **PENDING to COMPLETED**: All recipients complete their signing
|
- **PENDING to COMPLETED**: All recipients complete their signing
|
||||||
- **PENDING to REJECTED**: A recipient rejects the document
|
- **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">
|
<Callout type="warn">
|
||||||
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
||||||
@@ -914,8 +773,8 @@ flowchart LR
|
|||||||
| Parameter | Values | Description |
|
| Parameter | Values | Description |
|
||||||
| ---------- | ------------------------------------------- | ------------------------- |
|
| ---------- | ------------------------------------------- | ------------------------- |
|
||||||
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
||||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
|
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
|
||||||
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
|
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
|
||||||
| `folderId` | string | Filter by folder |
|
| `folderId` | string | Filter by folder |
|
||||||
|
|
||||||
### Sorting
|
### Sorting
|
||||||
@@ -941,10 +800,10 @@ async function getAllPendingDocuments() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, currentPage, totalPages } = await response.json();
|
const { data, pagination } = await response.json();
|
||||||
documents.push(...data);
|
documents.push(...data);
|
||||||
|
|
||||||
hasMore = currentPage < totalPages;
|
hasMore = page < pagination.totalPages;
|
||||||
page++;
|
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` | `GET /api/v2/envelope` |
|
||||||
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
| `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` | `POST /api/v2/envelope/create` |
|
||||||
| `POST /api/v2/document/create/beta` | `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` |
|
| `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` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||||
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
| `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` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
| `POST /api/v2/template/create/beta` | `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` |
|
| `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
|
## HTTP Rate Limits
|
||||||
|
|
||||||
The rate limit applies to:
|
|
||||||
|
|
||||||
- `/api/v1/*`
|
|
||||||
- `/api/v2/*`
|
|
||||||
- `/api/v2-beta/*`
|
|
||||||
|
|
||||||
**Limit:** 1000 requests per minute per IP address
|
**Limit:** 1000 requests per minute per IP address
|
||||||
**Response:** 429 Too Many Requests
|
**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.
|
this value, in which case you can be rate-limited before reaching the global limit.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
### Global per-IP 429 Response
|
### Rate Limit Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -33,22 +27,10 @@ The rate limit applies to:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rate Limit Headers
|
<Callout type="warn">
|
||||||
|
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
|
||||||
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
|
seconds before retrying.
|
||||||
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
|
</Callout>
|
||||||
and carry no rate limit headers:
|
|
||||||
|
|
||||||
| Header | Description |
|
|
||||||
| ----------------------- | ---------------------------------------------------------------------- |
|
|
||||||
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
|
|
||||||
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
|
|
||||||
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
|
|
||||||
|
|
||||||
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
|
|
||||||
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
|
|
||||||
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
|
|
||||||
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
|
|
||||||
|
|
||||||
## Resource Limits
|
## 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 |
|
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
||||||
| Direct Templates | 3 | 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
|
When you exceed a resource limit:
|
||||||
shape depends on the API version, and neither matches the global per-IP limiter's
|
|
||||||
`{ "error": "..." }` body.
|
|
||||||
|
|
||||||
On `/api/v1/*`, the body contains only a message:
|
|
||||||
|
|
||||||
```json
|
```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
|
## Error Codes
|
||||||
|
|
||||||
| Code | Status | Description |
|
| Code | Status | Description |
|
||||||
| ------------------- | ------ | ------------------------------------------------------------------ |
|
| ------------------- | ------ | ----------------------------- |
|
||||||
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
|
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
|
||||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -465,6 +465,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: false,
|
uploadSignatureEnabled: false,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
},
|
},
|
||||||
distributeDocument: true,
|
distributeDocument: true,
|
||||||
}),
|
}),
|
||||||
@@ -485,6 +486,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
|||||||
| `typedSignatureEnabled` | boolean | Allow typed signatures |
|
| `typedSignatureEnabled` | boolean | Allow typed signatures |
|
||||||
| `uploadSignatureEnabled` | boolean | Allow uploaded signature images |
|
| `uploadSignatureEnabled` | boolean | Allow uploaded signature images |
|
||||||
| `drawSignatureEnabled` | boolean | Allow drawn signatures |
|
| `drawSignatureEnabled` | boolean | Allow drawn signatures |
|
||||||
|
| `qrSignatureEnabled` | boolean | Allow QR code handoff to a mobile device |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ const response = await fetch(`${BASE_URL}/template/update`, {
|
|||||||
typedSignatureEnabled: true, // Allow typed signatures
|
typedSignatureEnabled: true, // Allow typed signatures
|
||||||
drawSignatureEnabled: true, // Allow drawn signatures
|
drawSignatureEnabled: true, // Allow drawn signatures
|
||||||
uploadSignatureEnabled: false, // Disable uploaded signatures
|
uploadSignatureEnabled: false, // Disable uploaded signatures
|
||||||
|
qrSignatureEnabled: true, // Allow QR code handoff to a mobile device
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1000,12 +1000,9 @@ async function fetchWithRetry(
|
|||||||
// Retry on rate limit
|
// Retry on rate limit
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const retryAfter = response.headers.get('Retry-After');
|
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 : baseDelayMs * Math.pow(2, attempt);
|
||||||
const delay = retryAfter
|
|
||||||
? parseInt(retryAfter) * 1000
|
|
||||||
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
||||||
console.log(`Rate limited, waiting ${delay}ms...`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ All webhook events share a common structure:
|
|||||||
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
||||||
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
|
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
|
||||||
| `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed |
|
| `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed |
|
||||||
|
| `qrSignatureEnabled` | boolean | Whether QR code handoff to a mobile device is allowed |
|
||||||
| `language` | string | Document language code |
|
| `language` | string | Document language code |
|
||||||
| `distributionMethod` | string | How document is distributed |
|
| `distributionMethod` | string | How document is distributed |
|
||||||
| `emailSettings` | object? | Custom email settings for this document |
|
| `emailSettings` | object? | Custom email settings for this document |
|
||||||
@@ -141,6 +142,7 @@ Triggered when a new document is created.
|
|||||||
"typedSignatureEnabled": true,
|
"typedSignatureEnabled": true,
|
||||||
"uploadSignatureEnabled": true,
|
"uploadSignatureEnabled": true,
|
||||||
"drawSignatureEnabled": true,
|
"drawSignatureEnabled": true,
|
||||||
|
"qrSignatureEnabled": true,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"distributionMethod": "EMAIL",
|
"distributionMethod": "EMAIL",
|
||||||
"emailSettings": null
|
"emailSettings": null
|
||||||
@@ -235,6 +237,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
|||||||
"typedSignatureEnabled": true,
|
"typedSignatureEnabled": true,
|
||||||
"uploadSignatureEnabled": true,
|
"uploadSignatureEnabled": true,
|
||||||
"drawSignatureEnabled": true,
|
"drawSignatureEnabled": true,
|
||||||
|
"qrSignatureEnabled": true,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"distributionMethod": "EMAIL",
|
"distributionMethod": "EMAIL",
|
||||||
"emailSettings": null
|
"emailSettings": null
|
||||||
@@ -435,6 +438,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
|||||||
"typedSignatureEnabled": true,
|
"typedSignatureEnabled": true,
|
||||||
"uploadSignatureEnabled": true,
|
"uploadSignatureEnabled": true,
|
||||||
"drawSignatureEnabled": true,
|
"drawSignatureEnabled": true,
|
||||||
|
"qrSignatureEnabled": true,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"distributionMethod": "EMAIL",
|
"distributionMethod": "EMAIL",
|
||||||
"emailSettings": null
|
"emailSettings": null
|
||||||
@@ -618,6 +622,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
|||||||
"typedSignatureEnabled": true,
|
"typedSignatureEnabled": true,
|
||||||
"uploadSignatureEnabled": true,
|
"uploadSignatureEnabled": true,
|
||||||
"drawSignatureEnabled": true,
|
"drawSignatureEnabled": true,
|
||||||
|
"qrSignatureEnabled": true,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"distributionMethod": "EMAIL",
|
"distributionMethod": "EMAIL",
|
||||||
"emailSettings": null
|
"emailSettings": null
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
"postinstall": "fumadocs-mdx"
|
"postinstall": "fumadocs-mdx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fumadocs-core": "16.14.3",
|
"fumadocs-core": "16.5.0",
|
||||||
"fumadocs-mdx": "15.2.3",
|
"fumadocs-mdx": "14.2.6",
|
||||||
"fumadocs-ui": "16.14.3",
|
"fumadocs-ui": "16.5.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"next": "16.3.0",
|
"next": "16.2.6",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next": "16.3.0"
|
"next": "16.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
+9
-15
@@ -3,7 +3,7 @@
|
|||||||
/* Inter Variable Fonts */
|
/* Inter Variable Fonts */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Inter";
|
font-family: "Inter";
|
||||||
src: url("/fonts/inter-variablefont_opsz,wght.woff2") format("woff2");
|
src: url("/fonts/inter-variablefont_opsz,wght.ttf") format("truetype-variations");
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
/* Inter Italic Variable Fonts */
|
/* Inter Italic Variable Fonts */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Inter";
|
font-family: "Inter";
|
||||||
src: url("/fonts/inter-italic-variablefont_opsz,wght.woff2") format("woff2");
|
src: url("/fonts/inter-italic-variablefont_opsz,wght.ttf") format("truetype-variations");
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
/* Caveat Variable Font */
|
/* Caveat Variable Font */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Caveat";
|
font-family: "Caveat";
|
||||||
src: url("/fonts/caveat-variablefont_wght.woff2") format("woff2");
|
src: url("/fonts/caveat-variablefont_wght.ttf") format("truetype-variations");
|
||||||
font-weight: 400 600;
|
font-weight: 400 600;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans";
|
font-family: "Noto Sans";
|
||||||
src: url("/fonts/noto-sans.woff2") format("woff2");
|
src: url("/fonts/noto-sans.ttf") format("truetype-variations");
|
||||||
font-weight: 400;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
/* Korean noto sans */
|
/* Korean noto sans */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Korean";
|
font-family: "Noto Sans Korean";
|
||||||
src: url("/fonts/noto-sans-korean.woff2") format("woff2");
|
src: url("/fonts/noto-sans-korean.ttf") format("truetype-variations");
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
/* Japanese noto sans */
|
/* Japanese noto sans */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Japanese";
|
font-family: "Noto Sans Japanese";
|
||||||
src: url("/fonts/noto-sans-japanese.woff2") format("woff2");
|
src: url("/fonts/noto-sans-japanese.ttf") format("truetype-variations");
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -56,19 +56,13 @@
|
|||||||
/* Chinese noto sans */
|
/* Chinese noto sans */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Chinese";
|
font-family: "Noto Sans Chinese";
|
||||||
src: url("/fonts/noto-sans-chinese.woff2") format("woff2");
|
src: url("/fonts/noto-sans-chinese.ttf") format("truetype-variations");
|
||||||
font-weight: 400;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
|
||||||
@apply antialiased;
|
|
||||||
font-optical-sizing: auto;
|
|
||||||
font-synthesis: style;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: "Inter";
|
--font-sans: "Inter";
|
||||||
--font-signature: "Caveat";
|
--font-signature: "Caveat";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
@@ -13,10 +14,21 @@ export type SignFieldSignatureDialogProps = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
|
qrSignatureContext?: TQrSignatureContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
|
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
|
||||||
({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => {
|
({
|
||||||
|
call,
|
||||||
|
fullName,
|
||||||
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
|
qrSignatureContext,
|
||||||
|
initialSignature,
|
||||||
|
}) => {
|
||||||
const [localSignature, setLocalSignature] = useState(initialSignature);
|
const [localSignature, setLocalSignature] = useState(initialSignature);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +48,8 @@ export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogP
|
|||||||
typedSignatureEnabled={typedSignatureEnabled}
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={drawSignatureEnabled}
|
drawSignatureEnabled={drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={qrSignatureEnabled}
|
||||||
|
qrSignatureContext={qrSignatureContext}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ export type EmbedDocumentFieldsProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: Pick<
|
metadata?: Pick<
|
||||||
DocumentMeta,
|
DocumentMeta,
|
||||||
'timezone' | 'dateFormat' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled'
|
| 'timezone'
|
||||||
|
| 'dateFormat'
|
||||||
|
| 'typedSignatureEnabled'
|
||||||
|
| 'uploadSignatureEnabled'
|
||||||
|
| 'drawSignatureEnabled'
|
||||||
|
| 'qrSignatureEnabled'
|
||||||
> | null;
|
> | null;
|
||||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
@@ -53,6 +58,7 @@ export const EmbedDocumentFields = ({ fields, metadata, onSignField, onUnsignFie
|
|||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
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 { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
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 { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
|
||||||
|
|
||||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
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({
|
toast({
|
||||||
title: _(toastMessage.title),
|
title: _(msg`Something went wrong`),
|
||||||
description: _(toastMessage.description),
|
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -466,6 +461,8 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||||
|
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { useState } from 'react';
|
|||||||
import { match, P } from 'ts-pattern';
|
import { match, P } from 'ts-pattern';
|
||||||
|
|
||||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
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 { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
||||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
||||||
@@ -142,12 +141,9 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
onDocumentError?.();
|
onDocumentError?.();
|
||||||
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(toastMessage.title),
|
title: _(msg`Error`),
|
||||||
description: _(toastMessage.description),
|
description: _(msg`Failed to complete the document. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -317,6 +313,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type SettingsSubset = Pick<
|
|||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
| 'uploadSignatureEnabled'
|
| 'uploadSignatureEnabled'
|
||||||
| 'drawSignatureEnabled'
|
| 'drawSignatureEnabled'
|
||||||
|
| 'qrSignatureEnabled'
|
||||||
| 'defaultRecipients'
|
| 'defaultRecipients'
|
||||||
| 'delegateDocumentOwnership'
|
| 'delegateDocumentOwnership'
|
||||||
| 'aiFeaturesEnabled'
|
| 'aiFeaturesEnabled'
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePadDialog
|
<SignaturePadDialog
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
|
||||||
fullName={user.name ?? ''}
|
fullName={user.name ?? ''}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ export const SignUpForm = ({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePadDialog
|
<SignaturePadDialog
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ export const AdminGlobalSettingsSection = ({
|
|||||||
</DetailsValue>
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
|
<DetailsCard label={<Trans>QR signature</Trans>}>
|
||||||
|
<DetailsValue>{booleanValue(settings.qrSignatureEnabled, inheritedSettings?.qrSignatureEnabled)}</DetailsValue>
|
||||||
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const Header = ({ className, fullWidth = false, ...props }: HeaderProps)
|
|||||||
<InboxIcon className="h-5 w-5 flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground" />
|
<InboxIcon className="h-5 w-5 flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground" />
|
||||||
|
|
||||||
{unreadCountData && unreadCountData.count > 0 && (
|
{unreadCountData && unreadCountData.count > 0 && (
|
||||||
<span className="absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums leading-none">
|
<span className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary font-semibold text-[10px] text-primary-foreground">
|
||||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
||||||
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 font-semibold text-primary-foreground text-xs tabular-nums">
|
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 font-semibold text-primary-foreground text-xs">
|
||||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
@@ -408,6 +409,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-17
@@ -14,7 +14,6 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { Field, Recipient } from '@prisma/client';
|
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 { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
|
||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
|
||||||
export type DocumentSigningCompleteDialogProps = {
|
export type DocumentSigningCompleteDialogProps = {
|
||||||
@@ -88,8 +85,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
position,
|
position,
|
||||||
disableNameInput = false,
|
disableNameInput = false,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const { t, i18n } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
@@ -178,18 +174,6 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
return;
|
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 { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
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 { Controller, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
|
||||||
|
|
||||||
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
|
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
@@ -103,12 +100,9 @@ export const DocumentSigningForm = ({
|
|||||||
try {
|
try {
|
||||||
await completeDocument({ nextSigner });
|
await completeDocument({ nextSigner });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
|
||||||
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(toastMessage.title),
|
title: _(msg`Error`),
|
||||||
description: _(toastMessage.description),
|
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,6 +254,8 @@ export const DocumentSigningForm = ({
|
|||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||||
|
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={documentMeta?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
|
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface DocumentSigningProviderProps {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export const DocumentSigningProvider = ({
|
|||||||
typedSignatureEnabled = true,
|
typedSignatureEnabled = true,
|
||||||
uploadSignatureEnabled = true,
|
uploadSignatureEnabled = true,
|
||||||
drawSignatureEnabled = true,
|
drawSignatureEnabled = true,
|
||||||
|
qrSignatureEnabled = true,
|
||||||
children,
|
children,
|
||||||
}: DocumentSigningProviderProps) => {
|
}: DocumentSigningProviderProps) => {
|
||||||
const [fullName, setFullName] = useState(initialFullName || '');
|
const [fullName, setFullName] = useState(initialFullName || '');
|
||||||
@@ -54,7 +56,7 @@ export const DocumentSigningProvider = ({
|
|||||||
const sig = initialSignature || '';
|
const sig = initialSignature || '';
|
||||||
const isBase64 = isBase64Image(sig);
|
const isBase64 = isBase64Image(sig);
|
||||||
|
|
||||||
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
|
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled || qrSignatureEnabled)) {
|
||||||
return sig;
|
return sig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -34,6 +34,7 @@ export type DocumentSigningSignatureFieldProps = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningSignatureField = ({
|
export const DocumentSigningSignatureField = ({
|
||||||
@@ -43,6 +44,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
}: DocumentSigningSignatureFieldProps) => {
|
}: DocumentSigningSignatureFieldProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -279,6 +281,8 @@ export const DocumentSigningSignatureField = ({
|
|||||||
typedSignatureEnabled={typedSignatureEnabled}
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={drawSignatureEnabled}
|
drawSignatureEnabled={drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={qrSignatureEnabled}
|
||||||
|
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentSigningDisclosure />
|
<DocumentSigningDisclosure />
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ export const EnvelopeSigningProvider = ({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!sig &&
|
!sig &&
|
||||||
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled) &&
|
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||||
|
envelope.documentMeta.drawSignatureEnabled ||
|
||||||
|
envelope.documentMeta.qrSignatureEnabled) &&
|
||||||
envelopeData.recipientSignature?.signatureImageAsBase64
|
envelopeData.recipientSignature?.signatureImageAsBase64
|
||||||
) {
|
) {
|
||||||
return envelopeData.recipientSignature.signatureImageAsBase64;
|
return envelopeData.recipientSignature.signatureImageAsBase64;
|
||||||
@@ -182,7 +184,12 @@ export const EnvelopeSigningProvider = ({
|
|||||||
return envelopeData.recipientSignature.typedSignature;
|
return envelopeData.recipientSignature.typedSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBase64 && (envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)) {
|
if (
|
||||||
|
isBase64 &&
|
||||||
|
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||||
|
envelope.documentMeta.drawSignatureEnabled ||
|
||||||
|
envelope.documentMeta.qrSignatureEnabled)
|
||||||
|
) {
|
||||||
return sig;
|
return sig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
|||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
envelopeExpirationPeriod,
|
envelopeExpirationPeriod,
|
||||||
reminderSettings,
|
reminderSettings,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export default function EnvelopeSignerForm() {
|
|||||||
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
|
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
|
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled}
|
||||||
|
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled,
|
||||||
|
recipientToken: envelopeData.recipient.token,
|
||||||
})
|
})
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
|
|||||||
+11
-4
@@ -148,11 +148,15 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
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?.();
|
onDocumentError?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
|
|
||||||
// toast a specific completion error message.
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -220,11 +224,14 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('err', 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?.();
|
onDocumentError?.();
|
||||||
|
|
||||||
// Rethrow so DocumentSigningCompleteDialog can toast a specific
|
|
||||||
// completion error message.
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children || (
|
{children || (
|
||||||
<p className="mt-auto font-semibold text-4xl text-foreground tabular-nums leading-8">
|
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
|
||||||
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ export const SettingsHeader = ({ children, title, subtitle, className, hideDivid
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('mb-4 flex flex-row items-center justify-between', className)}>
|
<div className={cn('mb-4 flex flex-row items-center justify-between', className)}>
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
<h2 className="font-bold text-xl leading-tight [text-wrap:balance]">{title}</h2>
|
<h2 className="font-bold text-xl">{title}</h2>
|
||||||
|
|
||||||
<p className="mt-1 max-w-[65ch] text-muted-foreground text-sm leading-normal [overflow-wrap:break-word] [text-wrap:pretty] md:mt-2">
|
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -186,9 +186,9 @@ export default function DocumentsPage() {
|
|||||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<h1 className="font-semibold text-2xl leading-tight tracking-tight md:text-3xl">
|
<h2 className="font-semibold text-4xl">
|
||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
</h1>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
|
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ export default function TeamsSettingsPage() {
|
|||||||
typedSignatureEnabled: null,
|
typedSignatureEnabled: null,
|
||||||
uploadSignatureEnabled: null,
|
uploadSignatureEnabled: null,
|
||||||
drawSignatureEnabled: null,
|
drawSignatureEnabled: null,
|
||||||
|
qrSignatureEnabled: null,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
}),
|
}),
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function TemplatesPage() {
|
|||||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<h1 className="font-semibold text-2xl leading-tight tracking-tight md:text-3xl">
|
<h1 className="truncate font-semibold text-2xl md:text-3xl">
|
||||||
<Trans>Templates</Trans>
|
<Trans>Templates</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,15 +137,15 @@ export default function TemplatesPage() {
|
|||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{activeQuery.data && activeQuery.data.count === 0 ? (
|
{activeQuery.data && activeQuery.data.count === 0 ? (
|
||||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4">
|
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||||
<Bird className="h-12 w-12 text-muted-foreground/60" strokeWidth={1.5} />
|
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="font-semibold text-foreground text-lg">
|
<h3 className="font-semibold text-lg">
|
||||||
<Trans>We're all empty</Trans>
|
<Trans>We're all empty</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="mt-2 max-w-[50ch] text-muted-foreground [text-wrap:pretty]">
|
<p className="mt-2 max-w-[50ch]">
|
||||||
{isOrgView ? (
|
{isOrgView ? (
|
||||||
<Trans>No organisation templates are shared with your team yet.</Trans>
|
<Trans>No organisation templates are shared with your team yet.</Trans>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
|||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
||||||
{sessionData?.user && <AuthenticatedHeader />}
|
{sessionData?.user && <AuthenticatedHeader />}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
import { Outlet } from 'react-router';
|
import { Outlet, useLocation } from 'react-router';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
// Todo: Use the layout params to hide instead of hardcoding the pathname.
|
||||||
|
const hideBackground = pathname.includes('mobile-signature');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||||
<div>
|
<div>
|
||||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
{!hideBackground && (
|
||||||
<img
|
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||||
src={backgroundPattern}
|
<img
|
||||||
alt="background pattern"
|
src={backgroundPattern}
|
||||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
alt="background pattern"
|
||||||
style={{
|
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
style={{
|
||||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
}}
|
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type {
|
||||||
|
TGetQrSignatureSessionResponse,
|
||||||
|
TQrSignatureSessionContext,
|
||||||
|
} from '@documenso/trpc/server/signature-router/qr/get-qr-signature-session.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
|
||||||
|
import { SignaturePadDraw } from '@documenso/ui/primitives/signature-pad/signature-pad-draw';
|
||||||
|
import { i18n } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { CheckCircle2Icon, ClockIcon, FileTextIcon, Loader2Icon, PenLineIcon, XCircleIcon } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import type { Route } from './+types/mobile-signature.$token';
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return [
|
||||||
|
{ title: i18n._(msg`Sign on mobile - Documenso`) },
|
||||||
|
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet, noimageindex' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileSignaturePage({ params }: Route.ComponentProps) {
|
||||||
|
const { token } = params;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: session,
|
||||||
|
isError: isSessionError,
|
||||||
|
isLoading: isSessionLoading,
|
||||||
|
} = trpc.signature.qr.getSession.useQuery(
|
||||||
|
{
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Do not refetch the session.
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSessionLoading || !session) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center text-center">
|
||||||
|
<Loader2Icon className="size-8 animate-spin text-muted-foreground" />
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Loading</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== 'VALID' || isSessionError) {
|
||||||
|
return <QrSignatureError reason={session.status !== 'VALID' ? session.status : undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg select-none px-4">
|
||||||
|
<QrSignature token={token} context={session.context} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type QrSignatureState = 'SIGNING' | 'SUCCESS' | 'EXPIRED' | 'ALREADY_SUBMITTED';
|
||||||
|
|
||||||
|
type QrSignatureProps = {
|
||||||
|
token: string;
|
||||||
|
context: TQrSignatureSessionContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QrSignature = ({ token, context }: QrSignatureProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [signature, setSignature] = useState('');
|
||||||
|
const [hasSubmissionError, setHasSubmissionError] = useState(false);
|
||||||
|
|
||||||
|
const [state, setState] = useState<QrSignatureState>('SIGNING');
|
||||||
|
|
||||||
|
// Portrait renders the pad in a bottom sheet beneath the document context;
|
||||||
|
// landscape renders a single card. This component only ever renders on the
|
||||||
|
// client (behind the session query), so the initial value can be read
|
||||||
|
// synchronously - no flicker on landscape devices.
|
||||||
|
const [isPortrait, setIsPortrait] = useState(
|
||||||
|
() => typeof window === 'undefined' || window.matchMedia('(orientation: portrait)').matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(orientation: portrait)');
|
||||||
|
|
||||||
|
setIsPortrait(mediaQuery.matches);
|
||||||
|
|
||||||
|
const onOrientationChange = (event: MediaQueryListEvent) => {
|
||||||
|
setIsPortrait(event.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', onOrientationChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', onOrientationChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { mutateAsync: completeQrSignature, isPending } = trpc.signature.qr.complete.useMutation({
|
||||||
|
// The session query must not refetch on completion: it would resolve to
|
||||||
|
// ALREADY_SUBMITTED and replace the success screen with an error card.
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextInfo = useMemo(
|
||||||
|
() =>
|
||||||
|
match(context)
|
||||||
|
.with({ type: 'DOCUMENT_SIGNATURE' }, (documentContext) => ({
|
||||||
|
title: documentContext.documentTitle,
|
||||||
|
subtitle: `${documentContext.teamName} · ${t`Signature requested`}`,
|
||||||
|
icon: <FileTextIcon className="size-6 text-primary" />,
|
||||||
|
}))
|
||||||
|
.with({ type: 'PROFILE_SIGNATURE' }, () => ({
|
||||||
|
title: t`Your signature`,
|
||||||
|
subtitle: t`Signature requested`,
|
||||||
|
icon: <PenLineIcon className="size-6 text-primary" />,
|
||||||
|
}))
|
||||||
|
// Context-less sessions show no subtitle, which would just repeat the title.
|
||||||
|
.with({ type: 'NONE' }, () => ({
|
||||||
|
title: t`Signature requested`,
|
||||||
|
subtitle: null,
|
||||||
|
icon: <PenLineIcon className="size-6 text-primary" />,
|
||||||
|
}))
|
||||||
|
.exhaustive(),
|
||||||
|
[context, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSubmitClick = async () => {
|
||||||
|
setHasSubmissionError(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completeQrSignature({
|
||||||
|
token,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
setState('SUCCESS');
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.EXPIRED_CODE || error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
setState('EXPIRED');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.INVALID_REQUEST) {
|
||||||
|
setState('ALREADY_SUBMITTED');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasSubmissionError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === 'EXPIRED' || state === 'ALREADY_SUBMITTED') {
|
||||||
|
return <QrSignatureError reason={state} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'SUCCESS') {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center text-center">
|
||||||
|
<CheckCircle2Icon className="size-10 text-primary" />
|
||||||
|
|
||||||
|
<h1 className="mt-4 font-semibold text-2xl">
|
||||||
|
<Trans>Success</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm">
|
||||||
|
<Trans>You can now return to your main device to continue.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPortrait) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Document context hero. */}
|
||||||
|
<div className="flex flex-col items-center pb-[45svh] text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-xl border border-primary/30 bg-primary/10">
|
||||||
|
{contextInfo.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mt-4 font-semibold text-2xl">{contextInfo.title}</h1>
|
||||||
|
|
||||||
|
{contextInfo.subtitle && <p className="mt-2 text-muted-foreground text-sm">{contextInfo.subtitle}</p>}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-muted-foreground text-xs">
|
||||||
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
|
<Trans>Connected</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Persistent signing sheet - cannot be dismissed. */}
|
||||||
|
<Sheet open>
|
||||||
|
<SheetContent
|
||||||
|
position="bottom"
|
||||||
|
size="content"
|
||||||
|
showOverlay={false}
|
||||||
|
className="h-auto select-none rounded-t-2xl border-t px-4 pt-4 pb-6 [&>button:last-child]:hidden"
|
||||||
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
onPointerDownOutside={(event) => event.preventDefault()}
|
||||||
|
onInteractOutside={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-muted" />
|
||||||
|
|
||||||
|
<SheetTitle className="font-semibold text-lg">
|
||||||
|
<Trans>Draw your signature</Trans>
|
||||||
|
</SheetTitle>
|
||||||
|
|
||||||
|
<div className="relative mt-3 flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25">
|
||||||
|
<SignaturePadDraw className="h-full w-full" value={signature} onChange={(value) => setSignature(value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSubmissionError && (
|
||||||
|
<p className="mt-2 text-destructive text-sm">
|
||||||
|
<Trans>Something went wrong. Please try again.</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!signature}
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => void onSubmitClick()}
|
||||||
|
>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Landscape: a single card, no sheet.
|
||||||
|
return (
|
||||||
|
// Need this to override the parent layout styling.
|
||||||
|
<div className="fixed inset-0 z-50 flex select-none items-center justify-center bg-background p-2">
|
||||||
|
{/* The column width IS the pad width: all height left beneath the fixed
|
||||||
|
h-12 header (100svh - 2*p-2 - h-12 - mb-2 = 100svh - 4.5rem) is
|
||||||
|
converted through the pad's 16/7 aspect ratio, clamped by the
|
||||||
|
viewport width. The header is w-full of the same column, so it always
|
||||||
|
matches the pad width exactly. */}
|
||||||
|
<div className="flex max-h-full w-[min(100%,calc((100svh-4.5rem)*16/7))] max-w-lg flex-col">
|
||||||
|
<div className="mb-2 flex h-12 w-full shrink-0 items-center justify-between rounded-lg border border-border bg-muted/25 px-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/30 bg-primary/10">
|
||||||
|
{contextInfo.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate font-semibold text-sm">{contextInfo.title}</h1>
|
||||||
|
|
||||||
|
<p className="truncate text-muted-foreground text-xs">{contextInfo.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="ml-2 flex-shrink-0 px-6"
|
||||||
|
disabled={!signature}
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => void onSubmitClick()}
|
||||||
|
>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex aspect-signature-pad w-full items-center justify-center rounded-md border border-border bg-muted/25">
|
||||||
|
<SignaturePadDraw className="h-full w-full" value={signature} onChange={(value) => setSignature(value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSubmissionError && (
|
||||||
|
<p className="mt-2 text-destructive text-sm">
|
||||||
|
<Trans>Something went wrong. Please try again.</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type QrSignatureErrorReason = Exclude<TGetQrSignatureSessionResponse['status'], 'VALID'>;
|
||||||
|
|
||||||
|
type QrSignatureErrorProps = {
|
||||||
|
reason?: QrSignatureErrorReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QrSignatureError = ({ reason }: QrSignatureErrorProps) => {
|
||||||
|
const content = match(reason)
|
||||||
|
.with('EXPIRED', () => ({
|
||||||
|
icon: <ClockIcon className="size-10 text-yellow-500" />,
|
||||||
|
title: <Trans>This link has expired</Trans>,
|
||||||
|
description: <Trans>Generate a new QR code on the original device and scan it again.</Trans>,
|
||||||
|
}))
|
||||||
|
.with('ALREADY_SUBMITTED', () => ({
|
||||||
|
icon: <CheckCircle2Icon className="size-10 text-primary" />,
|
||||||
|
title: <Trans>Signature already sent</Trans>,
|
||||||
|
description: <Trans>This link has already been used. Return to your computer to continue.</Trans>,
|
||||||
|
}))
|
||||||
|
.with('INVALID', () => ({
|
||||||
|
icon: <XCircleIcon className="size-10 text-muted-foreground" />,
|
||||||
|
title: <Trans>This signing request is invalid</Trans>,
|
||||||
|
description: (
|
||||||
|
<Trans>
|
||||||
|
The request is invalid or no longer exists. Scan the new QR code on the original device to try again.
|
||||||
|
</Trans>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.with(undefined, () => ({
|
||||||
|
icon: <XCircleIcon className="size-10 text-muted-foreground" />,
|
||||||
|
title: <Trans>Something went wrong</Trans>,
|
||||||
|
description: <Trans>We couldn't load this signing request. Please refresh the page to try again.</Trans>,
|
||||||
|
}))
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center text-center">
|
||||||
|
{content.icon}
|
||||||
|
|
||||||
|
<h1 className="mt-2 font-semibold text-2xl">{content.title}</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm">{content.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -266,6 +266,7 @@ const EmbedDirectTemplatePageV1 = ({ data }: { data: Awaited<ReturnType<typeof h
|
|||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider documentAuthOptions={template.authOptions} recipient={recipient} user={user}>
|
<DocumentSigningAuthProvider documentAuthOptions={template.authOptions} recipient={recipient} user={user}>
|
||||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
|
|||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
||||||
<EmbedSignDocumentV1ClientPage
|
<EmbedSignDocumentV1ClientPage
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
|||||||
drawSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
typedSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
qrSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
},
|
},
|
||||||
recipients: configuration.signers.map((signer) => ({
|
recipients: configuration.signers.map((signer) => ({
|
||||||
name: signer.name,
|
name: signer.name,
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ export default function EmbeddingAuthoringDocumentEditPage() {
|
|||||||
types.push(DocumentSignatureType.UPLOAD);
|
types.push(DocumentSignatureType.UPLOAD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.documentMeta?.qrSignatureEnabled) {
|
||||||
|
types.push(DocumentSignatureType.QR);
|
||||||
|
}
|
||||||
|
|
||||||
return types;
|
return types;
|
||||||
}, [document.documentMeta]);
|
}, [document.documentMeta]);
|
||||||
|
|
||||||
@@ -216,6 +220,10 @@ export default function EmbeddingAuthoringDocumentEditPage() {
|
|||||||
? configuration.meta.signatureTypes.length === 0 ||
|
? configuration.meta.signatureTypes.length === 0 ||
|
||||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
qrSignatureEnabled: configuration.meta.signatureTypes
|
||||||
|
? configuration.meta.signatureTypes.length === 0 ||
|
||||||
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
recipients: configuration.signers.map((signer) => ({
|
recipients: configuration.signers.map((signer) => ({
|
||||||
id: signer.nativeId,
|
id: signer.nativeId,
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
|||||||
types.push(DocumentSignatureType.UPLOAD);
|
types.push(DocumentSignatureType.UPLOAD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (template.templateMeta?.qrSignatureEnabled) {
|
||||||
|
types.push(DocumentSignatureType.QR);
|
||||||
|
}
|
||||||
|
|
||||||
return types;
|
return types;
|
||||||
}, [template.templateMeta]);
|
}, [template.templateMeta]);
|
||||||
|
|
||||||
@@ -215,6 +219,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
|||||||
? configuration.meta.signatureTypes.length === 0 ||
|
? configuration.meta.signatureTypes.length === 0 ||
|
||||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
qrSignatureEnabled: configuration.meta.signatureTypes
|
||||||
|
? configuration.meta.signatureTypes.length === 0 ||
|
||||||
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
recipients: configuration.signers.map((signer) => ({
|
recipients: configuration.signers.map((signer) => ({
|
||||||
id: signer.nativeId,
|
id: signer.nativeId,
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export default function MultisignPage() {
|
|||||||
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={selectedDocument.documentMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={selectedDocument.authOptions}
|
documentAuthOptions={selectedDocument.authOptions}
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
|||||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
|
||||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
|
||||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
|
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
|
||||||
|
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled ?? undefined,
|
||||||
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||||
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
|||||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, //
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, //
|
||||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, //
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, //
|
||||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, //
|
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, //
|
||||||
|
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled, //
|
||||||
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||||
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,12 +12,23 @@ type HandleSignatureFieldClickOptions = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
|
recipientToken?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSignatureFieldClick = async (
|
export const handleSignatureFieldClick = async (
|
||||||
options: HandleSignatureFieldClickOptions,
|
options: HandleSignatureFieldClickOptions,
|
||||||
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
|
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
|
||||||
const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options;
|
const {
|
||||||
|
field,
|
||||||
|
fullName,
|
||||||
|
signature,
|
||||||
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
|
recipientToken,
|
||||||
|
} = options;
|
||||||
|
|
||||||
if (field.type !== FieldType.SIGNATURE) {
|
if (field.type !== FieldType.SIGNATURE) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
@@ -40,6 +51,8 @@ export const handleSignatureFieldClick = async (
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
|
qrSignatureContext: recipientToken ? { type: 'DOCUMENT_SIGNATURE', recipientToken } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 => {
|
export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||||
return match(code)
|
return match(code)
|
||||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"esbuild": "^0.28.1",
|
"esbuild": "^0.27.0",
|
||||||
"remix-flat-routes": "^0.8.5",
|
"remix-flat-routes": "^0.8.5",
|
||||||
"rollup": "^4.53.3",
|
"rollup": "^4.53.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -76,6 +76,7 @@ COPY --from=builder /app/out/json/ .
|
|||||||
COPY --from=builder /app/out/package-lock.json ./package-lock.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/lingui.config.ts ./lingui.config.ts
|
||||||
|
COPY --from=builder /app/patches ./patches
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
@@ -114,6 +115,8 @@ WORKDIR /app
|
|||||||
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
||||||
# Copy the tailwind config files across
|
# Copy the tailwind config files across
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
|
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
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
|||||||
Generated
+3488
-3453
File diff suppressed because it is too large
Load Diff
+3
-5
@@ -64,7 +64,7 @@
|
|||||||
"@ts-rest/serverless": "^3.52.1",
|
"@ts-rest/serverless": "^3.52.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "^0.28.1",
|
"esbuild": "^0.27.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"inngest": "^3.54.0",
|
"inngest": "^3.54.0",
|
||||||
"inngest-cli": "^1.17.9",
|
"inngest-cli": "^1.17.9",
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"zod-prisma-types": "3.3.5"
|
"zod-prisma-types": "3.3.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google-vertex": "5.0.48",
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@libpdf/core": "^0.4.1",
|
"@libpdf/core": "^0.4.1",
|
||||||
"@lingui/conf": "^5.6.0",
|
"@lingui/conf": "^5.6.0",
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.14",
|
"@radix-ui/react-toggle": "^1.1.14",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.15",
|
"@radix-ui/react-toggle-group": "^1.1.15",
|
||||||
"@radix-ui/react-tooltip": "^1.2.12",
|
"@radix-ui/react-tooltip": "^1.2.12",
|
||||||
"ai": "^7.0.58",
|
"ai": "^5.0.104",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
@@ -130,14 +130,12 @@
|
|||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"sharp": "0.35.3",
|
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"@marsidev/react-turnstile": "^1.5.0",
|
"@marsidev/react-turnstile": "^1.5.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lodash": "4.18.1",
|
"lodash": "4.18.1",
|
||||||
"brace-expansion@1": "^1.1.18",
|
|
||||||
"pdfjs-dist": "5.4.296",
|
"pdfjs-dist": "5.4.296",
|
||||||
"postcss": "^8.5.19",
|
"postcss": "^8.5.19",
|
||||||
"react": "$react",
|
"react": "$react",
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
|
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
|
||||||
drawSignatureEnabled: body.meta.drawSignatureEnabled,
|
drawSignatureEnabled: body.meta.drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled: body.meta.qrSignatureEnabled,
|
||||||
distributionMethod: body.meta.distributionMethod,
|
distributionMethod: body.meta.distributionMethod,
|
||||||
emailSettings: body.meta.emailSettings,
|
emailSettings: body.meta.emailSettings,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export const ZCreateDocumentMutationSchema = z.object({
|
|||||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||||
uploadSignatureEnabled: z.boolean().optional().default(true),
|
uploadSignatureEnabled: z.boolean().optional().default(true),
|
||||||
drawSignatureEnabled: z.boolean().optional().default(true),
|
drawSignatureEnabled: z.boolean().optional().default(true),
|
||||||
|
// No default: omission must fall through to team/org settings.
|
||||||
|
qrSignatureEnabled: z.boolean().optional(),
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
})
|
})
|
||||||
@@ -340,6 +342,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
uploadSignatureEnabled: z.boolean(),
|
uploadSignatureEnabled: z.boolean(),
|
||||||
drawSignatureEnabled: z.boolean(),
|
drawSignatureEnabled: z.boolean(),
|
||||||
|
qrSignatureEnabled: z.boolean(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema,
|
emailSettings: ZDocumentEmailSettingsSchema,
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: false,
|
uploadSignatureEnabled: false,
|
||||||
drawSignatureEnabled: false,
|
drawSignatureEnabled: false,
|
||||||
|
qrSignatureEnabled: false,
|
||||||
emailReplyTo: userA.email,
|
emailReplyTo: userA.email,
|
||||||
emailSettings: {
|
emailSettings: {
|
||||||
recipientSigningRequest: false,
|
recipientSigningRequest: false,
|
||||||
@@ -295,6 +296,7 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
|
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
|
||||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
|
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
|
||||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
|
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
|
||||||
|
expect(envelope.documentMeta.qrSignatureEnabled).toBe(payload.meta.qrSignatureEnabled);
|
||||||
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
|
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
|
||||||
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
|
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ test.describe('AutoSave Settings Step', () => {
|
|||||||
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
|
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
|
||||||
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
|
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
|
||||||
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
|
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
|
||||||
|
expect(retrieved.documentMeta?.qrSignatureEnabled).toBe(true);
|
||||||
}).toPass();
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
|
|||||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||||
|
expect(envelope.documentMeta.qrSignatureEnabled).toBe(true);
|
||||||
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||||
|
|
||||||
const authOptions = parseAuthOptions(envelope.authOptions);
|
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
||||||
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
|
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
|
||||||
expect(teamSettings.drawSignatureEnabled).toEqual(false);
|
expect(teamSettings.drawSignatureEnabled).toEqual(false);
|
||||||
|
expect(teamSettings.qrSignatureEnabled).toEqual(true);
|
||||||
|
|
||||||
// Edit the team settings
|
// Edit the team settings
|
||||||
await page.goto(`/t/${team.url}/settings/document`);
|
await page.goto(`/t/${team.url}/settings/document`);
|
||||||
@@ -102,6 +103,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
||||||
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
|
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
|
||||||
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
|
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
|
||||||
|
expect(updatedTeamSettings.qrSignatureEnabled).toEqual(true);
|
||||||
|
|
||||||
const document = await seedTeamDocumentWithMeta(team);
|
const document = await seedTeamDocumentWithMeta(team);
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(documentMeta.typedSignatureEnabled).toEqual(true);
|
expect(documentMeta.typedSignatureEnabled).toEqual(true);
|
||||||
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
|
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
|
||||||
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
||||||
|
expect(documentMeta.qrSignatureEnabled).toEqual(true);
|
||||||
expect(documentMeta.language).toEqual('pl');
|
expect(documentMeta.language).toEqual('pl');
|
||||||
expect(documentMeta.timezone).toEqual('Europe/London');
|
expect(documentMeta.timezone).toEqual('Europe/London');
|
||||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { AnonymousVerificationTokenType, FieldType } from '@documenso/prisma/client';
|
||||||
|
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a zig-zag onto the drawing canvas so that it passes the minimum
|
||||||
|
* signature coverage threshold.
|
||||||
|
*/
|
||||||
|
const drawOnSignaturePad = async (page: Page) => {
|
||||||
|
const canvas = page.getByTestId('signature-pad-draw');
|
||||||
|
|
||||||
|
await canvas.waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
let capturedBox: { x: number; y: number; width: number; height: number } | null = null;
|
||||||
|
|
||||||
|
// `boundingBox()` can return null if the canvas is replaced mid-hydration,
|
||||||
|
// so poll until a measurable element is attached, capturing the box inside
|
||||||
|
// the retry closure so it is never re-fetched (and re-raced) afterwards.
|
||||||
|
await expect(async () => {
|
||||||
|
capturedBox = await canvas.boundingBox();
|
||||||
|
|
||||||
|
expect(capturedBox).not.toBeNull();
|
||||||
|
expect(capturedBox?.width ?? 0).toBeGreaterThan(0);
|
||||||
|
}).toPass({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// TS cannot see the closure assignment above, so widen the type back out.
|
||||||
|
const box = capturedBox as { x: number; y: number; width: number; height: number } | null;
|
||||||
|
|
||||||
|
if (!box) {
|
||||||
|
throw new Error('Signature pad canvas not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.move(box.x + box.width * 0.15, box.y + box.height * 0.5);
|
||||||
|
await page.mouse.down();
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await page.mouse.move(box.x + box.width * (0.15 + i * 0.09), box.y + box.height * (i % 2 === 0 ? 0.25 : 0.75), {
|
||||||
|
steps: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.up();
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[QR_SIGNATURE]: complete signing via mobile qr handoff', async ({ page, browser }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: ['qr-signer@test.documenso.com'],
|
||||||
|
teamId: team.id,
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
await page.goto(`/sign/${recipient.token}`);
|
||||||
|
|
||||||
|
// Wait for the client-side PDF render so we know the page has hydrated
|
||||||
|
// before interacting with the signature pad.
|
||||||
|
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
// Open the signature dialog and switch to the Mobile tab.
|
||||||
|
await page.getByTestId('signature-pad-dialog-button').click();
|
||||||
|
await page.getByRole('tab', { name: 'Mobile' }).click();
|
||||||
|
|
||||||
|
// Read the handoff URL rendered beneath the QR code.
|
||||||
|
await expect(page.getByTestId('signature-pad-qr-url')).toBeVisible();
|
||||||
|
const handoffUrl = await page.getByTestId('signature-pad-qr-url').textContent();
|
||||||
|
|
||||||
|
expect(handoffUrl).toContain('/mobile-signature/');
|
||||||
|
|
||||||
|
// Open the mobile page in a fully isolated browser context (no shared
|
||||||
|
// cookies or session) to prove the handoff requires no authentication.
|
||||||
|
// A realistic landscape-phone viewport: the pad sizes itself dynamically to
|
||||||
|
// the viewport, and the primitive's minimum-coverage check is a percentage
|
||||||
|
// of the canvas area - a desktop-sized context would demand far more ink
|
||||||
|
// than the drawn zigzag provides.
|
||||||
|
const mobileContext = await browser.newContext({ viewport: { width: 844, height: 390 } });
|
||||||
|
const mobilePage = await mobileContext.newPage();
|
||||||
|
|
||||||
|
await mobilePage.goto(handoffUrl ?? '');
|
||||||
|
|
||||||
|
// The phone page renders the signing card (landscape layout at the default
|
||||||
|
// test viewport) with Next disabled until a valid signature is drawn.
|
||||||
|
await expect(mobilePage.getByTestId('signature-pad-draw')).toBeVisible();
|
||||||
|
await expect(mobilePage.getByRole('button', { name: 'Next' })).toBeDisabled();
|
||||||
|
|
||||||
|
await drawOnSignaturePad(mobilePage);
|
||||||
|
|
||||||
|
await mobilePage.getByRole('button', { name: 'Next' }).click();
|
||||||
|
|
||||||
|
await expect(mobilePage.getByText('Success')).toBeVisible();
|
||||||
|
|
||||||
|
await mobileContext.close();
|
||||||
|
|
||||||
|
// The desktop pad should receive the signature within a poll interval.
|
||||||
|
await expect(page.getByTestId('signature-pad-qr-preview')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The session is single-use: the desktop pickup deletes the row on read, and
|
||||||
|
// a missing row is indistinguishable from an expired one by design. So a
|
||||||
|
// revisit must show the expired page (not "Signature already sent"), which
|
||||||
|
// proves the deletion happened.
|
||||||
|
const revisitContext = await browser.newContext();
|
||||||
|
const revisitPage = await revisitContext.newPage();
|
||||||
|
|
||||||
|
await revisitPage.goto(handoffUrl ?? '');
|
||||||
|
|
||||||
|
await expect(revisitPage.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||||
|
|
||||||
|
await revisitContext.close();
|
||||||
|
|
||||||
|
// Direct proof of consumption: the token row must be gone from the database.
|
||||||
|
const consumedToken = (handoffUrl ?? '').split('/mobile-signature/')[1];
|
||||||
|
|
||||||
|
const consumedRow = await prisma.anonymousVerificationToken.findFirst({
|
||||||
|
where: { token: consumedToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consumedRow).toBeNull();
|
||||||
|
|
||||||
|
// Confirm and finish signing the document.
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
|
||||||
|
await page.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])').first().click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||||
|
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[QR_SIGNATURE]: mobile tab hidden when qr disabled', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: ['qr-disabled-signer@test.documenso.com'],
|
||||||
|
teamId: team.id,
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seeded documents create their meta row with bare column defaults, which
|
||||||
|
// leave qrSignatureEnabled true, so disable it directly on the meta row.
|
||||||
|
await prisma.documentMeta.update({
|
||||||
|
where: { id: document.documentMetaId },
|
||||||
|
data: { qrSignatureEnabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
await page.goto(`/sign/${recipient.token}`);
|
||||||
|
|
||||||
|
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
await page.getByTestId('signature-pad-dialog-button').click();
|
||||||
|
|
||||||
|
// Waiting on the Draw tab first guarantees the tab list has rendered before
|
||||||
|
// asserting the Mobile tab is absent.
|
||||||
|
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Mobile' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[QR_SIGNATURE]: mobile tab shown when draw disabled but qr enabled', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: ['qr-only-signer@test.documenso.com'],
|
||||||
|
teamId: team.id,
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
});
|
||||||
|
|
||||||
|
// qrSignatureEnabled already defaults to true on seeded metas, but set it
|
||||||
|
// explicitly so the test still documents the required state if defaults change.
|
||||||
|
await prisma.documentMeta.update({
|
||||||
|
where: { id: document.documentMetaId },
|
||||||
|
data: { drawSignatureEnabled: false, qrSignatureEnabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
await page.goto(`/sign/${recipient.token}`);
|
||||||
|
|
||||||
|
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
await page.getByTestId('signature-pad-dialog-button').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Draw' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[QR_SIGNATURE]: unknown token shows expired page', async ({ page }) => {
|
||||||
|
await page.goto('/mobile-signature/this-token-does-not-exist');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[QR_SIGNATURE]: expired token shows expired page', async ({ page }) => {
|
||||||
|
const expiredToken = `qr-e2e-expired-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
|
await prisma.anonymousVerificationToken.create({
|
||||||
|
data: {
|
||||||
|
type: AnonymousVerificationTokenType.QR_SIGNATURE,
|
||||||
|
token: expiredToken,
|
||||||
|
expiresAt: new Date(Date.now() - 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/mobile-signature/${expiredToken}`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
|
|||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
|
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
|
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
|
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('combobox').filter({ hasText: 'QR code' })).toBeVisible();
|
||||||
|
|
||||||
// Go to document and check that the signatured tabs are correct.
|
// Go to document and check that the signatured tabs are correct.
|
||||||
await page.goto(`/sign/${document.recipients[0].token}`);
|
await page.goto(`/sign/${document.recipients[0].token}`);
|
||||||
@@ -34,6 +35,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
|
|||||||
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
|
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
|
||||||
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
|
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
|
||||||
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||||
@@ -45,8 +47,11 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/settings/document`,
|
redirectPath: `/t/${team.url}/settings/document`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
// The 'QR code' signature type is surfaced as the 'Mobile' tab on the signing dialog.
|
||||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
|
||||||
|
const tabNameForOption = (option: string) => (option === 'QR code' ? 'Mobile' : option);
|
||||||
|
|
||||||
|
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
|
||||||
|
|
||||||
for (const tabs of tabTest) {
|
for (const tabs of tabTest) {
|
||||||
await page.goto(`/t/${team.url}/settings/document`);
|
await page.goto(`/t/${team.url}/settings/document`);
|
||||||
@@ -57,9 +62,10 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
|||||||
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
||||||
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
||||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
|
||||||
|
|
||||||
// Clear all selected items.
|
// Clear all selected items.
|
||||||
for (const tab of allTabs) {
|
for (const tab of allSignatureOptions) {
|
||||||
const item = page.getByRole('option', { name: tab });
|
const item = page.getByRole('option', { name: tab });
|
||||||
|
|
||||||
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
||||||
@@ -90,12 +96,13 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
|||||||
await page.waitForSelector('[role="dialog"]');
|
await page.waitForSelector('[role="dialog"]');
|
||||||
|
|
||||||
// Check the tab values
|
// Check the tab values
|
||||||
for (const tab of allTabs) {
|
for (const option of allSignatureOptions) {
|
||||||
if (tabs.includes(tab)) {
|
const tabName = tabNameForOption(option);
|
||||||
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
|
|
||||||
|
if (tabs.includes(option)) {
|
||||||
|
await expect(page.getByRole('tab', { name: tabName })).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
// await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
|
await expect(page.getByRole('tab', { name: tabName })).toHaveCount(0);
|
||||||
await expect(page.getByRole('tab', { name: tab })).toHaveCount(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,8 +117,8 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/settings/document`,
|
redirectPath: `/t/${team.url}/settings/document`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
|
||||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
|
||||||
|
|
||||||
for (const tabs of tabTest) {
|
for (const tabs of tabTest) {
|
||||||
await page.goto(`/t/${team.url}/settings/document`);
|
await page.goto(`/t/${team.url}/settings/document`);
|
||||||
@@ -122,9 +129,10 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
|||||||
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
||||||
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
||||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
|
||||||
|
|
||||||
// Clear all selected items.
|
// Clear all selected items.
|
||||||
for (const tab of allTabs) {
|
for (const tab of allSignatureOptions) {
|
||||||
const item = page.getByRole('option', { name: tab });
|
const item = page.getByRole('option', { name: tab });
|
||||||
|
|
||||||
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
||||||
@@ -176,5 +184,6 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
|||||||
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
|
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
|
||||||
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
|
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
|
||||||
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
|
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
|
||||||
|
expect(document?.documentMeta?.qrSignatureEnabled).toEqual(tabs.includes('QR code'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
|||||||
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
|
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
|
||||||
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
|
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
|
||||||
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
|
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
|
||||||
|
expect(retrievedTemplate.templateMeta?.qrSignatureEnabled).toBe(true);
|
||||||
}).toPass();
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,4 +77,11 @@ export const DOCUMENT_SIGNATURE_TYPES = {
|
|||||||
}),
|
}),
|
||||||
value: DocumentSignatureType.UPLOAD,
|
value: DocumentSignatureType.UPLOAD,
|
||||||
},
|
},
|
||||||
|
[DocumentSignatureType.QR]: {
|
||||||
|
label: msg({
|
||||||
|
message: `QR code`,
|
||||||
|
context: `Sign using a mobile phone via QR code`,
|
||||||
|
}),
|
||||||
|
value: DocumentSignatureType.QR,
|
||||||
|
},
|
||||||
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
|
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ export const SIGNATURE_CANVAS_DPI = 2;
|
|||||||
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
|
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
||||||
|
|
||||||
|
export const QR_SIGNATURE_TOKEN_EXPIRY_MINUTES = 10;
|
||||||
|
|||||||
@@ -43,20 +43,6 @@ export enum AppErrorCode {
|
|||||||
*/
|
*/
|
||||||
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
|
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
|
* A signer recipient does not have a signature field assigned. Thrown when
|
||||||
* distributing an envelope or using a direct template where at least one
|
* 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_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { 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_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
[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_LEGACY,
|
||||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||||
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
||||||
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
|
|
||||||
AppErrorCode.RECIPIENT_OUT_OF_TURN,
|
|
||||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||||
AppErrorCode.CSC_CERT_INVALID,
|
AppErrorCode.CSC_CERT_INVALID,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/inte
|
|||||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||||
|
import { CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION } from './definitions/internal/cleanup-anonymous-tokens';
|
||||||
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
||||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||||
@@ -64,6 +65,7 @@ export const jobsClient = new JobClient([
|
|||||||
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
|
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
|
||||||
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
||||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||||
|
CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION,
|
||||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||||
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TCleanupAnonymousTokensJobDefinition } from './cleanup-anonymous-tokens';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 10_000;
|
||||||
|
|
||||||
|
export const run = async ({ io }: { payload: TCleanupAnonymousTokensJobDefinition; io: JobRunIO }) => {
|
||||||
|
// Snapshot the cutoff so the run is bounded by the rows that were already
|
||||||
|
// expired when it started, rather than chasing rows expiring mid-run.
|
||||||
|
const cutoff = new Date();
|
||||||
|
|
||||||
|
let totalDeleted = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Postgres doesn't support DELETE with LIMIT, so batch via ctid to avoid
|
||||||
|
// long-running transactions that could lock the table.
|
||||||
|
deleted = await prisma.$executeRaw`
|
||||||
|
DELETE FROM "AnonymousVerificationToken"
|
||||||
|
WHERE ctid IN (
|
||||||
|
SELECT ctid FROM "AnonymousVerificationToken"
|
||||||
|
WHERE "expiresAt" < ${cutoff}
|
||||||
|
LIMIT ${BATCH_SIZE}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
totalDeleted += deleted;
|
||||||
|
} while (deleted >= BATCH_SIZE);
|
||||||
|
|
||||||
|
if (totalDeleted > 0) {
|
||||||
|
io.logger.info(`Cleaned up ${totalDeleted} expired anonymous verification tokens`);
|
||||||
|
} else {
|
||||||
|
io.logger.info('No expired anonymous verification tokens to clean up');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID = 'internal.cleanup-anonymous-tokens';
|
||||||
|
|
||||||
|
const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA = z.object({});
|
||||||
|
|
||||||
|
export type TCleanupAnonymousTokensJobDefinition = z.infer<typeof CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA>;
|
||||||
|
|
||||||
|
export const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION = {
|
||||||
|
id: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
|
||||||
|
name: 'Cleanup Anonymous Verification Tokens',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
|
||||||
|
schema: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA,
|
||||||
|
cron: '0 */2 * * *', // Every 2 hours.
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./cleanup-anonymous-tokens.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
|
||||||
|
TCleanupAnonymousTokensJobDefinition
|
||||||
|
>;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"clean": "rimraf node_modules"
|
"clean": "rimraf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-s3": "^3.998.0",
|
||||||
"@aws-sdk/client-sesv2": "^3.998.0",
|
"@aws-sdk/client-sesv2": "^3.998.0",
|
||||||
"@aws-sdk/cloudfront-signer": "^3.998.0",
|
"@aws-sdk/cloudfront-signer": "^3.998.0",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"@sindresorhus/slugify": "^3.0.0",
|
"@sindresorhus/slugify": "^3.0.0",
|
||||||
"@team-plain/typescript-sdk": "^5.11.0",
|
"@team-plain/typescript-sdk": "^5.11.0",
|
||||||
"@vvo/tzdb": "^6.196.0",
|
"@vvo/tzdb": "^6.196.0",
|
||||||
"ai": "^7.0.58",
|
"ai": "^5.0.104",
|
||||||
"bullmq": "^5.71.1",
|
"bullmq": "^5.71.1",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"remeda": "^2.32.0",
|
"remeda": "^2.32.0",
|
||||||
"sharp": "0.35.3",
|
"sharp": "0.34.5",
|
||||||
"stripe": "^12.18.0",
|
"stripe": "^12.18.0",
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "^5.9.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { AnonymousVerificationTokenType } from '@prisma/client';
|
||||||
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
@@ -24,12 +25,14 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig
|
|||||||
id: sessionId,
|
id: sessionId,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
type: AnonymousVerificationTokenType.PASSKEY,
|
||||||
token: challenge,
|
token: challenge,
|
||||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
|
type: AnonymousVerificationTokenType.PASSKEY,
|
||||||
token: challenge,
|
token: challenge,
|
||||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type CreateDocumentMetaOptions = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@@ -53,6 +54,7 @@ export const updateDocumentMeta = async ({
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
@@ -132,6 +134,7 @@ export const updateDocumentMeta = async ({
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
nextSigner,
|
nextSigner,
|
||||||
recipientOverride,
|
recipientOverride,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
const envelope = await prisma.envelope.findFirst({
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||||
recipients: {
|
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);
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||||
|
|
||||||
if (envelope.recipients.length === 0) {
|
if (envelope.recipients.length === 0) {
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
||||||
message: `Document ${envelope.id} has no recipient with the provided token`,
|
|
||||||
statusCode: 404,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [recipient] = envelope.recipients;
|
const [recipient] = envelope.recipients;
|
||||||
@@ -111,19 +98,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (envelope.status !== DocumentStatus.PENDING) {
|
if (envelope.status !== DocumentStatus.PENDING) {
|
||||||
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
|
throw new Error(`Document ${envelope.id} must be pending`);
|
||||||
[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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRecipientNotExpired(recipient);
|
assertRecipientNotExpired(recipient);
|
||||||
@@ -141,10 +116,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isRecipientsTurn) {
|
if (!isRecipientsTurn) {
|
||||||
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
|
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
|
||||||
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,10 +279,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
if (fieldsContainUnsignedRequiredField(fields)) {
|
||||||
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
|
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||||
message: `Recipient ${recipient.id} has unsigned fields`,
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
allowDictateNextSigner: true,
|
allowDictateNextSigner: true,
|
||||||
language: true,
|
language: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user