mirror of
https://github.com/documenso/documenso.git
synced 2026-10-01 01:04:38 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d024a07f7 | ||
|
|
e73450dd08 | ||
|
|
5603a9e59d | ||
|
|
20ae849a1a | ||
|
|
6a99b40cba | ||
|
|
e1ad4a2c55 | ||
|
|
82918163c5 | ||
|
|
b97c22a607 | ||
|
|
389390c884 | ||
|
|
c5acba538e | ||
|
|
f5ab8a8ae3 | ||
|
|
3b20c2f960 | ||
|
|
5e8a434141 | ||
|
|
6a8bb4be04 | ||
|
|
2cac63a000 | ||
|
|
4aa3583e89 | ||
|
|
30a6b19b47 | ||
|
|
dabb7b7a0d | ||
|
|
cbb1cf7bef | ||
|
|
3ec877a68b | ||
|
|
bf406aa943 | ||
|
|
a2745ed3c2 | ||
|
|
937e9376d8 | ||
|
|
eba71d9be4 | ||
|
|
5082b475a0 | ||
|
|
4285c88f12 | ||
|
|
ae4b56dd88 | ||
|
|
ee8360585b | ||
|
|
9dc83bdb06 | ||
|
|
e532843aa6 | ||
|
|
dd08da7ea7 | ||
|
|
10f2b80025 | ||
|
|
73d58bdf31 | ||
|
|
1de57a9e43 | ||
|
|
7f11c163fe | ||
|
|
5beb57dbf0 | ||
|
|
ead3104896 | ||
|
|
75330166cc | ||
|
|
d42254ff52 | ||
|
|
914e325486 | ||
|
|
05f646b326 | ||
|
|
0099dd672a | ||
|
|
871c2a6f0e | ||
|
|
9bab1cddb3 | ||
|
|
3e0c1c444a | ||
|
|
6a8fe6f1ad | ||
|
|
779de01fe8 | ||
|
|
283c6d274b |
@@ -1,56 +0,0 @@
|
||||
---
|
||||
name: create-justification
|
||||
description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: agents
|
||||
workflow: decision-making
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets:
|
||||
|
||||
- A unique three-word identifier (e.g., `swift-emerald-river`)
|
||||
- Frontmatter with the current date and formatted title
|
||||
- Content you provide
|
||||
|
||||
## How to use
|
||||
|
||||
Run the script with a slug and content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-justification.ts "decision-name" "Justification content here"
|
||||
```
|
||||
|
||||
Or use heredoc for multi-line content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-justification.ts "decision-name" << HEREDOC
|
||||
Multi-line
|
||||
justification content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
## File format
|
||||
|
||||
Files are created as: `{three-word-id}-{slug}.md`
|
||||
|
||||
Example: `swift-emerald-river-decision-name.md`
|
||||
|
||||
The file includes frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Decision Name
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
## When to use me
|
||||
|
||||
Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
name: create-plan
|
||||
description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: agents
|
||||
workflow: planning
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I help you create new plan files in the `.agents/plans/` directory. Each plan file gets:
|
||||
|
||||
- A unique three-word identifier (e.g., `happy-blue-moon`)
|
||||
- Frontmatter with the current date and formatted title
|
||||
- Content you provide
|
||||
|
||||
## How to use
|
||||
|
||||
Run the script with a slug and content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-plan.ts "feature-name" "Plan content here"
|
||||
```
|
||||
|
||||
Or use heredoc for multi-line content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-plan.ts "feature-name" << HEREDOC
|
||||
Multi-line
|
||||
plan content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
## File format
|
||||
|
||||
Files are created as: `{three-word-id}-{slug}.md`
|
||||
|
||||
Example: `happy-blue-moon-feature-name.md`
|
||||
|
||||
The file includes frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Feature Name
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
## When to use me
|
||||
|
||||
Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
name: create-scratch
|
||||
description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: agents
|
||||
workflow: exploration
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets:
|
||||
|
||||
- A unique three-word identifier (e.g., `calm-teal-cloud`)
|
||||
- Frontmatter with the current date and formatted title
|
||||
- Content you provide
|
||||
|
||||
## How to use
|
||||
|
||||
Run the script with a slug and content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-scratch.ts "note-name" "Scratch content here"
|
||||
```
|
||||
|
||||
Or use heredoc for multi-line content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-scratch.ts "note-name" << HEREDOC
|
||||
Multi-line
|
||||
scratch content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
## File format
|
||||
|
||||
Files are created as: `{three-word-id}-{slug}.md`
|
||||
|
||||
Example: `calm-teal-cloud-note-name.md`
|
||||
|
||||
The file includes frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Note Name
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
## When to use me
|
||||
|
||||
Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
||||
@@ -0,0 +1,61 @@
|
||||
name: 'One-Click Deploy Provider Request'
|
||||
description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README
|
||||
title: 'One-Click Deploy Provider Request: [Provider Name]'
|
||||
labels: ['deploy-provider-request']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in adding a one-click deploy option for Documenso!
|
||||
|
||||
Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest.
|
||||
|
||||
**How this works:**
|
||||
|
||||
- 👍 this issue if you'd like to see Documenso deployable on this provider.
|
||||
- If community interest is high enough, we'll consider adding it to the README.
|
||||
- Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Provider Name
|
||||
placeholder: e.g. Railway
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Provider Website
|
||||
placeholder: e.g. https://railway.com
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Deploy/Template URL
|
||||
description: A link to an existing deployment template or deploy button URL, if one exists.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Who creates and maintains the deployment template?
|
||||
options:
|
||||
- The provider
|
||||
- Me / the community
|
||||
- Nobody yet
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Testing & Maintenance
|
||||
description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Why this provider?
|
||||
description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check the boxes that apply to this request.
|
||||
options:
|
||||
- label: I have searched existing issues to make sure this provider has not already been requested.
|
||||
- label: I understand that inclusion depends on community interest and is not guaranteed.
|
||||
- label: I understand that PRs adding deploy badges without a prior issue will be closed.
|
||||
@@ -2,7 +2,7 @@ name: 'Setup node'
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v22.x
|
||||
default: v24.x
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
build_app:
|
||||
name: Build App
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -32,6 +33,7 @@ jobs:
|
||||
build_docker:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -11,6 +11,7 @@ jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
label-when-assigned:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Label issue
|
||||
uses: actions/github-script@v6
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
label_issues:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
|
||||
@@ -13,6 +13,7 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
with:
|
||||
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
build_and_publish_platform_containers:
|
||||
name: Build and publish platform containers
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -78,6 +79,7 @@ jobs:
|
||||
create_and_publish_manifest:
|
||||
name: Create and publish manifest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: build_and_publish_platform_containers
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
validate-pr:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
id: lint_pr_title
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
pull_translations:
|
||||
name: Force pull translations
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -16,6 +16,7 @@ jobs:
|
||||
pull_translations:
|
||||
name: Pull translations
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
extract_translations:
|
||||
name: Extract and upload translations
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
# min-release-age = 7
|
||||
min-release-age = 7
|
||||
|
||||
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
|
||||
To run Documenso locally, you will need
|
||||
|
||||
- Node.js (v22 or above)
|
||||
- Node.js (v24 or above)
|
||||
- Postgres SQL Database
|
||||
- Docker (optional)
|
||||
|
||||
@@ -186,21 +186,37 @@ For full instructions, requirements, and configuration details, see the [Self Ho
|
||||
|
||||
### One-Click Deploys
|
||||
|
||||
#### Railway
|
||||
> [!NOTE]
|
||||
> Want to see another provider listed here? Please [open a provider request](https://github.com/documenso/documenso/issues/new?template=deploy-provider-request.yml) instead of a PR so the community can signal interest. PRs adding deploy badges without a prior issue will be closed.
|
||||
|
||||
[](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
#### Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
|
||||
#### Koyeb
|
||||
|
||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||
|
||||
#### Elestio
|
||||
|
||||
[](https://elest.io/open-source/documenso)
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="200">
|
||||
<a href="https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
||||
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="200">
|
||||
<a href="https://render.com/deploy?repo=https://github.com/documenso/documenso">
|
||||
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="200">
|
||||
<a href="https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile">
|
||||
<img src="https://www.koyeb.com/static/images/deploy/button.svg" alt="Deploy to Koyeb" height="40" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="200">
|
||||
<a href="https://elest.io/open-source/documenso">
|
||||
<img src="https://elest.io/images/logos/deploy-to-elestio-btn.png" alt="Deploy on Elestio" height="40" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="200"></td>
|
||||
<td align="center" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -28,35 +28,62 @@ Each document contains one or more PDF files, a list of recipients, and the fiel
|
||||
|
||||
A document object contains the following properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------------- | -------------- | -------------------------------------------------------------- |
|
||||
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
||||
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` |
|
||||
| `title` | string | Document title |
|
||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
|
||||
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
||||
| `externalId` | string \| null | Your custom identifier for the document |
|
||||
| `createdAt` | string | ISO 8601 timestamp |
|
||||
| `updatedAt` | string | ISO 8601 timestamp |
|
||||
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
|
||||
| `deletedAt` | string \| null | Timestamp if soft-deleted |
|
||||
| `recipients` | array | List of recipients and their signing status |
|
||||
| `fields` | array | Signature and form fields on the document |
|
||||
| `envelopeItems` | array | PDF files attached to the document |
|
||||
| `documentMeta` | object | Email settings, redirect URL, signing options |
|
||||
| Property | Type | Description |
|
||||
| ------------------- | -------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
||||
| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) |
|
||||
| `internalVersion` | number | Internal envelope schema version |
|
||||
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
|
||||
| `title` | string | Document title |
|
||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
|
||||
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
||||
| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) |
|
||||
| `externalId` | string \| null | Your custom identifier for the document |
|
||||
| `userId` | number | ID of the user who owns the document |
|
||||
| `teamId` | number | ID of the team the document belongs to |
|
||||
| `folderId` | string \| null | ID of the folder containing the document |
|
||||
| `templateId` | number \| null | Legacy ID of the template this document was created from |
|
||||
| `authOptions` | object \| null | Access and action authentication requirements |
|
||||
| `formValues` | object \| null | Pre-filled form values |
|
||||
| `publicTitle` | string | Public title shown on profile and direct-link pages |
|
||||
| `publicDescription` | string | Public description shown on profile and direct-link pages |
|
||||
| `createdAt` | string | ISO 8601 timestamp |
|
||||
| `updatedAt` | string | ISO 8601 timestamp |
|
||||
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
|
||||
| `deletedAt` | string \| null | Timestamp if soft-deleted |
|
||||
| `recipients` | array | List of recipients and their signing status |
|
||||
| `fields` | array | Signature and form fields on the document |
|
||||
| `envelopeItems` | array | PDF files attached to the document |
|
||||
| `directLink` | object \| null | Direct-link signing configuration (`id`, `token`, `enabled`, `directTemplateRecipientId`) |
|
||||
| `team` | object | Owning team (`id`, `url`) |
|
||||
| `user` | object | Document owner (`id`, `name`, `email`) |
|
||||
| `documentMeta` | object | Email settings, redirect URL, signing options |
|
||||
|
||||
Documents created through the API have `source: "DOCUMENT"` — there is no separate `API` source value. To tag documents created by your integration, set `externalId` when creating them.
|
||||
|
||||
### Example Document Object
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "envelope_abc123xyz",
|
||||
"secondaryId": "document_123",
|
||||
"internalVersion": 2,
|
||||
"type": "DOCUMENT",
|
||||
"status": "PENDING",
|
||||
"source": "API",
|
||||
"source": "DOCUMENT",
|
||||
"visibility": "EVERYONE",
|
||||
"templateType": "PRIVATE",
|
||||
"title": "Service Agreement",
|
||||
"externalId": "contract-2025-001",
|
||||
"userId": 1,
|
||||
"teamId": 1,
|
||||
"folderId": null,
|
||||
"templateId": null,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"publicTitle": "",
|
||||
"publicDescription": "",
|
||||
"createdAt": "2025-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2025-01-15T10:35:00.000Z",
|
||||
"completedAt": null,
|
||||
@@ -73,23 +100,41 @@ A document object contains the following properties:
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"id": "field_123",
|
||||
"id": 123,
|
||||
"secondaryId": "field_abc123",
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 1,
|
||||
"envelopeId": "envelope_abc123xyz",
|
||||
"envelopeItemId": "envelope_item_xyz",
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 80,
|
||||
"width": 30,
|
||||
"height": 5,
|
||||
"recipientId": 1
|
||||
"positionX": "10",
|
||||
"positionY": "80",
|
||||
"width": "30",
|
||||
"height": "5",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": null
|
||||
}
|
||||
],
|
||||
"envelopeItems": [
|
||||
{
|
||||
"id": "envelope_item_xyz",
|
||||
"envelopeId": "envelope_abc123xyz",
|
||||
"documentDataId": "doc_data_abc123",
|
||||
"title": "contract.pdf",
|
||||
"order": 1
|
||||
}
|
||||
],
|
||||
"directLink": null,
|
||||
"team": {
|
||||
"id": 1,
|
||||
"url": "your-team"
|
||||
},
|
||||
"user": {
|
||||
"id": 1,
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com"
|
||||
},
|
||||
"documentMeta": {
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hi, please review and sign this agreement.",
|
||||
@@ -99,6 +144,8 @@ A document object contains the following properties:
|
||||
}
|
||||
```
|
||||
|
||||
Field position and size values are stored as decimals and serialized as strings in API responses.
|
||||
|
||||
## List Documents
|
||||
|
||||
Retrieve a paginated list of documents.
|
||||
@@ -114,7 +161,7 @@ GET /envelope
|
||||
| `page` | integer | Page number (default: 1) |
|
||||
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
||||
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
|
||||
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` |
|
||||
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
|
||||
| `source` | string | Filter by creation source |
|
||||
| `folderId` | string | Filter by folder ID |
|
||||
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
||||
@@ -154,8 +201,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
|
||||
},
|
||||
});
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
console.log(`Found ${pagination.totalItems} documents`);
|
||||
const { data, count } = await response.json();
|
||||
console.log(`Found ${count} documents`);
|
||||
|
||||
// Filter by status
|
||||
const pendingResponse = await fetch(
|
||||
@@ -197,12 +244,10 @@ const pendingDocs = await pendingResponse.json();
|
||||
]
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 5,
|
||||
"totalItems": 42
|
||||
}
|
||||
"count": 42,
|
||||
"currentPage": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 5
|
||||
}
|
||||
```
|
||||
|
||||
@@ -628,6 +673,72 @@ The response includes signing URLs for each recipient:
|
||||
|
||||
---
|
||||
|
||||
## Cancel Document
|
||||
|
||||
Cancel a pending document. This changes its status from `PENDING` to `CANCELLED`.
|
||||
|
||||
```
|
||||
POST /envelope/cancel
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ----------------------------------- |
|
||||
| `envelopeId` | string | Yes | Document ID |
|
||||
| `reason` | string | No | Reason for cancelling the document |
|
||||
|
||||
### Code Examples
|
||||
|
||||
<Tabs items={['curl', 'TypeScript']}>
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "envelope_abc123",
|
||||
"reason": "The agreement is no longer needed."
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abc123',
|
||||
reason: 'The agreement is no longer needed.',
|
||||
}),
|
||||
});
|
||||
|
||||
const { success } = await response.json();
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- Only documents in `PENDING` status can be cancelled. Other statuses return `400`.
|
||||
- Cancellation is not idempotent. Cancelling the same document again returns `400`.
|
||||
- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`.
|
||||
- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook.
|
||||
- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document.
|
||||
|
||||
---
|
||||
|
||||
## Delete Document
|
||||
|
||||
Delete a document. Completed documents cannot be deleted.
|
||||
@@ -670,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
|
||||
|
||||
const { success } = await response.json();
|
||||
|
||||
````
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -680,7 +791,7 @@ const { success } = await response.json();
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
````
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -694,9 +805,11 @@ POST /envelope/get-many
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | ----- | -------- | --------------------- |
|
||||
| `envelopeIds` | array | Yes | Array of document IDs |
|
||||
| Field | Type | Required | Description |
|
||||
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
|
||||
| `ids` | object | Yes | ID selector containing `type` and `ids` |
|
||||
| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` |
|
||||
| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` |
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -707,12 +820,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||
"ids": {
|
||||
"type": "envelopeId",
|
||||
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
|
||||
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -720,16 +838,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: requestedIds,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const documents = await response.json();
|
||||
const { data } = await response.json();
|
||||
|
||||
````
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "envelope_abc123",
|
||||
"type": "DOCUMENT",
|
||||
"status": "PENDING",
|
||||
"title": "Service Agreement"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions.
|
||||
|
||||
---
|
||||
|
||||
## Document Statuses
|
||||
@@ -740,6 +878,7 @@ const documents = await response.json();
|
||||
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
|
||||
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
||||
| `REJECTED` | A recipient rejected the document. |
|
||||
| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. |
|
||||
|
||||
### Status Transitions
|
||||
|
||||
@@ -747,11 +886,13 @@ const documents = await response.json();
|
||||
flowchart LR
|
||||
DRAFT --> PENDING --> COMPLETED
|
||||
PENDING --> REJECTED
|
||||
PENDING --> CANCELLED
|
||||
```
|
||||
|
||||
- **DRAFT to PENDING**: Call the distribute endpoint
|
||||
- **PENDING to COMPLETED**: All recipients complete their signing
|
||||
- **PENDING to REJECTED**: A recipient rejects the document
|
||||
- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document
|
||||
|
||||
<Callout type="warn">
|
||||
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
||||
@@ -773,8 +914,8 @@ flowchart LR
|
||||
| Parameter | Values | Description |
|
||||
| ---------- | ------------------------------------------- | ------------------------- |
|
||||
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
|
||||
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
|
||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
|
||||
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
|
||||
| `folderId` | string | Filter by folder |
|
||||
|
||||
### Sorting
|
||||
@@ -800,10 +941,10 @@ async function getAllPendingDocuments() {
|
||||
},
|
||||
);
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
const { data, currentPage, totalPages } = await response.json();
|
||||
documents.push(...data);
|
||||
|
||||
hasMore = page < pagination.totalPages;
|
||||
hasMore = currentPage < totalPages;
|
||||
page++;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
||||
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
|
||||
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) |
|
||||
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
||||
@@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||
| ------------------------------------- | ------------------------------------------------ |
|
||||
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
|
||||
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) |
|
||||
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
||||
|
||||
@@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
|
||||
## HTTP Rate Limits
|
||||
|
||||
The rate limit applies to:
|
||||
|
||||
- `/api/v1/*`
|
||||
- `/api/v2/*`
|
||||
- `/api/v2-beta/*`
|
||||
|
||||
**Limit:** 1000 requests per minute per IP address
|
||||
**Response:** 429 Too Many Requests
|
||||
|
||||
@@ -19,7 +25,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
this value, in which case you can be rate-limited before reaching the global limit.
|
||||
</Callout>
|
||||
|
||||
### Rate Limit Response
|
||||
### Global per-IP 429 Response
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
|
||||
seconds before retrying.
|
||||
</Callout>
|
||||
### Rate Limit Headers
|
||||
|
||||
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
|
||||
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
|
||||
and carry no rate limit headers:
|
||||
|
||||
| Header | Description |
|
||||
| ----------------------- | ---------------------------------------------------------------------- |
|
||||
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
|
||||
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
|
||||
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
|
||||
|
||||
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
|
||||
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
|
||||
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
|
||||
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
|
||||
|
||||
## Resource Limits
|
||||
|
||||
@@ -44,24 +62,55 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
|
||||
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
||||
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
|
||||
|
||||
### Error Response
|
||||
### Organisation Limit 429 Responses
|
||||
|
||||
When you exceed a resource limit:
|
||||
Organisation windowed limits and organisation monthly quotas produce 429 responses whose body
|
||||
shape depends on the API version, and neither matches the global per-IP limiter's
|
||||
`{ "error": "..." }` body.
|
||||
|
||||
On `/api/v1/*`, the body contains only a message:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "You have reached your document limit for this month. Please upgrade your plan.",
|
||||
"code": "LIMIT_EXCEEDED",
|
||||
"statusCode": 400
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||
}
|
||||
```
|
||||
|
||||
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"data": {
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"httpStatus": 429,
|
||||
"appError": {
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
|
||||
their own window. Monthly quota responses carry no quota-specific rate limit headers or
|
||||
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Status | Description |
|
||||
| ------------------- | ------ | ----------------------------- |
|
||||
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
|
||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
||||
| Code | Status | Description |
|
||||
| ------------------- | ------ | ------------------------------------------------------------------ |
|
||||
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
|
||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
||||
|
||||
There are three sources of `TOO_MANY_REQUESTS` responses:
|
||||
|
||||
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
|
||||
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
|
||||
3. Organisation monthly quotas for the same three counters. Every authenticated API request
|
||||
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
|
||||
exhausted — not just envelope-related ones.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Documents created with a team token belong to that team:
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F 'payload={
|
||||
"type": "DOCUMENT",
|
||||
@@ -157,11 +157,11 @@ Retrieve all documents belonging to the team:
|
||||
```bash
|
||||
# List all team documents
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope" \
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
|
||||
# Filter by status
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
@@ -191,7 +191,7 @@ Templates created with a team token are shared across the team.
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/template/create" \
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F 'payload={
|
||||
"title": "NDA Template",
|
||||
@@ -269,7 +269,7 @@ console.log('Created team template:', template.id);
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X GET "https://app.documenso.com/api/v2/template" \
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -51,6 +51,7 @@ async function createAndSendDocument(
|
||||
pdfBuffer: Buffer,
|
||||
filename: string,
|
||||
title: string,
|
||||
externalId: string,
|
||||
recipients: Recipient[],
|
||||
): Promise<CreateAndSendResult> {
|
||||
const recipientPayload = recipients.map((recipient, index) => ({
|
||||
@@ -89,6 +90,7 @@ async function createAndSendDocument(
|
||||
JSON.stringify({
|
||||
type: 'DOCUMENT',
|
||||
title,
|
||||
externalId,
|
||||
recipients: recipientPayload,
|
||||
meta: {
|
||||
subject: `Please sign: ${title}`,
|
||||
@@ -145,6 +147,7 @@ const result = await createAndSendDocument(
|
||||
pdfBuffer,
|
||||
'contract.pdf',
|
||||
'Service Agreement',
|
||||
'nda-contract-ndac214',
|
||||
[
|
||||
{ email: 'client@example.com', name: 'John Smith', role: 'SIGNER' },
|
||||
{ email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' },
|
||||
@@ -172,6 +175,7 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \
|
||||
-F 'payload={
|
||||
"type": "DOCUMENT",
|
||||
"title": "Service Agreement",
|
||||
"externalId": "nda-contract-ndac214",
|
||||
"recipients": [
|
||||
{
|
||||
"email": "client@example.com",
|
||||
@@ -241,6 +245,8 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}'
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents).
|
||||
|
||||
---
|
||||
|
||||
## Workflow 2: Create Document from Template with Custom Data
|
||||
@@ -1000,9 +1006,12 @@ async function fetchWithRetry(
|
||||
// Retry on rate limit
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
|
||||
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter) * 1000
|
||||
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
||||
console.log(`Rate limited, waiting ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
|
||||
| `PORT` | Port the application listens on | `3000` |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — set to the volume-mount path (e.g. `/opt/documenso/cert.p12`). Only Docker Compose defaults this; plain `docker run` must set it explicitly | - |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
|
||||
@@ -136,6 +136,7 @@ docker run -d \
|
||||
-e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \
|
||||
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
|
||||
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \
|
||||
-e NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH="/opt/documenso/cert.p12" \
|
||||
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \
|
||||
-e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \
|
||||
-e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \
|
||||
@@ -154,6 +155,12 @@ A signing certificate is required for document signing. You have two options for
|
||||
- **Volume mount** — mount a `.p12` file from the host into the container at `/opt/documenso/cert.p12` (shown above). This is the simplest approach for small to moderate deployments.
|
||||
- **Base64-encoded contents** — set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` with the base64-encoded certificate string. Use this when file mounting is not available (e.g., Railway, Vercel).
|
||||
|
||||
<Callout type="warn">
|
||||
Plain `docker run` deployments must set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` explicitly. This
|
||||
prevents production deployments from accidentally using the insecure example certificate.
|
||||
Docker Compose sets the file path for you.
|
||||
</Callout>
|
||||
|
||||
For production deployments that require Adobe Approved Trust List recognition, consider using a [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) or another external HSM.
|
||||
|
||||
<Callout type="warn">
|
||||
@@ -178,6 +185,7 @@ NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
|
||||
NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
|
||||
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
|
||||
@@ -203,6 +211,12 @@ docker run -d \
|
||||
|
||||
Documenso provides health check endpoints for monitoring:
|
||||
|
||||
<Callout type="info">
|
||||
If a certificate is mounted but signing fails, ensure `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH`
|
||||
explicitly points to its path inside the container. Production does not use the development
|
||||
example certificate as a fallback.
|
||||
</Callout>
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| ------------------------- | -------------------------------------------------------------- |
|
||||
| `/api/health` | Checks database connectivity and certificate status |
|
||||
|
||||
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22 or later
|
||||
- npm 11 or later
|
||||
- Node.js 24 or later
|
||||
- npm 11.17 or later
|
||||
- PostgreSQL 14 or later
|
||||
- A Linux server (for systemd service setup)
|
||||
|
||||
|
||||
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
|
||||
|
||||
| Requirement | Version |
|
||||
| ----------- | ------- |
|
||||
| Node.js | 22+ |
|
||||
| npm | 11+ |
|
||||
| Node.js | 24+ |
|
||||
| npm | 11.17+ |
|
||||
|
||||
---
|
||||
|
||||
@@ -169,7 +169,7 @@ Documenso runs on:
|
||||
| MySQL/MariaDB | PostgreSQL-specific features required |
|
||||
| SQLite | Not suitable for production workloads |
|
||||
| MongoDB | Relational database required |
|
||||
| Node.js < 22 | Modern JavaScript features required |
|
||||
| Node.js < 24 | Modern JavaScript features required |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ To access the preferences, navigate to either the organisation or teams settings
|
||||
| **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. |
|
||||
| **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). |
|
||||
| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). |
|
||||
| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. |
|
||||
| **Delegate Document Ownership** | By default, documents created with a team API token are owned by the user who created the token. Enable this setting to let supported API requests assign ownership to another team member. |
|
||||
| **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. |
|
||||
|
||||
Document visibility, language, and signature settings can be overridden per document.
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"fumadocs-core": "16.5.0",
|
||||
"fumadocs-mdx": "14.2.6",
|
||||
"fumadocs-ui": "16.5.0",
|
||||
"fumadocs-core": "16.14.3",
|
||||
"fumadocs-mdx": "15.2.3",
|
||||
"fumadocs-ui": "16.14.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "16.2.6",
|
||||
"next": "^16.3.3",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "16.2.6"
|
||||
"next": "^16.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react": "^19.2.17",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM oven/bun:1 AS dependencies-env
|
||||
COPY . /app
|
||||
|
||||
FROM dependencies-env AS development-dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
WORKDIR /app
|
||||
RUN bun i --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS production-dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
WORKDIR /app
|
||||
RUN bun i --production
|
||||
|
||||
FROM dependencies-env AS build-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN bun run build
|
||||
|
||||
FROM dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["bun", "run", "start"]
|
||||
@@ -1,26 +0,0 @@
|
||||
FROM node:20-alpine AS dependencies-env
|
||||
RUN npm i -g pnpm
|
||||
COPY . /app
|
||||
|
||||
FROM dependencies-env AS development-dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
WORKDIR /app
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS production-dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
WORKDIR /app
|
||||
RUN pnpm i --prod --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS build-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN pnpm build
|
||||
|
||||
FROM dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
@@ -71,6 +72,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
const { toast } = useToast();
|
||||
const { t, i18n } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
@@ -200,6 +202,12 @@ export const EnvelopeDistributeDialog = ({
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'distribute_document',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
@@ -51,6 +52,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t, i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -95,6 +97,13 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'redistribute_document',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -290,10 +290,11 @@ export const EnvelopesBulkDownloadDialog = ({
|
||||
{isOverDownloadLimit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
|
||||
continue.
|
||||
</Trans>
|
||||
<Plural
|
||||
value={MAX_BULK_DOWNLOAD_ENVELOPES}
|
||||
one="You can download up to # document at a time. Deselect some documents to continue."
|
||||
other="You can download up to # documents at a time. Deselect some documents to continue."
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
@@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { File as FileIcon, Upload, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({
|
||||
|
||||
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
||||
|
||||
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
|
||||
|
||||
export type TemplateBulkSendDialogProps = {
|
||||
templateId: number;
|
||||
recipients: Array<{ email: string; name?: string | null }>;
|
||||
@@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
|
||||
|
||||
const form = useForm<TBulkSendFormSchema>({
|
||||
resolver: zodResolver(ZBulkSendFormSchema),
|
||||
defaultValues: {
|
||||
@@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
|
||||
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
|
||||
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(value);
|
||||
|
||||
if (!value) {
|
||||
setValidationError(null);
|
||||
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadTemplate = () => {
|
||||
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
|
||||
|
||||
@@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TBulkSendFormSchema) => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const csv = await values.file.text();
|
||||
|
||||
await uploadBulkSend({
|
||||
const result = await uploadBulkSend({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
csv: csv,
|
||||
sendImmediately: values.sendImmediately,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setValidationError(result.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="shrink-0" size="sm">
|
||||
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
setValidationError(null);
|
||||
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
type="button"
|
||||
variant="link"
|
||||
className="p-0 text-destructive text-xs hover:text-destructive"
|
||||
onClick={() => onChange(null)}
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
|
||||
form.resetField('file');
|
||||
}}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -218,6 +257,72 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
)}
|
||||
/>
|
||||
|
||||
{validationError !== null && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="max-h-32 overflow-y-auto">
|
||||
{match(validationError)
|
||||
.with({ type: 'PARSE_ERROR' }, () => (
|
||||
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
|
||||
))
|
||||
.with({ type: 'EMPTY' }, () => (
|
||||
<Trans>
|
||||
The CSV does not contain any rows. Please add at least one row of recipient details.
|
||||
</Trans>
|
||||
))
|
||||
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
|
||||
<Trans>
|
||||
<Plural value={rowCount} one="The CSV contains # row." other="The CSV contains # rows." />{' '}
|
||||
<Plural
|
||||
value={maxRows}
|
||||
one="A maximum of # row is allowed per upload."
|
||||
other="A maximum of # rows is allowed per upload."
|
||||
/>
|
||||
</Trans>
|
||||
))
|
||||
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
|
||||
<>
|
||||
<Trans>
|
||||
The CSV is missing the following required columns. Please download the template CSV for the
|
||||
correct format.
|
||||
</Trans>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{missingColumns.map((column) => (
|
||||
<li key={column} className="font-mono">
|
||||
{column}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
))
|
||||
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
|
||||
<>
|
||||
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{rowErrors.map((rowError, index) => (
|
||||
<li key={index}>
|
||||
<Trans>
|
||||
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
|
||||
email or empty
|
||||
</Trans>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
))
|
||||
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
|
||||
code === AppErrorCode.LIMIT_EXCEEDED ? (
|
||||
<Trans>The CSV exceeds the maximum file size.</Trans>
|
||||
) : (
|
||||
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
|
||||
),
|
||||
)
|
||||
.exhaustive()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendImmediately"
|
||||
@@ -240,7 +345,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
type="button"
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useCallback, useRef } from 'react';
|
||||
import type { Control } from 'react-hook-form';
|
||||
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
|
||||
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
import { useConfigureDocument } from './configure-document-context';
|
||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||
|
||||
@@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps {
|
||||
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { isTemplate } = useConfigureDocument();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
|
||||
@@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
|
||||
/>
|
||||
|
||||
<DragDropContext
|
||||
nonce={cspNonce}
|
||||
onDragEnd={onDragEnd}
|
||||
sensors={[
|
||||
(api: SensorAPI) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
@@ -77,6 +78,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -264,6 +266,13 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'direct_template',
|
||||
recipientId: recipient.id,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
@@ -308,6 +317,14 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'embed_init',
|
||||
recipientId: recipient.id,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
@@ -32,6 +34,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
@@ -73,6 +76,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -152,6 +156,14 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
|
||||
setHasCompletedDocument(true);
|
||||
} catch (err) {
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'complete_document',
|
||||
recipientId: recipient.id,
|
||||
documentId,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
@@ -162,9 +174,12 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
);
|
||||
}
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
|
||||
title: _(toastMessage.title),
|
||||
description: _(toastMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -231,6 +246,15 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'embed_init',
|
||||
recipientId: recipient.id,
|
||||
documentId,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
@@ -25,6 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
allowWhitelabelling = false,
|
||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
@@ -170,6 +172,14 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'embed_init',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
@@ -26,6 +27,7 @@ import { useState } from 'react';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
||||
@@ -56,6 +58,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
}: MultiSignDocumentSigningViewProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -100,6 +103,13 @@ export const MultiSignDocumentSigningView = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'sign_field',
|
||||
recipientId,
|
||||
documentId: document?.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
@@ -119,6 +129,13 @@ export const MultiSignDocumentSigningView = ({
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'remove_field',
|
||||
recipientId,
|
||||
documentId: document?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,11 +156,21 @@ export const MultiSignDocumentSigningView = ({
|
||||
recipientId,
|
||||
});
|
||||
} catch (err) {
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'complete_document',
|
||||
recipientId,
|
||||
documentId: document?.id,
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to complete the document. Please try again.`),
|
||||
title: _(toastMessage.title),
|
||||
description: _(toastMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { type FieldValues, type Path, useFormContext } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`.
|
||||
*/
|
||||
export const ZTwoFactorCodeFieldSchema = z.object({
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type TTwoFactorCodeFieldSchema = z.infer<typeof ZTwoFactorCodeFieldSchema>;
|
||||
|
||||
export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode;
|
||||
|
||||
type TwoFactorMethod = 'totp' | 'backup';
|
||||
|
||||
export type TwoFactorCodeDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isSubmitting?: boolean;
|
||||
submitLabel: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Called when the user submits the code. Typically the parent form's submit handler.
|
||||
*/
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects a TOTP or backup code on top of an existing form, mirroring the
|
||||
* sign in and disable 2FA dialogs.
|
||||
*
|
||||
* Must be rendered inside a `<Form>` whose values include `totpCode` and `backupCode`.
|
||||
*/
|
||||
export const TwoFactorCodeDialog = <T extends FieldValues & TTwoFactorCodeFieldSchema>({
|
||||
open,
|
||||
onOpenChange,
|
||||
isSubmitting,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
}: TwoFactorCodeDialogProps) => {
|
||||
const form = useFormContext<T>();
|
||||
|
||||
const [method, setMethod] = useState<TwoFactorMethod>('totp');
|
||||
|
||||
const totpCodeName = 'totpCode' as Path<T>;
|
||||
const backupCodeName = 'backupCode' as Path<T>;
|
||||
|
||||
const onToggleMethod = () => {
|
||||
form.resetField(totpCodeName);
|
||||
form.resetField(backupCodeName);
|
||||
|
||||
setMethod((current) => (current === 'totp' ? 'backup' : 'totp'));
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
form.resetField(totpCodeName);
|
||||
form.resetField(backupCodeName);
|
||||
setMethod('totp');
|
||||
}
|
||||
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Two-Factor Authentication</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{method === 'totp' ? (
|
||||
<Trans>Enter the code from your authenticator app to continue.</Trans>
|
||||
) : (
|
||||
<Trans>Enter one of your backup codes to continue.</Trans>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset disabled={isSubmitting}>
|
||||
{method === 'totp' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={totpCodeName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6} autoFocus>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{method === 'backup' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={backupCodeName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Backup Code</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" autoComplete="off" autoFocus {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="secondary" onClick={onToggleMethod}>
|
||||
{method === 'totp' ? <Trans>Use Backup Code</Trans> : <Trans>Use Authenticator</Trans>}
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isSubmitting} onClick={onSubmit}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* Compact "send me a setup link" button that reports via toast, for settings
|
||||
* cards where the surrounding layout provides the explanation.
|
||||
*/
|
||||
export const PasswordSetupRequestButton = () => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
|
||||
const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Check your email`),
|
||||
description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`),
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: (errorCode) => {
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: match(errorCode)
|
||||
.with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`))
|
||||
.otherwise(() => _(msg`We were unable to send the email. Please try again later.`)),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<Button variant="outline" className="flex-shrink-0 bg-background" disabled>
|
||||
<CheckIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Link sent</Trans>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" className="flex-shrink-0 bg-background" loading={isPending} onClick={requestSetupLink}>
|
||||
<Trans>Send setup link</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type PasswordSetupRequestProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline "send me a setup link" control with its own sent/error states, for
|
||||
* contexts like dialogs where a toast would be missed.
|
||||
*/
|
||||
export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { user } = useSession();
|
||||
|
||||
const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest();
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<Alert className={className} variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Check your email</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{errorCode && (
|
||||
<Alert className="mb-4" variant="destructive">
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{match(errorCode)
|
||||
.with('SIGNIN_DISABLED', () =>
|
||||
_(msg`Password sign in is disabled for this instance. Please contact support.`),
|
||||
)
|
||||
.otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="button" loading={isPending} onClick={requestSetupLink}>
|
||||
<Trans>Send setup link</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -11,20 +11,21 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export const ZPasswordFormSchema = z
|
||||
.object({
|
||||
currentPassword: ZCurrentPasswordSchema,
|
||||
password: ZPasswordSchema,
|
||||
repeatedPassword: ZPasswordSchema,
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['repeatedPassword'],
|
||||
});
|
||||
import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog';
|
||||
|
||||
export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({
|
||||
currentPassword: ZCurrentPasswordSchema,
|
||||
password: ZPasswordSchema,
|
||||
repeatedPassword: ZPasswordSchema,
|
||||
}).refine((data) => data.password === data.repeatedPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['repeatedPassword'],
|
||||
});
|
||||
|
||||
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
|
||||
|
||||
@@ -33,29 +34,51 @@ export type PasswordFormProps = {
|
||||
user: SessionUser;
|
||||
};
|
||||
|
||||
export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
export const PasswordForm = ({ className, user }: PasswordFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false);
|
||||
|
||||
const form = useForm<TPasswordFormSchema>({
|
||||
values: {
|
||||
currentPassword: '',
|
||||
password: '',
|
||||
repeatedPassword: '',
|
||||
totpCode: '',
|
||||
backupCode: '',
|
||||
},
|
||||
resolver: zodResolver(ZPasswordFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
|
||||
const onFormSubmit = async (values: TPasswordFormSchema) => {
|
||||
const { currentPassword, password, totpCode, backupCode } = values;
|
||||
|
||||
// Collect the 2FA code in a dialog once the password fields are valid.
|
||||
if (user.twoFactorEnabled && !hasTwoFactorCode(values)) {
|
||||
if (isTwoFactorDialogOpen) {
|
||||
const message = _(msg`A code is required`);
|
||||
|
||||
form.setError('totpCode', { message });
|
||||
form.setError('backupCode', { message });
|
||||
}
|
||||
|
||||
setIsTwoFactorDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authClient.emailPassword.updatePassword({
|
||||
currentPassword,
|
||||
password,
|
||||
totpCode: totpCode || undefined,
|
||||
backupCode: backupCode || undefined,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
setIsTwoFactorDialogOpen(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`Password updated`),
|
||||
@@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('NO_PASSWORD', () => msg`User has no password.`)
|
||||
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
|
||||
.with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`)
|
||||
.with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`)
|
||||
.with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`)
|
||||
.with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`)
|
||||
.with(
|
||||
AppErrorCode.INCORRECT_TWO_FACTOR_CODE,
|
||||
AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS,
|
||||
() => msg`The two factor code you provided is invalid. Please try again.`,
|
||||
)
|
||||
.otherwise(
|
||||
() => msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
|
||||
);
|
||||
@@ -83,7 +111,12 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
{/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */}
|
||||
<form
|
||||
method="post"
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -140,6 +173,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<TwoFactorCodeDialog<TPasswordFormSchema>
|
||||
open={isTwoFactorDialogOpen}
|
||||
onOpenChange={setIsTwoFactorDialogOpen}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={<Trans>Update password</Trans>}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,9 +17,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { commandScore } from 'cmdk/dist/command-score';
|
||||
import { defaultFilter as commandScore } from 'cmdk';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
@@ -596,9 +596,21 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
|
||||
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{hasValidSearch ? (
|
||||
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
|
||||
isVisibleCountCapped ? (
|
||||
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
|
||||
) : (
|
||||
<Plural
|
||||
value={totalVisibleCount}
|
||||
one="# result"
|
||||
other="# results"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Trans>{totalVisibleCount} items</Trans>
|
||||
<Plural
|
||||
value={totalVisibleCount}
|
||||
one="# item"
|
||||
other="# items"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
@@ -41,6 +42,7 @@ export const DirectTemplatePageView = ({
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
|
||||
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
|
||||
@@ -124,6 +126,13 @@ export const DirectTemplatePageView = ({
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'direct_template',
|
||||
recipientId: directTemplateRecipient.id,
|
||||
envelopeId: template.envelopeId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
|
||||
+28
-5
@@ -1,5 +1,7 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
@@ -7,11 +9,13 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password';
|
||||
|
||||
export type DocumentSigningAuthPasswordProps = {
|
||||
open: boolean;
|
||||
@@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({
|
||||
}: DocumentSigningAuthPasswordProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
// Fetched on demand since this is only needed once the user opts for password auth.
|
||||
const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, {
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const form = useForm<TPasswordAuthFormSchema>({
|
||||
resolver: zodResolver(ZPasswordAuthFormSchema),
|
||||
@@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({
|
||||
|
||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
|
||||
// If the query fails we fall through to the regular password form rather than blocking.
|
||||
const isPasswordSetupRequired =
|
||||
!!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD);
|
||||
|
||||
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
@@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
setFormErrorCode(error.code);
|
||||
|
||||
// Todo: Alert.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,9 +89,22 @@ export const DocumentSigningAuthPassword = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
if (user && isAuthMethodsPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2Icon className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPasswordSetupRequired) {
|
||||
return <DocumentSigningAuthSetPassword onOpenChange={onOpenChange} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
{/* method="post" so a pre-hydration native submit can't leak the password into the URL. */}
|
||||
<form method="post" onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
{formErrorCode && (
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { PasswordSetupRequest } from '~/components/forms/password-setup-request';
|
||||
|
||||
export type DocumentSigningAuthSetPasswordProps = {
|
||||
onOpenChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shown in place of the password reauth form when the signed in user has no
|
||||
* password (e.g. they signed up via OAuth or a passkey).
|
||||
*
|
||||
* Password based action auth is meant to prove more than possession of a session,
|
||||
* so rather than letting the session set a password inline we send the user the
|
||||
* verified reset link and ask them to come back.
|
||||
*/
|
||||
export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => {
|
||||
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isEmailPasswordSigninEnabled ? (
|
||||
<>
|
||||
<Alert variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>No password set</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Signing this field requires a password, but your account does not have one. We can email you a link to
|
||||
set one. Once done, sign in again and return to this document to continue.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<PasswordSetupRequest />
|
||||
</>
|
||||
) : (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>Password authentication unavailable</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Your account does not have a password and password sign in is disabled for this instance. Please contact
|
||||
the document sender to use a different authentication method.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+23
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -39,6 +40,7 @@ export const DocumentSigningCheckboxField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -126,6 +128,13 @@ export const DocumentSigningCheckboxField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -157,6 +166,13 @@ export const DocumentSigningCheckboxField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
@@ -216,6 +232,13 @@ export const DocumentSigningCheckboxField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while updating the signature.`),
|
||||
|
||||
+24
-1
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth';
|
||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
@@ -27,6 +29,8 @@ import { useEmbedSigningContext } from '~/components/embed/embed-signing-context
|
||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||
|
||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningCompleteDialogProps = {
|
||||
@@ -85,7 +89,9 @@ export const DocumentSigningCompleteDialog = ({
|
||||
position,
|
||||
disableNameInput = false,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
@@ -174,6 +180,23 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.captureException(error, {
|
||||
source: 'signing',
|
||||
location: 'complete_document',
|
||||
});
|
||||
|
||||
// This dialog owns the completion error toast for every signing surface
|
||||
// so the user gets a specific, actionable message. Callers should run
|
||||
// their own side effects (e.g. embeds posting document-error) and
|
||||
// rethrow rather than toasting themselves.
|
||||
const toastMessage = getSigningCompletionErrorMessage(err.code);
|
||||
|
||||
toast({
|
||||
title: i18n._(toastMessage.title),
|
||||
description: i18n._(toastMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
@@ -39,6 +40,7 @@ export const DocumentSigningDateField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -85,6 +87,13 @@ export const DocumentSigningDateField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -113,6 +122,13 @@ export const DocumentSigningDateField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
+16
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -35,6 +36,7 @@ export const DocumentSigningDropdownField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -86,6 +88,13 @@ export const DocumentSigningDropdownField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -120,6 +129,13 @@ export const DocumentSigningDropdownField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -33,6 +34,7 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { email: providedEmail } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -78,6 +80,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -106,6 +115,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
@@ -19,6 +20,8 @@ import { useId, useMemo, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||
@@ -100,9 +103,12 @@ export const DocumentSigningForm = ({
|
||||
try {
|
||||
await completeDocument({ nextSigner });
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||
title: _(toastMessage.title),
|
||||
description: _(toastMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
|
||||
+16
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -38,6 +39,7 @@ export const DocumentSigningInitialsField = ({
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName } = useRequiredDocumentSigningContext();
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
@@ -84,6 +86,13 @@ export const DocumentSigningInitialsField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -112,6 +121,13 @@ export const DocumentSigningInitialsField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -39,6 +40,7 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -116,6 +118,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -144,6 +153,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -47,6 +48,7 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -142,6 +144,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -193,6 +202,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
@@ -83,8 +82,6 @@ export const DocumentSigningPageViewV1 = ({
|
||||
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||
: false;
|
||||
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -118,12 +115,6 @@ export const DocumentSigningPageViewV1 = ({
|
||||
|
||||
await completeDocumentWithToken(payload);
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: document.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (documentMeta?.redirectUrl) {
|
||||
window.location.href = documentMeta.redirectUrl;
|
||||
} else {
|
||||
|
||||
+22
-4
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -31,6 +32,7 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -91,6 +93,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -120,6 +129,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the selection.`),
|
||||
@@ -146,14 +162,17 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
{isLoading && <DocumentSigningFieldsLoader />}
|
||||
|
||||
{!field.inserted && (
|
||||
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
|
||||
<RadioGroup
|
||||
value={selectedOption}
|
||||
onValueChange={(value) => handleSelectItem(value)}
|
||||
className="z-10 my-0.5 gap-y-1"
|
||||
>
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
className="h-3 w-3 shrink-0"
|
||||
value={item.value}
|
||||
id={`option-${field.id}-${item.id}`}
|
||||
checked={item.checked}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
@@ -167,14 +186,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<RadioGroup className="my-0.5 gap-y-1">
|
||||
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
className="h-3 w-3"
|
||||
value={item.value}
|
||||
id={`option-${field.id}-${item.id}`}
|
||||
checked={item.value === field.customText}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -41,6 +42,7 @@ export function DocumentSigningRejectDialog({
|
||||
}: DocumentSigningRejectDialogProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -76,6 +78,12 @@ export function DocumentSigningRejectDialog({
|
||||
window.location.href = `/sign/${token}/rejected`;
|
||||
}
|
||||
} catch (err) {
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'reject_document',
|
||||
documentId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while rejecting the document. Please try again.`,
|
||||
|
||||
+16
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -47,6 +48,7 @@ export const DocumentSigningSignatureField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -157,6 +159,13 @@ export const DocumentSigningSignatureField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
@@ -183,6 +192,13 @@ export const DocumentSigningSignatureField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -50,6 +51,7 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -170,6 +172,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -200,6 +209,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
export type DocumentEditFormProps = {
|
||||
@@ -42,6 +43,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
|
||||
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -473,6 +475,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
onAutoSave={onAddSignersFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
nonce={cspNonce}
|
||||
/>
|
||||
|
||||
<AddFieldsFormPartial
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@@ -38,7 +37,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const userTimezone =
|
||||
@@ -103,12 +101,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Handle legacy template creation.
|
||||
|
||||
+15
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
@@ -37,8 +38,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
||||
|
||||
/** How far past a resize handle you can still grab it, in screen pixels. */
|
||||
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
|
||||
|
||||
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
|
||||
@@ -276,6 +281,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
unsafeRenderFieldOnLayer(field);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'envelope_page_render',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
@@ -350,6 +362,9 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
shouldOverdrawWholeArea: true,
|
||||
ignoreStroke: true,
|
||||
flipEnabled: false,
|
||||
anchorStyleFunc: (anchor) => {
|
||||
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / scale);
|
||||
},
|
||||
boundBoxFunc: (oldBox, newBox) => {
|
||||
// Enforce minimum size
|
||||
if (newBox.width < 30 || newBox.height < 20) {
|
||||
|
||||
@@ -45,6 +45,7 @@ import { isDeepEqual } from 'remeda';
|
||||
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
|
||||
@@ -52,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -795,6 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</div>
|
||||
|
||||
<DragDropContext
|
||||
nonce={cspNonce}
|
||||
onDragEnd={onDragEnd}
|
||||
sensors={[
|
||||
(api: SensorAPI) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
@@ -25,6 +26,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
|
||||
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||
@@ -41,10 +43,12 @@ type LocalFile = {
|
||||
|
||||
export const EnvelopeEditorUploadPage = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const { t, i18n } = useLingui();
|
||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
@@ -213,6 +217,12 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
const { data } = await createPromise.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
analytics.captureException(error, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'create_envelope_items',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
// Set error state on files in batch upload.
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((uploadingFile) =>
|
||||
@@ -290,6 +300,12 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
analytics.captureException(error, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'replace_pdf',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Replace failed`,
|
||||
description: t`Something went wrong while replacing the PDF`,
|
||||
@@ -480,7 +496,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
{/* Uploaded Files List */}
|
||||
<div className="mt-4">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<DragDropContext nonce={cspNonce} onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="files">
|
||||
{(provided) => (
|
||||
<div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import {
|
||||
type PageRenderData,
|
||||
@@ -18,6 +19,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
|
||||
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelopeStatus,
|
||||
@@ -114,6 +116,13 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
unsafeRenderFieldOnLayer(field);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'envelope_page_render',
|
||||
envelopeId: currentEnvelopeItem?.envelopeId,
|
||||
});
|
||||
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import {
|
||||
type PageRenderData,
|
||||
@@ -53,6 +54,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelopeData,
|
||||
@@ -426,6 +428,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'page_render',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
@@ -507,6 +517,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: payload.type,
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while signing the field.`,
|
||||
|
||||
+15
-14
@@ -118,12 +118,6 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
title: t`Document already signed`,
|
||||
description: t`This document was already signed and no further action was taken.`,
|
||||
});
|
||||
} else {
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: envelope.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (onDocumentCompleted) {
|
||||
@@ -148,15 +142,18 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'complete_document',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
}
|
||||
|
||||
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
|
||||
// toast a specific completion error message.
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -224,14 +221,18 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('err', err);
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'complete_document_next_signer',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
|
||||
// Rethrow so DocumentSigningCompleteDialog can toast a specific
|
||||
// completion error message.
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,14 +93,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
@@ -109,6 +101,11 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'upload_document',
|
||||
});
|
||||
|
||||
const errorMessage = getUploadErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@@ -34,6 +35,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
@@ -112,6 +114,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'upload_document',
|
||||
});
|
||||
|
||||
const errorMessage = getUploadErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -68,6 +69,7 @@ export default function PDFViewer({
|
||||
}: PDFViewerProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -150,6 +152,11 @@ export default function PDFViewer({
|
||||
console.error(err);
|
||||
setLoadingState('error');
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'pdf_viewer',
|
||||
location: 'pdf_load',
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while loading the document.`,
|
||||
@@ -215,7 +222,7 @@ export default function PDFViewer({
|
||||
|
||||
type VirtualizedPageListProps = {
|
||||
scrollParentRef: ScrollTarget;
|
||||
constraintRef: React.RefObject<HTMLDivElement>;
|
||||
constraintRef: React.RefObject<HTMLDivElement | null>;
|
||||
pages: PageMeta[];
|
||||
numPages: number;
|
||||
pdf: pdfjsLib.PDFDocumentProxy;
|
||||
@@ -366,6 +373,8 @@ const PdfViewerPage = ({
|
||||
* Manages rendering a page from a pdf.
|
||||
*/
|
||||
const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
|
||||
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
@@ -457,6 +466,12 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
|
||||
|
||||
if (!isCancelled) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'pdf_viewer',
|
||||
location: 'pdf_page_render',
|
||||
});
|
||||
|
||||
setImageLoadingState('error');
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { z } from 'zod';
|
||||
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
export type TemplateEditFormProps = {
|
||||
className?: string;
|
||||
@@ -38,6 +39,7 @@ const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
|
||||
export const TemplateEditForm = ({ initialTemplate, className, templateRootPath }: TemplateEditFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
@@ -339,6 +341,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
nonce={cspNonce}
|
||||
/>
|
||||
|
||||
<AddTemplateFieldsFormPartial
|
||||
|
||||
@@ -39,13 +39,14 @@ const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
|
||||
|
||||
/**
|
||||
* Admin list pages which support prefilling their search from the URL, used
|
||||
* for the "View all results" links on capped groups. Teams, recipients and
|
||||
* for the "View all results" links on capped groups. Teams and
|
||||
* subscriptions have no admin list pages.
|
||||
*/
|
||||
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
||||
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
||||
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
||||
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
||||
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
|
||||
};
|
||||
|
||||
export type UseAdminSearchCategoriesOptions = {
|
||||
|
||||
@@ -18,6 +18,23 @@ import './utils/polyfills/promise-with-resolvers';
|
||||
* the page early, leaving dead event handlers (broken dropdowns, native form
|
||||
* submits).
|
||||
*/
|
||||
/**
|
||||
* Signing and direct template URLs contain recipient tokens which must never
|
||||
* be sent to PostHog. Recipient context is attached explicitly via
|
||||
* `recipientId` where needed instead.
|
||||
*/
|
||||
const redactTokensFromUrl = (value: string) => {
|
||||
return value.replace(/(\/(?:sign|d|direct)\/)([^/?#]+)/g, '$1:token');
|
||||
};
|
||||
|
||||
const URL_EVENT_PROPERTIES = [
|
||||
'$current_url',
|
||||
'$pathname',
|
||||
'$referrer',
|
||||
'$initial_referrer',
|
||||
'$prev_pageview_pathname',
|
||||
] as const;
|
||||
|
||||
function initPosthog() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
@@ -25,12 +42,59 @@ function initPosthog() {
|
||||
void import('posthog-js').then(({ default: posthog }) => {
|
||||
posthog.init(postHogConfig.key, {
|
||||
api_host: postHogConfig.host,
|
||||
// Only create person profiles for identified (authenticated) users,
|
||||
// anonymous recipients on signing pages stay anonymous.
|
||||
person_profiles: 'identified_only',
|
||||
// Explicit events only, autocapture on signing pages blows up usage
|
||||
// without providing actionable data.
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
capture_pageleave: false,
|
||||
capture_exceptions: true,
|
||||
before_send: (event) => {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const property of URL_EVENT_PROPERTIES) {
|
||||
const value = event.properties?.[property];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
event.properties[property] = redactTokensFromUrl(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.$set_once && typeof event.$set_once['$initial_current_url'] === 'string') {
|
||||
event.$set_once['$initial_current_url'] = redactTokensFromUrl(event.$set_once['$initial_current_url']);
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces hydration recoveries (React 19 discards the server HTML and
|
||||
* re-renders on the client instead of dying) so we can track how often
|
||||
* extensions/early clicks interfere with hydration in the wild.
|
||||
*/
|
||||
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
|
||||
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
|
||||
|
||||
if (extractPostHogConfig()) {
|
||||
void import('posthog-js').then(({ default: posthog }) => {
|
||||
if (posthog.__loaded) {
|
||||
posthog.capture('$hydration_recoverable_error', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const locale = detect(fromHtmlTag('lang')) || 'en';
|
||||
|
||||
@@ -44,6 +108,7 @@ async function main() {
|
||||
<HydratedRouter />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
{ onRecoverableError },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import type { EntryContext, RouterContextProvider } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
|
||||
import { langCookie } from './storage/lang-cookie.server';
|
||||
import { nonceContext } from './utils/nonce';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
@@ -19,7 +20,7 @@ export default async function handleRequest(
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: AppLoadContext,
|
||||
loadContext: RouterContextProvider,
|
||||
) {
|
||||
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
|
||||
|
||||
@@ -33,7 +34,7 @@ export default async function handleRequest(
|
||||
// scripts it injects (route manifest, hydration data, module preloads).
|
||||
// The same nonce is also exposed to the React tree via the root loader so
|
||||
// our own inline scripts/styles can carry it.
|
||||
const nonce = loadContext.nonce || undefined;
|
||||
const nonce = loadContext.get(nonceContext) || undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { type MiddlewareFunction, redirect } from 'react-router';
|
||||
|
||||
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
if (!user || !isAdmin(user)) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MiddlewareFunction } from 'react-router';
|
||||
|
||||
import { getRequestNonce } from '../../server/load-context';
|
||||
import { nonceContext } from '../utils/nonce';
|
||||
|
||||
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
|
||||
context.set(nonceContext, getRequestNonce());
|
||||
};
|
||||
+15
-2
@@ -1,4 +1,5 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||
import { getBasePath } from '@documenso/lib/constants/app';
|
||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||
@@ -9,6 +10,7 @@ import { getOrganisationSession } from '@documenso/trpc/server/organisation-rout
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
data,
|
||||
isRouteErrorResponse,
|
||||
@@ -21,13 +23,16 @@ import {
|
||||
useMatches,
|
||||
} from 'react-router';
|
||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||
import { nonceMiddleware } from '~/middleware/nonce';
|
||||
import type { Route } from './+types/root';
|
||||
import stylesheet from './app.css?url';
|
||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||
import { langCookie } from './storage/lang-cookie.server';
|
||||
import { themeSessionResolver } from './storage/theme-session.server';
|
||||
import { appMetaTags } from './utils/meta';
|
||||
import { nonce } from './utils/nonce';
|
||||
import { nonce, nonceContext } from './utils/nonce';
|
||||
|
||||
export const middleware = [nonceMiddleware];
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
@@ -72,7 +77,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||
// routes that need it) can carry the matching nonce attribute.
|
||||
nonce: context.nonce,
|
||||
nonce: context.get(nonceContext),
|
||||
session: session.isAuthenticated
|
||||
? {
|
||||
user: session.user,
|
||||
@@ -200,11 +205,19 @@ export default function App() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
if (errorCode !== 404) {
|
||||
console.error('[RootErrorBoundary]', error);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCode !== 404) {
|
||||
analytics.captureException(error, { source: 'app', location: 'root_boundary' });
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return <GenericErrorLayout errorCode={errorCode} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -20,16 +20,18 @@ import {
|
||||
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
||||
|
||||
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
|
||||
import { adminMiddleware } from '~/middleware/admin';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Admin`);
|
||||
}
|
||||
|
||||
export const middleware = [adminMiddleware];
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const license = await LicenseClient.getInstance()?.getCachedLicense();
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { debounce, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
@@ -24,32 +22,10 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -59,8 +35,8 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => void setSearchQuery(e.target.value || null)}
|
||||
placeholder={t`Search by claim ID or name`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
@@ -30,6 +30,12 @@ export default function AdminDocumentsPage() {
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
|
||||
const urlTerm = searchParams?.get?.('term') ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
setTerm(urlTerm);
|
||||
}, [urlTerm]);
|
||||
|
||||
const { data: findDocumentsData, isPending: isFindDocumentsLoading } = trpc.admin.document.find.useQuery(
|
||||
{
|
||||
query: debouncedTerm,
|
||||
@@ -131,7 +137,7 @@ export default function AdminDocumentsPage() {
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search by document title, team:123 or user:123`)}
|
||||
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { debounce, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
@@ -11,32 +9,10 @@ import { AdminEmailTransportsTable } from '~/components/tables/admin-email-trans
|
||||
export default function AdminEmailTransportsPage() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -46,8 +22,8 @@ export default function AdminEmailTransportsPage() {
|
||||
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => void setSearchQuery(e.target.value || null)}
|
||||
placeholder={t`Search by name or from address`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
@@ -6,8 +5,9 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { debounce, parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import {
|
||||
@@ -52,14 +52,17 @@ export default function OrganisationStats() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
const [{ query: searchQuery }, setSearchFilters] = useQueryStates(
|
||||
{
|
||||
query: parseAsString.withDefault(''),
|
||||
page: parseAsInteger,
|
||||
},
|
||||
{ shallow: false, limitUrlUpdates: debounce(500) },
|
||||
);
|
||||
|
||||
const [displayMode, setDisplayMode] = useState<OrganisationStatsDisplayMode>('usage');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
const periodOptions = useMemo(() => generatePeriodOptions(), []);
|
||||
|
||||
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
|
||||
@@ -71,29 +74,12 @@ export default function OrganisationStats() {
|
||||
|
||||
const claimOptions = claimsData?.data ?? [];
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
const onSearchQueryChange = (value: string) => {
|
||||
void setSearchFilters({
|
||||
query: value || null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
const onPeriodChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
@@ -128,8 +114,8 @@ export default function OrganisationStats() {
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
placeholder={t`Search by organisation name, URL or ID`}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { debounce, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
|
||||
@@ -10,32 +8,10 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
|
||||
export default function Organisations() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -43,8 +19,8 @@ export default function Organisations() {
|
||||
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => void setSearchQuery(e.target.value || null)}
|
||||
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { debounce, parseAsString, useQueryState } from 'nuqs';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||
|
||||
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
|
||||
@@ -15,35 +14,16 @@ import { OrganisationMembersDataTable } from '~/components/tables/organisation-m
|
||||
export default function TeamsSettingsMembersPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
|
||||
);
|
||||
|
||||
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
@@ -57,8 +37,8 @@ export default function TeamsSettingsMembersPage() {
|
||||
<div>
|
||||
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => void setSearchQuery(e.target.value || null)}
|
||||
placeholder={_(msg`Search`)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { debounce, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
@@ -11,27 +9,10 @@ import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-t
|
||||
export default function OrganisationSettingsTeamsPage() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -40,8 +21,8 @@ export default function OrganisationSettingsTeamsPage() {
|
||||
</SettingsHeader>
|
||||
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => void setSearchQuery(e.target.value || null)}
|
||||
placeholder={t`Search`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { getUserAuthMethods } from '@documenso/lib/server-only/user/get-user-auth-methods';
|
||||
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -12,6 +14,7 @@ import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-au
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
import { PasswordSetupRequestButton } from '~/components/forms/password-setup-request-button';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -24,33 +27,10 @@ export function meta() {
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
// Todo: Use providers instead after RR7 migration.
|
||||
// const accounts = await prisma.account.findMany({
|
||||
// where: {
|
||||
// userId: user.id,
|
||||
// },
|
||||
// select: {
|
||||
// provider: true,
|
||||
// },
|
||||
// });
|
||||
|
||||
// const providers = accounts.map((account) => account.provider);
|
||||
// let hasEmailPasswordAccount = providers.includes('DOCUMENSO');
|
||||
|
||||
const hasEmailPasswordAccount: boolean = await prisma.user
|
||||
.count({
|
||||
where: {
|
||||
id: user.id,
|
||||
password: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((value) => value > 0);
|
||||
const authMethods = await getUserAuthMethods({ userId: user.id });
|
||||
|
||||
return {
|
||||
// providers,
|
||||
hasEmailPasswordAccount,
|
||||
hasEmailPasswordAccount: authMethods.includes(UserAuthMethod.PASSWORD),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,14 +40,36 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
||||
const { _ } = useLingui();
|
||||
const { user } = useSession();
|
||||
|
||||
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Security`)}
|
||||
subtitle={_(msg`Here you can manage your password and security settings.`)}
|
||||
/>
|
||||
|
||||
{hasEmailPasswordAccount && <PasswordForm user={user} />}
|
||||
|
||||
{!hasEmailPasswordAccount && isEmailPasswordSigninEnabled && (
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Set a password</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
<Trans>
|
||||
Your account has no password. Add one to sign in with your email and to sign documents that require it.
|
||||
We'll email you a link.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<PasswordSetupRequestButton />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
@@ -7,6 +8,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { ChevronLeftIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import type { ShouldRevalidateFunctionArgs } from 'react-router';
|
||||
import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router';
|
||||
|
||||
@@ -66,8 +68,18 @@ export default function DocumentsLayout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
useEffect(() => {
|
||||
analytics.captureException(error, {
|
||||
source: 'editor',
|
||||
location: 'editor_layout_boundary',
|
||||
envelopeId: params.id,
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
const errorCodeMap = {
|
||||
404: {
|
||||
subHeading: msg`404 Document not found`,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { debounce, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
@@ -16,34 +14,12 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
export default function TeamsSettingsGroupsPage() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
|
||||
);
|
||||
|
||||
const everyoneGroupQuery = trpc.team.group.find.useQuery({
|
||||
teamId: team.id,
|
||||
@@ -61,8 +37,8 @@ export default function TeamsSettingsGroupsPage() {
|
||||
</SettingsHeader>
|
||||
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => void setSearchQuery(e.target.value || null)}
|
||||
placeholder={t`Search`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { debounce, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
@@ -11,32 +9,10 @@ import { TeamMembersTable } from '~/components/tables/team-members-table';
|
||||
export default function TeamsSettingsMembersPage() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -45,8 +21,8 @@ export default function TeamsSettingsMembersPage() {
|
||||
</SettingsHeader>
|
||||
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => void setSearchQuery(e.target.value || null)}
|
||||
placeholder={t`Search`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
@@ -68,6 +68,20 @@ export default function ApiTokensPage() {
|
||||
return i18n.date(row.original.expires);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t`Last Used`,
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.lastUsedAt) {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Never</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-foreground">{i18n.date(row.original.lastUsedAt)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t`Actions`,
|
||||
cell: ({ row }) => (
|
||||
@@ -146,6 +160,9 @@ export default function ApiTokensPage() {
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
@@ -20,7 +19,8 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { CheckCircle2Icon, ChevronRightIcon, PencilIcon, TerminalIcon, XCircleIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { debounce, parseAsString, useQueryState } from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -51,13 +51,14 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'query',
|
||||
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
|
||||
);
|
||||
|
||||
const parsedSearchParams = WebhookSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
@@ -81,26 +82,6 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
|
||||
query: parsedSearchParams.query,
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
@@ -252,8 +233,8 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
|
||||
<div className="mt-4">
|
||||
<div className="mb-4 flex flex-row items-center justify-between gap-x-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => void setSearchQuery(e.target.value || null)}
|
||||
placeholder={t`Search by ID`}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
@@ -8,6 +9,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { ChevronLeftIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
@@ -84,8 +86,18 @@ export default function TemplatesLayout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
useEffect(() => {
|
||||
analytics.captureException(error, {
|
||||
source: 'editor',
|
||||
location: 'editor_layout_boundary',
|
||||
envelopeId: params.id,
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
const errorCodeMap = {
|
||||
404: {
|
||||
subHeading: msg`404 Template not found`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -5,6 +6,7 @@ import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
|
||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
@@ -48,8 +50,14 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
useEffect(() => {
|
||||
analytics.captureException(error, { source: 'signing', location: 'recipient_layout_boundary' });
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={errorCode}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
||||
|
||||
export const loader = () => {
|
||||
export const loader = async () => {
|
||||
try {
|
||||
const certStatus = getCertificateStatus();
|
||||
const certStatus = await getCertificateStatus();
|
||||
|
||||
return Response.json({
|
||||
isAvailable: certStatus.isAvailable,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const loader = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const certStatus = getCertificateStatus();
|
||||
const certStatus = await getCertificateStatus();
|
||||
|
||||
if (certStatus.isAvailable) {
|
||||
checks.certificate = { status: 'ok' };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useEffect } from 'react';
|
||||
import { isRouteErrorResponse, Outlet, useRouteError } from 'react-router';
|
||||
|
||||
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
||||
@@ -47,10 +49,30 @@ export default function Layout() {
|
||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
||||
|
||||
const analytics = useAnalytics();
|
||||
const error = useRouteError();
|
||||
|
||||
console.log({ routeError: error });
|
||||
|
||||
useEffect(() => {
|
||||
const isExpectedEmbedResponse =
|
||||
isRouteErrorResponse(error) &&
|
||||
[
|
||||
'embed-authentication-required',
|
||||
'embed-paywall',
|
||||
'embed-waiting-for-turn',
|
||||
'embed-recipient-expired',
|
||||
'embed-document-rejected',
|
||||
'embed-document-completed',
|
||||
].includes(error.data?.type);
|
||||
|
||||
if (isExpectedEmbedResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.captureException(error, { source: 'embed', location: 'embed_layout_boundary' });
|
||||
}, [error]);
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user