mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
Merge branch 'main' into refactor/trpc-error-formatter
This commit is contained in:
@@ -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.
|
|
||||||
@@ -80,6 +80,8 @@ NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
|
|||||||
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
|
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
|
||||||
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
|
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
|
||||||
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
|
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
|
||||||
|
# OPTIONAL: Reason to embed in PDF signatures. Defaults to "Signed by Documenso".
|
||||||
|
NEXT_PRIVATE_SIGNING_REASON=
|
||||||
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
|
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
|
||||||
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
|
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
|
||||||
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
|
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: 'One-Click Deploy Provider Request'
|
||||||
|
description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README
|
||||||
|
title: 'One-Click Deploy Provider Request: [Provider Name]'
|
||||||
|
labels: ['deploy-provider-request']
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for your interest in adding a one-click deploy option for Documenso!
|
||||||
|
|
||||||
|
Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest.
|
||||||
|
|
||||||
|
**How this works:**
|
||||||
|
|
||||||
|
- 👍 this issue if you'd like to see Documenso deployable on this provider.
|
||||||
|
- If community interest is high enough, we'll consider adding it to the README.
|
||||||
|
- Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed.
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Provider Name
|
||||||
|
placeholder: e.g. Railway
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Provider Website
|
||||||
|
placeholder: e.g. https://railway.com
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Deploy/Template URL
|
||||||
|
description: A link to an existing deployment template or deploy button URL, if one exists.
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Who creates and maintains the deployment template?
|
||||||
|
options:
|
||||||
|
- The provider
|
||||||
|
- Me / the community
|
||||||
|
- Nobody yet
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Testing & Maintenance
|
||||||
|
description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Why this provider?
|
||||||
|
description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Please check the boxes that apply to this request.
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues to make sure this provider has not already been requested.
|
||||||
|
- label: I understand that inclusion depends on community interest and is not guaranteed.
|
||||||
|
- label: I understand that PRs adding deploy badges without a prior issue will be closed.
|
||||||
@@ -2,7 +2,7 @@ name: 'Setup node'
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: v22.x
|
default: v24.x
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ jobs:
|
|||||||
build_app:
|
build_app:
|
||||||
name: Build App
|
name: Build App
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -32,6 +33,7 @@ jobs:
|
|||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ jobs:
|
|||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
label-when-assigned:
|
label-when-assigned:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Label issue
|
- name: Label issue
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
label_issues:
|
label_issues:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v4
|
- uses: actions/labeler@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ jobs:
|
|||||||
build_and_publish_platform_containers:
|
build_and_publish_platform_containers:
|
||||||
name: Build and publish platform containers
|
name: Build and publish platform containers
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -78,6 +79,7 @@ jobs:
|
|||||||
create_and_publish_manifest:
|
create_and_publish_manifest:
|
||||||
name: Create and publish manifest
|
name: Create and publish manifest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
needs: build_and_publish_platform_containers
|
needs: build_and_publish_platform_containers
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ jobs:
|
|||||||
validate-pr:
|
validate-pr:
|
||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
id: lint_pr_title
|
id: lint_pr_title
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ jobs:
|
|||||||
pull_translations:
|
pull_translations:
|
||||||
name: Force pull translations
|
name: Force pull translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ jobs:
|
|||||||
pull_translations:
|
pull_translations:
|
||||||
name: Pull translations
|
name: Pull translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ jobs:
|
|||||||
extract_translations:
|
extract_translations:
|
||||||
name: Extract and upload translations
|
name: Extract and upload translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
legacy-peer-deps = true
|
legacy-peer-deps = true
|
||||||
prefer-dedupe = 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
|
To run Documenso locally, you will need
|
||||||
|
|
||||||
- Node.js (v22 or above)
|
- Node.js (v24 or above)
|
||||||
- Postgres SQL Database
|
- Postgres SQL Database
|
||||||
- Docker (optional)
|
- Docker (optional)
|
||||||
|
|
||||||
@@ -186,21 +186,37 @@ For full instructions, requirements, and configuration details, see the [Self Ho
|
|||||||
|
|
||||||
### One-Click Deploys
|
### 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)
|
<table>
|
||||||
|
<tr>
|
||||||
#### Render
|
<td align="center" width="200">
|
||||||
|
<a href="https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
||||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40" />
|
||||||
|
</a>
|
||||||
#### Koyeb
|
</td>
|
||||||
|
<td align="center" width="200">
|
||||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
<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" />
|
||||||
#### Elestio
|
</a>
|
||||||
|
</td>
|
||||||
[](https://elest.io/open-source/documenso)
|
<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
|
## Security
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ This guide provides a comprehensive troubleshooting matrix for the standard erro
|
|||||||
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
|
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
|
||||||
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
|
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
|
||||||
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
|
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
|
||||||
|
| `MISSING_ENV_VAR` | A required environment variable is not configured on the server (500). | Primarily affects self-hosted instances: set the environment variable named in the error message and restart. On Documenso Cloud, contact support. |
|
||||||
|
| `MISSING_SIGNATURE_FIELD` | A signer has no signature field placed on the document (400). Returned when distributing an envelope. | Add at least one signature field for every recipient with a signing role before calling `/envelope/distribute`. |
|
||||||
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
|
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
|
||||||
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
|
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
|
||||||
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
|
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
|
||||||
@@ -37,7 +39,28 @@ The following errors occur when attempting to perform actions on an envelope tha
|
|||||||
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
|
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
|
||||||
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
|
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
|
||||||
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
|
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
|
||||||
|
| `ENVELOPE_CANCELLED` | The action cannot be performed because the envelope was cancelled (400). | Create a new envelope if you need to restart the signing process. |
|
||||||
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
|
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
|
||||||
|
| `ENVELOPE_TSP_LOCKED` | An AES/QES envelope cannot be modified after it leaves the draft state (400). | Make changes while the envelope is in `DRAFT`, or create a new envelope. |
|
||||||
|
|
||||||
|
## CSC Signing Errors
|
||||||
|
|
||||||
|
These errors apply to Cloud Signature Consortium (CSC) signing flows.
|
||||||
|
|
||||||
|
| Error Code | Description | Recommended Action |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `CSC_INSTANCE_MODE_MISMATCH` | The requested signature level does not match the instance's CSC mode (400). | Use the signature level supported by the instance's signing configuration. |
|
||||||
|
| `CSC_UNLICENSED` | CSC signing is not licensed for this instance (403). | Enable the CSC signing license before retrying. |
|
||||||
|
| `CSC_PROVIDER_INFO_FAILED` | The CSC provider's discovery request failed or returned unusable information (500). | Check the provider URL, availability, and OAuth configuration. |
|
||||||
|
| `CSC_PROVIDER_NO_TSA` | A timestamp authority is unavailable or unusable for CSC signing (500). | Configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` and verify provider timestamp access. |
|
||||||
|
| `CSC_CREDENTIAL_LIST_EMPTY` | The CSC provider returned no signing credentials for the authenticated user (400). | Enrol a signing credential with the provider, then authenticate again. |
|
||||||
|
| `CSC_CERT_INVALID` | The selected signing certificate is missing, invalid, or outside its validity period (400). | Select or renew a valid certificate, then authenticate again. |
|
||||||
|
| `CSC_ALGORITHM_REFUSED` | The signing credential uses an unsupported key or digest algorithm (400). | Select a credential that satisfies the instance's CSC algorithm policy. |
|
||||||
|
| `CSC_SAD_EXPIRED_PRE_SIGN` | The signature activation data is missing, expired, or unreadable before signing (400). | Repeat the credential authorization flow. |
|
||||||
|
| `CSC_TSP_TIMEOUT` | The trust service provider did not complete the signing request before the timeout (408). | Retry the signing request after checking provider availability. |
|
||||||
|
| `CSC_EMBED_FAILED` | The returned CSC signature could not be embedded into the envelope items (400). | Restart the signing attempt. If it fails again, contact support. |
|
||||||
|
| `CSC_BASE_DOCUMENT_MUTATED` | The document changed between signature preparation and signing (500). | Restart signing from the current envelope state. |
|
||||||
|
| `CSC_REQUEST_FAILED` | A CSC provider request failed without a more specific CSC error (500). | Check provider availability and configuration, then retry. |
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
|||||||
@@ -28,35 +28,62 @@ Each document contains one or more PDF files, a list of recipients, and the fiel
|
|||||||
|
|
||||||
A document object contains the following properties:
|
A document object contains the following properties:
|
||||||
|
|
||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
| --------------- | -------------- | -------------------------------------------------------------- |
|
| ------------------- | -------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
||||||
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) |
|
||||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` |
|
| `internalVersion` | number | Internal envelope schema version |
|
||||||
| `title` | string | Document title |
|
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
||||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
|
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
|
||||||
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
| `title` | string | Document title |
|
||||||
| `externalId` | string \| null | Your custom identifier for the document |
|
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
|
||||||
| `createdAt` | string | ISO 8601 timestamp |
|
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
||||||
| `updatedAt` | string | ISO 8601 timestamp |
|
| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) |
|
||||||
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
|
| `externalId` | string \| null | Your custom identifier for the document |
|
||||||
| `deletedAt` | string \| null | Timestamp if soft-deleted |
|
| `userId` | number | ID of the user who owns the document |
|
||||||
| `recipients` | array | List of recipients and their signing status |
|
| `teamId` | number | ID of the team the document belongs to |
|
||||||
| `fields` | array | Signature and form fields on the document |
|
| `folderId` | string \| null | ID of the folder containing the document |
|
||||||
| `envelopeItems` | array | PDF files attached to the document |
|
| `templateId` | number \| null | Legacy ID of the template this document was created from |
|
||||||
| `documentMeta` | object | Email settings, redirect URL, signing options |
|
| `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
|
### Example Document Object
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "envelope_abc123xyz",
|
"id": "envelope_abc123xyz",
|
||||||
|
"secondaryId": "document_123",
|
||||||
|
"internalVersion": 2,
|
||||||
"type": "DOCUMENT",
|
"type": "DOCUMENT",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"source": "API",
|
"source": "DOCUMENT",
|
||||||
"visibility": "EVERYONE",
|
"visibility": "EVERYONE",
|
||||||
|
"templateType": "PRIVATE",
|
||||||
"title": "Service Agreement",
|
"title": "Service Agreement",
|
||||||
"externalId": "contract-2025-001",
|
"externalId": "contract-2025-001",
|
||||||
|
"userId": 1,
|
||||||
|
"teamId": 1,
|
||||||
|
"folderId": null,
|
||||||
|
"templateId": null,
|
||||||
|
"authOptions": null,
|
||||||
|
"formValues": null,
|
||||||
|
"publicTitle": "",
|
||||||
|
"publicDescription": "",
|
||||||
"createdAt": "2025-01-15T10:30:00.000Z",
|
"createdAt": "2025-01-15T10:30:00.000Z",
|
||||||
"updatedAt": "2025-01-15T10:35:00.000Z",
|
"updatedAt": "2025-01-15T10:35:00.000Z",
|
||||||
"completedAt": null,
|
"completedAt": null,
|
||||||
@@ -73,23 +100,41 @@ A document object contains the following properties:
|
|||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"id": "field_123",
|
"id": 123,
|
||||||
|
"secondaryId": "field_abc123",
|
||||||
"type": "SIGNATURE",
|
"type": "SIGNATURE",
|
||||||
|
"recipientId": 1,
|
||||||
|
"envelopeId": "envelope_abc123xyz",
|
||||||
|
"envelopeItemId": "envelope_item_xyz",
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"positionX": 10,
|
"positionX": "10",
|
||||||
"positionY": 80,
|
"positionY": "80",
|
||||||
"width": 30,
|
"width": "30",
|
||||||
"height": 5,
|
"height": "5",
|
||||||
"recipientId": 1
|
"customText": "",
|
||||||
|
"inserted": false,
|
||||||
|
"fieldMeta": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"envelopeItems": [
|
"envelopeItems": [
|
||||||
{
|
{
|
||||||
"id": "envelope_item_xyz",
|
"id": "envelope_item_xyz",
|
||||||
|
"envelopeId": "envelope_abc123xyz",
|
||||||
|
"documentDataId": "doc_data_abc123",
|
||||||
"title": "contract.pdf",
|
"title": "contract.pdf",
|
||||||
"order": 1
|
"order": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"directLink": null,
|
||||||
|
"team": {
|
||||||
|
"id": 1,
|
||||||
|
"url": "your-team"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Jane Smith",
|
||||||
|
"email": "jane@example.com"
|
||||||
|
},
|
||||||
"documentMeta": {
|
"documentMeta": {
|
||||||
"subject": "Please sign this document",
|
"subject": "Please sign this document",
|
||||||
"message": "Hi, please review and sign this agreement.",
|
"message": "Hi, please review and sign this agreement.",
|
||||||
@@ -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
|
## List Documents
|
||||||
|
|
||||||
Retrieve a paginated list of documents.
|
Retrieve a paginated list of documents.
|
||||||
@@ -114,7 +161,7 @@ GET /envelope
|
|||||||
| `page` | integer | Page number (default: 1) |
|
| `page` | integer | Page number (default: 1) |
|
||||||
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
||||||
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
|
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
|
||||||
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` |
|
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
|
||||||
| `source` | string | Filter by creation source |
|
| `source` | string | Filter by creation source |
|
||||||
| `folderId` | string | Filter by folder ID |
|
| `folderId` | string | Filter by folder ID |
|
||||||
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
||||||
@@ -154,8 +201,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, pagination } = await response.json();
|
const { data, count } = await response.json();
|
||||||
console.log(`Found ${pagination.totalItems} documents`);
|
console.log(`Found ${count} documents`);
|
||||||
|
|
||||||
// Filter by status
|
// Filter by status
|
||||||
const pendingResponse = await fetch(
|
const pendingResponse = await fetch(
|
||||||
@@ -197,12 +244,10 @@ const pendingDocs = await pendingResponse.json();
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pagination": {
|
"count": 42,
|
||||||
"page": 1,
|
"currentPage": 1,
|
||||||
"perPage": 10,
|
"perPage": 10,
|
||||||
"totalPages": 5,
|
"totalPages": 5
|
||||||
"totalItems": 42
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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 Document
|
||||||
|
|
||||||
Delete a document. Completed documents cannot be deleted.
|
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();
|
const { success } = await response.json();
|
||||||
|
|
||||||
````
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -680,7 +791,7 @@ const { success } = await response.json();
|
|||||||
{
|
{
|
||||||
"success": true
|
"success": true
|
||||||
}
|
}
|
||||||
````
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -694,9 +805,11 @@ POST /envelope/get-many
|
|||||||
|
|
||||||
### Request Body
|
### Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ------------- | ----- | -------- | --------------------- |
|
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
|
||||||
| `envelopeIds` | array | Yes | Array of document IDs |
|
| `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
|
### Code Examples
|
||||||
|
|
||||||
@@ -707,12 +820,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
|
|||||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
"ids": {
|
||||||
|
"type": "envelopeId",
|
||||||
|
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||||
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value="TypeScript">
|
<Tab value="TypeScript">
|
||||||
```typescript
|
```typescript
|
||||||
|
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
|
||||||
|
|
||||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -720,16 +838,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
|
ids: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
ids: requestedIds,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const documents = await response.json();
|
const { data } = await response.json();
|
||||||
|
|
||||||
````
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "envelope_abc123",
|
||||||
|
"type": "DOCUMENT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"title": "Service Agreement"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Document Statuses
|
## Document Statuses
|
||||||
@@ -740,6 +878,7 @@ const documents = await response.json();
|
|||||||
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
|
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
|
||||||
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
||||||
| `REJECTED` | A recipient rejected the document. |
|
| `REJECTED` | A recipient rejected the document. |
|
||||||
|
| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. |
|
||||||
|
|
||||||
### Status Transitions
|
### Status Transitions
|
||||||
|
|
||||||
@@ -747,11 +886,13 @@ const documents = await response.json();
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
DRAFT --> PENDING --> COMPLETED
|
DRAFT --> PENDING --> COMPLETED
|
||||||
PENDING --> REJECTED
|
PENDING --> REJECTED
|
||||||
|
PENDING --> CANCELLED
|
||||||
```
|
```
|
||||||
|
|
||||||
- **DRAFT to PENDING**: Call the distribute endpoint
|
- **DRAFT to PENDING**: Call the distribute endpoint
|
||||||
- **PENDING to COMPLETED**: All recipients complete their signing
|
- **PENDING to COMPLETED**: All recipients complete their signing
|
||||||
- **PENDING to REJECTED**: A recipient rejects the document
|
- **PENDING to REJECTED**: A recipient rejects the document
|
||||||
|
- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
||||||
@@ -773,8 +914,8 @@ flowchart LR
|
|||||||
| Parameter | Values | Description |
|
| Parameter | Values | Description |
|
||||||
| ---------- | ------------------------------------------- | ------------------------- |
|
| ---------- | ------------------------------------------- | ------------------------- |
|
||||||
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
||||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
|
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
|
||||||
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
|
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
|
||||||
| `folderId` | string | Filter by folder |
|
| `folderId` | string | Filter by folder |
|
||||||
|
|
||||||
### Sorting
|
### Sorting
|
||||||
@@ -800,10 +941,10 @@ async function getAllPendingDocuments() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, pagination } = await response.json();
|
const { data, currentPage, totalPages } = await response.json();
|
||||||
documents.push(...data);
|
documents.push(...data);
|
||||||
|
|
||||||
hasMore = page < pagination.totalPages;
|
hasMore = currentPage < totalPages;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ description: Add signature and form fields to documents via API.
|
|||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
@@ -19,13 +21,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
|||||||
| `secondaryId` | string | Secondary identifier for audit logs |
|
| `secondaryId` | string | Secondary identifier for audit logs |
|
||||||
| `type` | string | Field type (see [Field Types](#field-types)) |
|
| `type` | string | Field type (see [Field Types](#field-types)) |
|
||||||
| `recipientId` | number | ID of the recipient assigned to this field |
|
| `recipientId` | number | ID of the recipient assigned to this field |
|
||||||
| `envelopeId` | number | ID of the parent envelope |
|
| `envelopeId` | string | ID of the parent envelope |
|
||||||
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
|
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
|
||||||
| `page` | number | Page number (1-indexed) |
|
| `page` | number | Page number (1-indexed) |
|
||||||
| `positionX` | number | X coordinate as percentage (0-100) |
|
| `positionX` | string | X coordinate as percentage (0-100), a decimal serialized as a string |
|
||||||
| `positionY` | number | Y coordinate as percentage (0-100) |
|
| `positionY` | string | Y coordinate as percentage (0-100), a decimal serialized as a string |
|
||||||
| `width` | number | Width as percentage of page (0-100) |
|
| `width` | string | Width as percentage of page (0-100), a decimal serialized as a string |
|
||||||
| `height` | number | Height as percentage of page (0-100) |
|
| `height` | string | Height as percentage of page (0-100), a decimal serialized as a string |
|
||||||
| `customText` | string | Value entered by the recipient |
|
| `customText` | string | Value entered by the recipient |
|
||||||
| `inserted` | boolean | Whether the field has been completed |
|
| `inserted` | boolean | Whether the field has been completed |
|
||||||
| `fieldMeta` | object \| null | Type-specific configuration options |
|
| `fieldMeta` | object \| null | Type-specific configuration options |
|
||||||
@@ -38,18 +40,19 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
|||||||
"secondaryId": "field_abc123",
|
"secondaryId": "field_abc123",
|
||||||
"type": "SIGNATURE",
|
"type": "SIGNATURE",
|
||||||
"recipientId": 123,
|
"recipientId": 123,
|
||||||
"envelopeId": 789,
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"envelopeItemId": "envelope_item_xyz",
|
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"positionX": 10,
|
"positionX": "10",
|
||||||
"positionY": 80,
|
"positionY": "80",
|
||||||
"width": 30,
|
"width": "30",
|
||||||
"height": 5,
|
"height": "5",
|
||||||
"customText": "",
|
"customText": "",
|
||||||
"inserted": false,
|
"inserted": false,
|
||||||
"fieldMeta": {
|
"fieldMeta": {
|
||||||
"type": "signature",
|
"type": "signature",
|
||||||
"required": true
|
"required": true,
|
||||||
|
"overflow": "auto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -61,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
|||||||
| Type | Description | Auto-filled |
|
| Type | Description | Auto-filled |
|
||||||
| ---------------- | ----------------------------------------- | ----------- |
|
| ---------------- | ----------------------------------------- | ----------- |
|
||||||
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
|
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
|
||||||
| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
|
| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No |
|
||||||
| `INITIALS` | Recipient's initials | No |
|
| `INITIALS` | Recipient's initials | No |
|
||||||
| `NAME` | Recipient's full name | Yes |
|
| `NAME` | Recipient's full name | Yes |
|
||||||
| `EMAIL` | Recipient's email address | Yes |
|
| `EMAIL` | Recipient's email address | Yes |
|
||||||
@@ -134,10 +137,12 @@ POST /envelope/field/create-many
|
|||||||
|
|
||||||
### Request Body
|
### Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ----------- | ------ | -------- | ------------------------------- |
|
| ------------ | ------ | -------- | ------------------------------- |
|
||||||
| `documentId`| number | Yes | The document ID |
|
| `envelopeId` | string | Yes | The envelope ID |
|
||||||
| `fields` | array | Yes | Array of field configurations |
|
| `data` | array | Yes | Array of field configurations |
|
||||||
|
|
||||||
|
Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`.
|
||||||
|
|
||||||
### Code Examples
|
### Code Examples
|
||||||
|
|
||||||
@@ -148,32 +153,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
|
|||||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"documentId": 123,
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"fields": [
|
"data": [
|
||||||
{
|
{
|
||||||
"type": "SIGNATURE",
|
"type": "SIGNATURE",
|
||||||
"recipientId": 456,
|
"recipientId": 456,
|
||||||
"pageNumber": 1,
|
"page": 1,
|
||||||
"pageX": 10,
|
"positionX": 10,
|
||||||
"pageY": 80,
|
"positionY": 80,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"height": 5
|
"height": 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "DATE",
|
"type": "DATE",
|
||||||
"recipientId": 456,
|
"recipientId": 456,
|
||||||
"pageNumber": 1,
|
"page": 1,
|
||||||
"pageX": 50,
|
"positionX": 50,
|
||||||
"pageY": 80,
|
"positionY": 80,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"height": 3
|
"height": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "TEXT",
|
"type": "TEXT",
|
||||||
"recipientId": 456,
|
"recipientId": 456,
|
||||||
"pageNumber": 1,
|
"page": 1,
|
||||||
"pageX": 10,
|
"positionX": 10,
|
||||||
"pageY": 70,
|
"positionY": 70,
|
||||||
"width": 40,
|
"width": 40,
|
||||||
"height": 4,
|
"height": 4,
|
||||||
"fieldMeta": {
|
"fieldMeta": {
|
||||||
@@ -199,32 +204,32 @@ const response = await fetch(
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
documentId: 123,
|
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||||
fields: [
|
data: [
|
||||||
{
|
{
|
||||||
type: 'SIGNATURE',
|
type: 'SIGNATURE',
|
||||||
recipientId: 456,
|
recipientId: 456,
|
||||||
pageNumber: 1,
|
page: 1,
|
||||||
pageX: 10,
|
positionX: 10,
|
||||||
pageY: 80,
|
positionY: 80,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 5,
|
height: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'DATE',
|
type: 'DATE',
|
||||||
recipientId: 456,
|
recipientId: 456,
|
||||||
pageNumber: 1,
|
page: 1,
|
||||||
pageX: 50,
|
positionX: 50,
|
||||||
pageY: 80,
|
positionY: 80,
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 3,
|
height: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'TEXT',
|
type: 'TEXT',
|
||||||
recipientId: 456,
|
recipientId: 456,
|
||||||
pageNumber: 1,
|
page: 1,
|
||||||
pageX: 10,
|
positionX: 10,
|
||||||
pageY: 70,
|
positionY: 70,
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 4,
|
height: 4,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
@@ -239,8 +244,8 @@ const response = await fetch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { fields } = await response.json();
|
const { data } = await response.json();
|
||||||
console.log(`Created ${fields.length} fields`);
|
console.log(`Created ${data.length} fields`);
|
||||||
|
|
||||||
````
|
````
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -250,36 +255,68 @@ console.log(`Created ${fields.length} fields`);
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"fields": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": 101,
|
"id": 101,
|
||||||
|
"secondaryId": "field_abc123",
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
|
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||||
"type": "SIGNATURE",
|
"type": "SIGNATURE",
|
||||||
"recipientId": 456,
|
"recipientId": 456,
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"positionX": 10,
|
"positionX": "10",
|
||||||
"positionY": 80,
|
"positionY": "80",
|
||||||
"width": 30,
|
"width": "30",
|
||||||
"height": 5
|
"height": "5",
|
||||||
|
"customText": "",
|
||||||
|
"inserted": false,
|
||||||
|
"fieldMeta": {
|
||||||
|
"type": "signature",
|
||||||
|
"fontSize": 18,
|
||||||
|
"overflow": "auto"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 102,
|
"id": 102,
|
||||||
|
"secondaryId": "field_def456",
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
|
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||||
"type": "DATE",
|
"type": "DATE",
|
||||||
"recipientId": 456,
|
"recipientId": 456,
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"positionX": 50,
|
"positionX": "50",
|
||||||
"positionY": 80,
|
"positionY": "80",
|
||||||
"width": 20,
|
"width": "20",
|
||||||
"height": 3
|
"height": "3",
|
||||||
|
"customText": "",
|
||||||
|
"inserted": false,
|
||||||
|
"fieldMeta": {
|
||||||
|
"type": "date",
|
||||||
|
"fontSize": 12,
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "auto"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 103,
|
"id": 103,
|
||||||
|
"secondaryId": "field_ghi789",
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
|
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||||
"type": "TEXT",
|
"type": "TEXT",
|
||||||
"recipientId": 456,
|
"recipientId": 456,
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"positionX": 10,
|
"positionX": "10",
|
||||||
"positionY": 70,
|
"positionY": "70",
|
||||||
"width": 40,
|
"width": "40",
|
||||||
"height": 4
|
"height": "4",
|
||||||
|
"customText": "",
|
||||||
|
"inserted": false,
|
||||||
|
"fieldMeta": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Job Title",
|
||||||
|
"placeholder": "Enter your job title",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -299,8 +336,10 @@ POST /envelope/field/update-many
|
|||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ------------ | ------ | -------- | ----------------------------- |
|
| ------------ | ------ | -------- | ----------------------------- |
|
||||||
| `documentId` | number | Yes | The document ID |
|
| `envelopeId` | string | Yes | The envelope ID |
|
||||||
| `fields` | array | Yes | Array of field update objects |
|
| `data` | array | Yes | Array of field update objects |
|
||||||
|
|
||||||
|
Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates.
|
||||||
|
|
||||||
### Code Examples
|
### Code Examples
|
||||||
|
|
||||||
@@ -311,17 +350,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
|
|||||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"documentId": 123,
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"fields": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": 101,
|
"id": 101,
|
||||||
"type": "SIGNATURE",
|
"type": "SIGNATURE",
|
||||||
"pageY": 85
|
"positionY": 85
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 102,
|
"id": 102,
|
||||||
"type": "DATE",
|
"type": "DATE",
|
||||||
"pageY": 85
|
"positionY": 85
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
@@ -338,16 +377,16 @@ const response = await fetch(
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
documentId: 123,
|
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||||
fields: [
|
data: [
|
||||||
{ id: 101, type: 'SIGNATURE', pageY: 85 },
|
{ id: 101, type: 'SIGNATURE', positionY: 85 },
|
||||||
{ id: 102, type: 'DATE', pageY: 85 },
|
{ id: 102, type: 'DATE', positionY: 85 },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { fields } = await response.json();
|
const { data } = await response.json();
|
||||||
|
|
||||||
````
|
````
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -357,9 +396,48 @@ const { fields } = await response.json();
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"fields": [
|
"data": [
|
||||||
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
|
{
|
||||||
{ "id": 102, "type": "DATE", "positionY": 85 }
|
"id": 101,
|
||||||
|
"secondaryId": "field_abc123",
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
|
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||||
|
"type": "SIGNATURE",
|
||||||
|
"recipientId": 456,
|
||||||
|
"page": 1,
|
||||||
|
"positionX": "10",
|
||||||
|
"positionY": "85",
|
||||||
|
"width": "30",
|
||||||
|
"height": "5",
|
||||||
|
"customText": "",
|
||||||
|
"inserted": false,
|
||||||
|
"fieldMeta": {
|
||||||
|
"type": "signature",
|
||||||
|
"fontSize": 18,
|
||||||
|
"overflow": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 102,
|
||||||
|
"secondaryId": "field_def456",
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
|
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||||
|
"type": "DATE",
|
||||||
|
"recipientId": 456,
|
||||||
|
"page": 1,
|
||||||
|
"positionX": "50",
|
||||||
|
"positionY": "85",
|
||||||
|
"width": "20",
|
||||||
|
"height": "3",
|
||||||
|
"customText": "",
|
||||||
|
"inserted": false,
|
||||||
|
"fieldMeta": {
|
||||||
|
"type": "date",
|
||||||
|
"fontSize": 12,
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
````
|
````
|
||||||
@@ -443,8 +521,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
|
|||||||
(0,0) ─────────────────────────── (100,0)
|
(0,0) ─────────────────────────── (100,0)
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────┐ │
|
│ ┌─────────┐ │
|
||||||
│ │ Field │ (pageX: 10, │
|
│ │ Field │ (positionX: 10, │
|
||||||
│ │ │ pageY: 20, │
|
│ │ │ positionY: 20, │
|
||||||
│ └─────────┘ width: 30, │
|
│ └─────────┘ width: 30, │
|
||||||
│ height: 5) │
|
│ height: 5) │
|
||||||
│ │
|
│ │
|
||||||
@@ -457,9 +535,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
|
|||||||
const field = {
|
const field = {
|
||||||
type: 'SIGNATURE',
|
type: 'SIGNATURE',
|
||||||
recipientId: 123,
|
recipientId: 123,
|
||||||
pageNumber: 1,
|
page: 1,
|
||||||
pageX: 60, // 60% from left
|
positionX: 60, // 60% from left
|
||||||
pageY: 85, // 85% from top (near bottom)
|
positionY: 85, // 85% from top (near bottom)
|
||||||
width: 30, // 30% of page width
|
width: 30, // 30% of page width
|
||||||
height: 8, // 8% of page height
|
height: 8, // 8% of page height
|
||||||
};
|
};
|
||||||
@@ -479,6 +557,33 @@ This approach is useful when generating PDFs programmatically or using templates
|
|||||||
|
|
||||||
See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options.
|
See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options.
|
||||||
|
|
||||||
|
### Placeholder Positioning via the API
|
||||||
|
|
||||||
|
`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass:
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. |
|
||||||
|
| `width` | number | No | Override the field width. Defaults to the width of the matched text. |
|
||||||
|
| `height` | number | No | Override the field height. Defaults to the height of the matched text. |
|
||||||
|
| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"type": "SIGNATURE",
|
||||||
|
"recipientId": 456,
|
||||||
|
"placeholder": "{{signature}}",
|
||||||
|
"matchAll": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Field Meta Options
|
## Field Meta Options
|
||||||
@@ -643,15 +748,15 @@ All field types support these base options:
|
|||||||
Create a document with a signature block containing multiple field types:
|
Create a document with a signature block containing multiple field types:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function addSignatureBlock(documentId: number, recipientId: number) {
|
async function addSignatureBlock(envelopeId: string, recipientId: number) {
|
||||||
const fields = [
|
const data = [
|
||||||
// Signature
|
// Signature
|
||||||
{
|
{
|
||||||
type: 'SIGNATURE',
|
type: 'SIGNATURE',
|
||||||
recipientId,
|
recipientId,
|
||||||
pageNumber: 1,
|
page: 1,
|
||||||
pageX: 10,
|
positionX: 10,
|
||||||
pageY: 80,
|
positionY: 80,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 8,
|
height: 8,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
@@ -663,9 +768,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
|
|||||||
{
|
{
|
||||||
type: 'NAME',
|
type: 'NAME',
|
||||||
recipientId,
|
recipientId,
|
||||||
pageNumber: 1,
|
page: 1,
|
||||||
pageX: 10,
|
positionX: 10,
|
||||||
pageY: 90,
|
positionY: 90,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 4,
|
height: 4,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
@@ -677,9 +782,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
|
|||||||
{
|
{
|
||||||
type: 'DATE',
|
type: 'DATE',
|
||||||
recipientId,
|
recipientId,
|
||||||
pageNumber: 1,
|
page: 1,
|
||||||
pageX: 50,
|
positionX: 50,
|
||||||
pageY: 80,
|
positionY: 80,
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 4,
|
height: 4,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
@@ -691,9 +796,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
|
|||||||
{
|
{
|
||||||
type: 'TEXT',
|
type: 'TEXT',
|
||||||
recipientId,
|
recipientId,
|
||||||
pageNumber: 1,
|
page: 1,
|
||||||
pageX: 50,
|
positionX: 50,
|
||||||
pageY: 90,
|
positionY: 90,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 4,
|
height: 4,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
@@ -710,7 +815,7 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
|
|||||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ documentId, fields }),
|
body: JSON.stringify({ envelopeId, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
|
|||||||
href="/docs/developers/api/templates"
|
href="/docs/developers/api/templates"
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
title="Teams"
|
title="Team-scoped access"
|
||||||
description="Manage teams and team members."
|
description="Use team-scoped API tokens with envelope endpoints."
|
||||||
href="/docs/developers/api/teams"
|
href="/docs/developers/api/teams"
|
||||||
/>
|
/>
|
||||||
</Cards>
|
</Cards>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
|||||||
| ------------------------------------------------- | ----------------------------------------------------- |
|
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||||
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
||||||
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
|
| `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` | `POST /api/v2/envelope/create` |
|
||||||
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
|
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
|
||||||
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
||||||
@@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
|||||||
| ------------------------------------- | ------------------------------------------------ |
|
| ------------------------------------- | ------------------------------------------------ |
|
||||||
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||||
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
|
| `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` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
|||||||
|
|
||||||
## HTTP Rate Limits
|
## HTTP Rate Limits
|
||||||
|
|
||||||
|
The rate limit applies to:
|
||||||
|
|
||||||
|
- `/api/v1/*`
|
||||||
|
- `/api/v2/*`
|
||||||
|
- `/api/v2-beta/*`
|
||||||
|
|
||||||
**Limit:** 1000 requests per minute per IP address
|
**Limit:** 1000 requests per minute per IP address
|
||||||
**Response:** 429 Too Many Requests
|
**Response:** 429 Too Many Requests
|
||||||
|
|
||||||
@@ -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.
|
this value, in which case you can be rate-limited before reaching the global limit.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
### Rate Limit Response
|
### Global per-IP 429 Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="warn">
|
### Rate Limit Headers
|
||||||
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
|
|
||||||
seconds before retrying.
|
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
|
||||||
</Callout>
|
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
|
## 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 |
|
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
||||||
| Direct Templates | 3 | 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
|
```json
|
||||||
{
|
{
|
||||||
"error": "You have reached your document limit for this month. Please upgrade your plan.",
|
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||||
"code": "LIMIT_EXCEEDED",
|
|
||||||
"statusCode": 400
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
|
||||||
|
"code": "TOO_MANY_REQUESTS",
|
||||||
|
"data": {
|
||||||
|
"code": "TOO_MANY_REQUESTS",
|
||||||
|
"httpStatus": 429,
|
||||||
|
"appError": {
|
||||||
|
"code": "TOO_MANY_REQUESTS",
|
||||||
|
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
|
||||||
|
their own window. Monthly quota responses carry no quota-specific rate limit headers or
|
||||||
|
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
|
||||||
|
|
||||||
## Error Codes
|
## Error Codes
|
||||||
|
|
||||||
| Code | Status | Description |
|
| Code | Status | Description |
|
||||||
| ------------------- | ------ | ----------------------------- |
|
| ------------------- | ------ | ------------------------------------------------------------------ |
|
||||||
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
|
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
|
||||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
||||||
|
|
||||||
|
There are three sources of `TOO_MANY_REQUESTS` responses:
|
||||||
|
|
||||||
|
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
|
||||||
|
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
|
||||||
|
3. Organisation monthly quotas for the same three counters. Every authenticated API request
|
||||||
|
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
|
||||||
|
exhausted — not just envelope-related ones.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ description: Add and manage envelope recipients via API.
|
|||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
@@ -16,7 +18,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 123,
|
"id": 123,
|
||||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "SIGNER",
|
"role": "SIGNER",
|
||||||
@@ -134,7 +136,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/create-many" \
|
|||||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -164,7 +166,7 @@ const response = await fetch(
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
email: 'signer@example.com',
|
email: 'signer@example.com',
|
||||||
@@ -196,7 +198,7 @@ const { data: recipients } = await response.json();
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": 789,
|
"id": 789,
|
||||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "SIGNER",
|
"role": "SIGNER",
|
||||||
@@ -209,7 +211,7 @@ const { data: recipients } = await response.json();
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 790,
|
"id": 790,
|
||||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "approver@example.com",
|
"email": "approver@example.com",
|
||||||
"name": "Jane Smith",
|
"name": "Jane Smith",
|
||||||
"role": "APPROVER",
|
"role": "APPROVER",
|
||||||
@@ -262,7 +264,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/update-many" \
|
|||||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": 789,
|
"id": 789,
|
||||||
@@ -284,7 +286,7 @@ const response = await fetch(
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 789,
|
id: 789,
|
||||||
@@ -387,7 +389,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
email: 'approver@example.com',
|
email: 'approver@example.com',
|
||||||
@@ -462,7 +464,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
email: 'signer@example.com',
|
email: 'signer@example.com',
|
||||||
|
|||||||
@@ -1,44 +1,29 @@
|
|||||||
---
|
---
|
||||||
title: Teams API
|
title: Team-Scoped API Access
|
||||||
description: Manage team resources, documents, and templates with team-scoped API tokens.
|
description: Use team-scoped API tokens with document and template envelopes.
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Team Object
|
## Team Context
|
||||||
|
|
||||||
A team object contains the following properties:
|
<Callout type="info">
|
||||||
|
The V2 REST API does not expose `/team/*` endpoints. Create and manage teams, members, and team
|
||||||
|
settings in the Documenso web application. This page explains how a team-scoped token applies
|
||||||
|
that team context to supported API resources.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
| Property | Type | Description |
|
The API resolves the team from your token. You do not pass a team ID when creating, listing, or
|
||||||
| ----------------- | -------------- | --------------------------------------------------- |
|
using envelopes. The token's team ID determines which resources the request can access.
|
||||||
| `id` | number | Unique team identifier |
|
|
||||||
| `name` | string | Team display name |
|
|
||||||
| `url` | string | Unique team URL slug |
|
|
||||||
| `createdAt` | string | ISO 8601 timestamp |
|
|
||||||
| `avatarImageId` | string \| null | ID of the team's avatar image |
|
|
||||||
| `organisationId` | string | ID of the parent organisation |
|
|
||||||
| `currentTeamRole` | string | Your role in the team: `ADMIN`, `MANAGER`, `MEMBER` |
|
|
||||||
|
|
||||||
### Example Team Object
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 123,
|
|
||||||
"name": "Engineering",
|
|
||||||
"url": "engineering",
|
|
||||||
"createdAt": "2025-01-15T10:30:00.000Z",
|
|
||||||
"avatarImageId": null,
|
|
||||||
"organisationId": "org_abc123",
|
|
||||||
"currentTeamRole": "ADMIN"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Team-Scoped API Tokens
|
## Team-Scoped API Tokens
|
||||||
|
|
||||||
@@ -95,7 +80,7 @@ Documents created with a team token belong to that team:
|
|||||||
<Tab value="curl">
|
<Tab value="curl">
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
|
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" \
|
-H "Content-Type: multipart/form-data" \
|
||||||
-F 'payload={
|
-F 'payload={
|
||||||
"type": "DOCUMENT",
|
"type": "DOCUMENT",
|
||||||
@@ -156,26 +141,26 @@ Retrieve all documents belonging to the team:
|
|||||||
<Tab value="curl">
|
<Tab value="curl">
|
||||||
```bash
|
```bash
|
||||||
# List all team documents
|
# List all team documents
|
||||||
curl -X GET "https://app.documenso.com/api/v2/envelope" \
|
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT" \
|
||||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||||
|
|
||||||
# Filter by status
|
# Filter by status
|
||||||
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
|
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
|
||||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||||
````
|
````
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value="TypeScript">
|
<Tab value="TypeScript">
|
||||||
```typescript
|
```typescript
|
||||||
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
|
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: TEAM_API_TOKEN,
|
Authorization: TEAM_API_TOKEN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, pagination } = await response.json();
|
const { data, count } = await response.json();
|
||||||
console.log(`Found ${pagination.totalItems} team documents`);
|
console.log(`Found ${count} team documents`);
|
||||||
|
|
||||||
````
|
````
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -190,10 +175,11 @@ Templates created with a team token are shared across the team.
|
|||||||
<Tabs items={['curl', 'TypeScript']}>
|
<Tabs items={['curl', 'TypeScript']}>
|
||||||
<Tab value="curl">
|
<Tab value="curl">
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "https://app.documenso.com/api/v2/template/create" \
|
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" \
|
-H "Content-Type: multipart/form-data" \
|
||||||
-F 'payload={
|
-F 'payload={
|
||||||
|
"type": "TEMPLATE",
|
||||||
"title": "NDA Template",
|
"title": "NDA Template",
|
||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
@@ -223,6 +209,7 @@ curl -X POST "https://app.documenso.com/api/v2/template/create" \
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
type: 'TEMPLATE',
|
||||||
title: 'NDA Template',
|
title: 'NDA Template',
|
||||||
recipients: [
|
recipients: [
|
||||||
{
|
{
|
||||||
@@ -249,7 +236,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
|
|||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
|
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: TEAM_API_TOKEN,
|
Authorization: TEAM_API_TOKEN,
|
||||||
@@ -257,8 +244,8 @@ const response = await fetch('https://app.documenso.com/api/v2/template/create',
|
|||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await response.json();
|
const { id } = await response.json();
|
||||||
console.log('Created team template:', template.id);
|
console.log('Created team template envelope:', id);
|
||||||
````
|
````
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -268,14 +255,14 @@ console.log('Created team template:', template.id);
|
|||||||
<Tabs items={['curl', 'TypeScript']}>
|
<Tabs items={['curl', 'TypeScript']}>
|
||||||
<Tab value="curl">
|
<Tab value="curl">
|
||||||
```bash
|
```bash
|
||||||
curl -X GET "https://app.documenso.com/api/v2/template" \
|
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
|
||||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||||
````
|
````
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value="TypeScript">
|
<Tab value="TypeScript">
|
||||||
```typescript
|
```typescript
|
||||||
const response = await fetch('https://app.documenso.com/api/v2/template', {
|
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: TEAM_API_TOKEN,
|
Authorization: TEAM_API_TOKEN,
|
||||||
@@ -330,19 +317,19 @@ const SALES_TEAM_TOKEN = process.env.SALES_TEAM_API_TOKEN;
|
|||||||
const LEGAL_TEAM_TOKEN = process.env.LEGAL_TEAM_API_TOKEN;
|
const LEGAL_TEAM_TOKEN = process.env.LEGAL_TEAM_API_TOKEN;
|
||||||
|
|
||||||
// Get pending documents from sales team
|
// Get pending documents from sales team
|
||||||
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=PENDING', {
|
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING', {
|
||||||
headers: { Authorization: SALES_TEAM_TOKEN },
|
headers: { Authorization: SALES_TEAM_TOKEN },
|
||||||
});
|
});
|
||||||
const salesDocs = await salesResponse.json();
|
const salesDocs = await salesResponse.json();
|
||||||
|
|
||||||
// Get completed documents from legal team
|
// Get completed documents from legal team
|
||||||
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', {
|
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=COMPLETED', {
|
||||||
headers: { Authorization: LEGAL_TEAM_TOKEN },
|
headers: { Authorization: LEGAL_TEAM_TOKEN },
|
||||||
});
|
});
|
||||||
const legalDocs = await legalResponse.json();
|
const legalDocs = await legalResponse.json();
|
||||||
|
|
||||||
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
|
console.log(`Sales team: ${salesDocs.count} pending`);
|
||||||
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
|
console.log(`Legal team: ${legalDocs.count} completed`);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|||||||
@@ -13,7 +13,194 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
|||||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Template Object
|
## Use a Template Envelope
|
||||||
|
|
||||||
|
New integrations should create a document from a template envelope with the Envelope API.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /envelope/use
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
The request uses `multipart/form-data`:
|
||||||
|
|
||||||
|
| Part | Type | Required | Description |
|
||||||
|
| --------- | ------- | -------- | ------------------------------------------------------------------ |
|
||||||
|
| `payload` | JSON | Yes | Template envelope ID, recipient details, and document settings |
|
||||||
|
| `files` | File(s) | No | Replacement PDFs referenced by entries in `customDocumentData` |
|
||||||
|
|
||||||
|
### Payload Schema
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| -------------------- | ------- | -------- | ------------------------------------------------------------------------ |
|
||||||
|
| `envelopeId` | string | Yes | ID of the template envelope |
|
||||||
|
| `externalId` | string | No | Your identifier for the created document envelope |
|
||||||
|
| `recipients` | array | No | Recipient details mapped to recipients in the template |
|
||||||
|
| `distributeDocument` | boolean | No | If `true`, create the document as pending and distribute it |
|
||||||
|
| `customDocumentData` | array | No | Maps uploaded replacement PDFs to template envelope items |
|
||||||
|
| `folderId` | string | No | Folder in which to create the document |
|
||||||
|
| `prefillFields` | array | No | Field values to prefill before distribution |
|
||||||
|
| `override` | object | No | Template values to override for the created document |
|
||||||
|
| `attachments` | array | No | Link attachments to add to the document |
|
||||||
|
| `formValues` | object | No | PDF form values to apply |
|
||||||
|
|
||||||
|
Each recipient entry accepts the following fields:
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| -------------- | ------- | -------- | -------------------------------------------- |
|
||||||
|
| `id` | number | Yes | Recipient ID from the template envelope |
|
||||||
|
| `email` | string | Yes | Recipient email address |
|
||||||
|
| `name` | string | No | Recipient display name |
|
||||||
|
| `signingOrder` | number | No | Recipient position in sequential signing |
|
||||||
|
|
||||||
|
Each `customDocumentData` entry maps an uploaded file to a template item:
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ---------------- | ---------------- | -------- | --------------------------------------------------------------- |
|
||||||
|
| `identifier` | string \| number | Yes | Uploaded filename or zero-based file index |
|
||||||
|
| `envelopeItemId` | string | Yes | Template envelope item whose PDF the uploaded file replaces |
|
||||||
|
|
||||||
|
### Code Examples
|
||||||
|
|
||||||
|
<Tabs items={['curl', 'TypeScript']}>
|
||||||
|
<Tab value="curl">
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://app.documenso.com/api/v2/envelope/use" \
|
||||||
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
|
-F 'payload={
|
||||||
|
"envelopeId": "envelope_template123",
|
||||||
|
"externalId": "contract-2025-001",
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"name": "John Doe"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefillFields": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"type": "text",
|
||||||
|
"value": "Senior Software Engineer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"distributeDocument": false
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab value="TypeScript">
|
||||||
|
```typescript
|
||||||
|
const form = new FormData();
|
||||||
|
|
||||||
|
form.append(
|
||||||
|
'payload',
|
||||||
|
JSON.stringify({
|
||||||
|
envelopeId: 'envelope_template123',
|
||||||
|
externalId: 'contract-2025-001',
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
email: 'john.doe@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prefillFields: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
type: 'text',
|
||||||
|
value: 'Senior Software Engineer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
distributeDocument: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch('https://app.documenso.com/api/v2/envelope/use', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await response.json();
|
||||||
|
console.log('Created document envelope:', document.id);
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "envelope_document123",
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"token": "recipient_token",
|
||||||
|
"role": "SIGNER",
|
||||||
|
"signingOrder": 1,
|
||||||
|
"signingUrl": "https://app.documenso.com/sign/recipient_token"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Distribute the Created Envelope
|
||||||
|
|
||||||
|
If you leave `distributeDocument` unset or set it to `false`, distribute the created document with
|
||||||
|
`POST /envelope/distribute`. Its response confirms delivery and includes each recipient's signing URL.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const distributionResponse = await fetch(
|
||||||
|
'https://app.documenso.com/api/v2/envelope/distribute',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
envelopeId: document.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const distribution = await distributionResponse.json();
|
||||||
|
console.log('Signing URL:', distribution.recipients[0].signingUrl);
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"id": "envelope_document123",
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"token": "recipient_token",
|
||||||
|
"role": "SIGNER",
|
||||||
|
"signingOrder": 1,
|
||||||
|
"signingUrl": "https://app.documenso.com/sign/recipient_token"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deprecated Template Endpoint Reference
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
Every `/template/*` endpoint below is deprecated. Use the Envelope API for new integrations and
|
||||||
|
follow [Migrating to Envelopes](/docs/developers/api/migrate-to-envelopes) to replace existing calls.
|
||||||
|
The legacy reference remains here to support migrations.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Legacy Template Object
|
||||||
|
|
||||||
A template object contains the following properties:
|
A template object contains the following properties:
|
||||||
|
|
||||||
@@ -91,7 +278,7 @@ A template object contains the following properties:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## List Templates
|
## List Templates (Deprecated)
|
||||||
|
|
||||||
Retrieve a paginated list of templates.
|
Retrieve a paginated list of templates.
|
||||||
|
|
||||||
@@ -139,8 +326,8 @@ const response = await fetch(`${BASE_URL}/template`, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, pagination } = await response.json();
|
const { data, count } = await response.json();
|
||||||
console.log(`Found ${pagination.totalItems} templates`);
|
console.log(`Found ${count} templates`);
|
||||||
|
|
||||||
// Filter by type
|
// Filter by type
|
||||||
const privateResponse = await fetch(
|
const privateResponse = await fetch(
|
||||||
@@ -181,18 +368,16 @@ const privateTemplates = await privateResponse.json();
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pagination": {
|
"count": 25,
|
||||||
"page": 1,
|
"currentPage": 1,
|
||||||
"perPage": 10,
|
"perPage": 10,
|
||||||
"totalPages": 3,
|
"totalPages": 3
|
||||||
"totalItems": 25
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Get Template
|
## Get Template (Deprecated)
|
||||||
|
|
||||||
Retrieve a single template by ID.
|
Retrieve a single template by ID.
|
||||||
|
|
||||||
@@ -238,9 +423,9 @@ Returns the full template object including recipients, fields, and metadata.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Create Document from Template
|
## Create Document from Template (Deprecated)
|
||||||
|
|
||||||
Create a new document using a template. This is the primary way to use templates programmatically.
|
Create a new document using the deprecated template endpoint.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning.
|
This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning.
|
||||||
@@ -415,32 +600,57 @@ const prefilledDocument = await prefillResponse.json();
|
|||||||
|
|
||||||
### Response
|
### Response
|
||||||
|
|
||||||
Returns the created document object with recipients and signing URLs.
|
The endpoint returns the full legacy document object. The selected fields below show both the numeric
|
||||||
|
legacy `id` and canonical `envelopeId`. Recipient entries do not include a `signingUrl`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "envelope_xyz789",
|
"id": 789,
|
||||||
"type": "DOCUMENT",
|
"envelopeId": "envelope_xyz789",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"title": "Employment Contract",
|
|
||||||
"source": "TEMPLATE",
|
"source": "TEMPLATE",
|
||||||
|
"title": "Employment Contract",
|
||||||
"externalId": "contract-2025-001",
|
"externalId": "contract-2025-001",
|
||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
"envelopeId": "envelope_xyz789",
|
||||||
|
"documentId": 789,
|
||||||
|
"templateId": null,
|
||||||
"email": "john.doe@example.com",
|
"email": "john.doe@example.com",
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "SIGNER",
|
"role": "SIGNER",
|
||||||
"signingStatus": "NOT_SIGNED",
|
"signingStatus": "NOT_SIGNED",
|
||||||
"signingUrl": "https://app.documenso.com/sign/abc123"
|
"signingOrder": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
````
|
```
|
||||||
|
|
||||||
|
To send a document created with `distributeDocument: false` and receive signing links, call
|
||||||
|
`POST /envelope/distribute` with its `envelopeId`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const document = await response.json();
|
||||||
|
|
||||||
|
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: API_TOKEN,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
envelopeId: document.envelopeId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const distribution = await distributionResponse.json();
|
||||||
|
console.log('Signing URL:', distribution.recipients[0].signingUrl);
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Override Template Settings
|
## Override Template Settings (Deprecated)
|
||||||
|
|
||||||
When creating a document from a template, you can override various settings:
|
When creating a document from a template, you can override various settings:
|
||||||
|
|
||||||
@@ -488,7 +698,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prefill Fields
|
## Prefill Fields (Deprecated)
|
||||||
|
|
||||||
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
|
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
|
||||||
|
|
||||||
@@ -577,7 +787,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Update Template
|
## Update Template (Deprecated)
|
||||||
|
|
||||||
Update a template's properties.
|
Update a template's properties.
|
||||||
|
|
||||||
@@ -643,7 +853,7 @@ const template = await response.json();
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Duplicate Template
|
## Duplicate Template (Deprecated)
|
||||||
|
|
||||||
Create a copy of an existing template.
|
Create a copy of an existing template.
|
||||||
|
|
||||||
@@ -695,7 +905,7 @@ console.log('New template ID:', duplicatedTemplate.id);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Delete Template
|
## Delete Template (Deprecated)
|
||||||
|
|
||||||
Delete a template.
|
Delete a template.
|
||||||
|
|
||||||
@@ -754,7 +964,7 @@ const { success } = await response.json();
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Direct Link Templates
|
## Direct Link Templates (Deprecated)
|
||||||
|
|
||||||
Direct link templates allow recipients to create and sign documents without requiring you to explicitly create each document. When a recipient visits the direct link, a new document is automatically created from the template.
|
Direct link templates allow recipients to create and sign documents without requiring you to explicitly create each document. When a recipient visits the direct link, a new document is automatically created from the template.
|
||||||
|
|
||||||
@@ -898,7 +1108,7 @@ const { success } = await response.json();
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Custom Document Data
|
## Custom Document Data (Deprecated)
|
||||||
|
|
||||||
When creating a document from a template, you can replace the template's PDF with a custom PDF by using the `customDocumentData` parameter. This is useful when you need to generate the PDF dynamically while reusing the template's recipient and field configuration.
|
When creating a document from a template, you can replace the template's PDF with a custom PDF by using the `customDocumentData` parameter. This is useful when you need to generate the PDF dynamically while reusing the template's recipient and field configuration.
|
||||||
|
|
||||||
@@ -913,7 +1123,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Template Types
|
## Template Types (Legacy)
|
||||||
|
|
||||||
| Type | Description |
|
| Type | Description |
|
||||||
| --------- | ------------------------------------------------------------------ |
|
| --------- | ------------------------------------------------------------------ |
|
||||||
@@ -922,7 +1132,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Complete Example: Contract Workflow
|
## Complete Legacy Example: Contract Workflow (Deprecated)
|
||||||
|
|
||||||
This example demonstrates a complete workflow for using templates to send contracts.
|
This example demonstrates a complete workflow for using templates to send contracts.
|
||||||
|
|
||||||
@@ -996,16 +1206,29 @@ async function sendEmploymentContract(employeeData: {
|
|||||||
subject: `Employment Contract for ${employeeData.name}`,
|
subject: `Employment Contract for ${employeeData.name}`,
|
||||||
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
|
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
|
||||||
},
|
},
|
||||||
distributeDocument: true,
|
distributeDocument: false,
|
||||||
externalId: `emp-contract-${Date.now()}`,
|
externalId: `emp-contract-${Date.now()}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const document = await documentResponse.json();
|
const document = await documentResponse.json();
|
||||||
|
|
||||||
|
// 5. Distribute the envelope and get recipient signing links
|
||||||
|
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: API_TOKEN,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
envelopeId: document.envelopeId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const distribution = await distributionResponse.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documentId: document.id,
|
envelopeId: document.envelopeId,
|
||||||
signingUrl: document.recipients[0].signingUrl,
|
signingUrl: distribution.recipients[0].signingUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,7 +1241,7 @@ const result = await sendEmploymentContract({
|
|||||||
startDate: '2025-03-01',
|
startDate: '2025-03-01',
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Document created:', result.documentId);
|
console.log('Document created:', result.envelopeId);
|
||||||
console.log('Signing URL:', result.signingUrl);
|
console.log('Signing URL:', result.signingUrl);
|
||||||
````
|
````
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ async function createAndSendDocument(
|
|||||||
pdfBuffer: Buffer,
|
pdfBuffer: Buffer,
|
||||||
filename: string,
|
filename: string,
|
||||||
title: string,
|
title: string,
|
||||||
|
externalId: string,
|
||||||
recipients: Recipient[],
|
recipients: Recipient[],
|
||||||
): Promise<CreateAndSendResult> {
|
): Promise<CreateAndSendResult> {
|
||||||
const recipientPayload = recipients.map((recipient, index) => ({
|
const recipientPayload = recipients.map((recipient, index) => ({
|
||||||
@@ -89,6 +90,7 @@ async function createAndSendDocument(
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'DOCUMENT',
|
type: 'DOCUMENT',
|
||||||
title,
|
title,
|
||||||
|
externalId,
|
||||||
recipients: recipientPayload,
|
recipients: recipientPayload,
|
||||||
meta: {
|
meta: {
|
||||||
subject: `Please sign: ${title}`,
|
subject: `Please sign: ${title}`,
|
||||||
@@ -145,6 +147,7 @@ const result = await createAndSendDocument(
|
|||||||
pdfBuffer,
|
pdfBuffer,
|
||||||
'contract.pdf',
|
'contract.pdf',
|
||||||
'Service Agreement',
|
'Service Agreement',
|
||||||
|
'nda-contract-ndac214',
|
||||||
[
|
[
|
||||||
{ email: 'client@example.com', name: 'John Smith', role: 'SIGNER' },
|
{ email: 'client@example.com', name: 'John Smith', role: 'SIGNER' },
|
||||||
{ email: 'manager@company.com', name: 'Jane Doe', 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={
|
-F 'payload={
|
||||||
"type": "DOCUMENT",
|
"type": "DOCUMENT",
|
||||||
"title": "Service Agreement",
|
"title": "Service Agreement",
|
||||||
|
"externalId": "nda-contract-ndac214",
|
||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"email": "client@example.com",
|
"email": "client@example.com",
|
||||||
@@ -241,6 +245,8 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}'
|
|||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</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
|
## Workflow 2: Create Document from Template with Custom Data
|
||||||
@@ -256,8 +262,11 @@ Use templates for repeatable document workflows. This example creates an employm
|
|||||||
Map template fields by label and build a <code>prefillFields</code> array
|
Map template fields by label and build a <code>prefillFields</code> array
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
|
Call <code>POST /template/use</code> with recipients and prefill data
|
||||||
<code>distributeDocument: true</code>
|
</Step>
|
||||||
|
<Step>
|
||||||
|
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
|
||||||
|
links
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
@@ -291,7 +300,7 @@ type TemplateRecipient = {
|
|||||||
async function sendEmploymentContract(
|
async function sendEmploymentContract(
|
||||||
templateId: number,
|
templateId: number,
|
||||||
employee: EmployeeData,
|
employee: EmployeeData,
|
||||||
): Promise<{ documentId: string; signingUrl: string }> {
|
): Promise<{ documentId: number; signingUrl: string }> {
|
||||||
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
|
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
|
||||||
headers: { Authorization: API_TOKEN },
|
headers: { Authorization: API_TOKEN },
|
||||||
});
|
});
|
||||||
@@ -368,7 +377,6 @@ async function sendEmploymentContract(
|
|||||||
subject: `Your Employment Contract at ${employee.department}`,
|
subject: `Your Employment Contract at ${employee.department}`,
|
||||||
message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`,
|
message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`,
|
||||||
},
|
},
|
||||||
distributeDocument: true,
|
|
||||||
externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`,
|
externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -380,9 +388,25 @@ async function sendEmploymentContract(
|
|||||||
|
|
||||||
const document = await createResponse.json();
|
const document = await createResponse.json();
|
||||||
|
|
||||||
|
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: API_TOKEN,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ envelopeId: document.envelopeId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!distributeResponse.ok) {
|
||||||
|
const error = await distributeResponse.json();
|
||||||
|
throw new Error(`Failed to send document: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributeResult = await distributeResponse.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
signingUrl: document.recipients[0].signingUrl,
|
signingUrl: distributeResult.recipients[0].signingUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,12 +471,17 @@ RESPONSE=$(curl -s -X POST "${BASE_URL}/template/use" \
|
|||||||
\"subject\": \"Your Employment Contract\",
|
\"subject\": \"Your Employment Contract\",
|
||||||
\"message\": \"Please review and sign your employment contract.\"
|
\"message\": \"Please review and sign your employment contract.\"
|
||||||
},
|
},
|
||||||
\"distributeDocument\": true,
|
|
||||||
\"externalId\": \"emp-$(date +%s)-alice\"
|
\"externalId\": \"emp-$(date +%s)-alice\"
|
||||||
}")
|
}")
|
||||||
|
|
||||||
echo "Document created:"
|
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
|
||||||
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
|
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||||
|
-H "Authorization: ${API_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
|
||||||
|
|
||||||
|
echo "Document created: $(echo $RESPONSE | jq -r '.id')"
|
||||||
|
echo "Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
|
||||||
````
|
````
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -470,8 +499,10 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
|
|||||||
Fetch the template and get the signer recipient slot ID
|
Fetch the template and get the signer recipient slot ID
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
For each recipient, call <code>POST /template/use</code> with{' '}
|
For each recipient, call <code>POST /template/use</code>
|
||||||
<code>distributeDocument: true</code>
|
</Step>
|
||||||
|
<Step>
|
||||||
|
Distribute each returned envelope via <code>POST /envelope/distribute</code>
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
||||||
@@ -534,7 +565,6 @@ async function bulkSendFromTemplate(
|
|||||||
recipients: [
|
recipients: [
|
||||||
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
|
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
|
||||||
],
|
],
|
||||||
distributeDocument: true,
|
|
||||||
externalId: `bulk-${Date.now()}-${recipient.email}`,
|
externalId: `bulk-${Date.now()}-${recipient.email}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -545,10 +575,26 @@ async function bulkSendFromTemplate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const document = await response.json();
|
const document = await response.json();
|
||||||
|
|
||||||
|
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: API_TOKEN,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ envelopeId: document.envelopeId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!distributeResponse.ok) {
|
||||||
|
const error = await distributeResponse.json();
|
||||||
|
throw new Error(error.message || 'Failed to distribute document');
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributeResult = await distributeResponse.json();
|
||||||
return {
|
return {
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
envelopeId: document.id,
|
envelopeId: document.envelopeId,
|
||||||
signingUrl: document.recipients[0].signingUrl,
|
signingUrl: distributeResult.recipients[0].signingUrl,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -621,14 +667,24 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
|
|||||||
\"email\": \"${EMAIL}\",
|
\"email\": \"${EMAIL}\",
|
||||||
\"name\": \"${NAME}\"
|
\"name\": \"${NAME}\"
|
||||||
}],
|
}],
|
||||||
\"distributeDocument\": true,
|
|
||||||
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
|
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
|
||||||
}")
|
}")
|
||||||
|
|
||||||
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
|
if echo $RESPONSE | jq -e '.envelopeId' > /dev/null 2>&1; then
|
||||||
echo " Success: $(echo $RESPONSE | jq -r '.id')"
|
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
|
||||||
|
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||||
|
-H "Authorization: ${API_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
|
||||||
|
|
||||||
|
if echo $DISTRIBUTE_RESPONSE | jq -e '.success' > /dev/null 2>&1; then
|
||||||
|
echo " Success: ${ENVELOPE_ID}"
|
||||||
|
echo " Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
|
||||||
|
else
|
||||||
|
echo " Failed to distribute: $(echo $DISTRIBUTE_RESPONSE | jq -r '.message')"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
|
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rate limiting delay
|
# Rate limiting delay
|
||||||
@@ -831,13 +887,15 @@ After a document is completed, download the signed PDF with all signatures embed
|
|||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
|
The `version` query parameter accepts `original`, `pending`, or `signed`.
|
||||||
|
|
||||||
<Tabs items={['TypeScript', 'curl']}>
|
<Tabs items={['TypeScript', 'curl']}>
|
||||||
<Tab value="TypeScript">
|
<Tab value="TypeScript">
|
||||||
```typescript
|
```typescript
|
||||||
const API_TOKEN = process.env.DOCUMENSO_API_TOKEN;
|
const API_TOKEN = process.env.DOCUMENSO_API_TOKEN;
|
||||||
const BASE_URL = 'https://app.documenso.com/api/v2';
|
const BASE_URL = 'https://app.documenso.com/api/v2';
|
||||||
|
|
||||||
type DownloadVersion = 'signed' | 'original';
|
type DownloadVersion = 'original' | 'pending' | 'signed';
|
||||||
|
|
||||||
async function downloadDocument(
|
async function downloadDocument(
|
||||||
envelopeId: string,
|
envelopeId: string,
|
||||||
@@ -891,7 +949,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
|
|||||||
{ headers: { Authorization: API_TOKEN } },
|
{ headers: { Authorization: API_TOKEN } },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, pagination } = await response.json();
|
const { data, count, currentPage, perPage, totalPages } = await response.json();
|
||||||
|
|
||||||
for (const envelope of data) {
|
for (const envelope of data) {
|
||||||
try {
|
try {
|
||||||
@@ -905,8 +963,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
hasMore = page < pagination.totalPages;
|
hasMore = currentPage < totalPages;
|
||||||
page++;
|
page = currentPage + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,9 +1058,12 @@ async function fetchWithRetry(
|
|||||||
// Retry on rate limit
|
// Retry on rate limit
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const retryAfter = response.headers.get('Retry-After');
|
const retryAfter = response.headers.get('Retry-After');
|
||||||
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...`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,20 +24,18 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
|||||||
{/* prettier-ignore */}
|
{/* prettier-ignore */}
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step>
|
<Step>
|
||||||
### Open settings
|
### Select a team
|
||||||
|
|
||||||
- Log in to your Documenso account
|
Log in to Documenso and open the team that the integration should access. API tokens are scoped to a
|
||||||
- Click your avatar in the top right corner
|
team.
|
||||||
- Select **Settings** from the dropdown menu
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step>
|
<Step>
|
||||||
### Navigate to the API Tokens tab
|
### Open API Tokens
|
||||||
|
|
||||||
Go to **Settings** and open the **API Tokens** tab.
|
Go to **Team Settings** → **API Tokens**, or open
|
||||||
|
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -48,7 +46,7 @@ Go to **Settings** and open the **API Tokens** tab.
|
|||||||
|
|
||||||
- Click **Create Token**
|
- Click **Create Token**
|
||||||
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
|
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
|
||||||
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
|
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
|
||||||
- Click **Create Token**
|
- Click **Create Token**
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
@@ -75,21 +73,21 @@ Include the token in the `Authorization` header of your HTTP requests.
|
|||||||
### cURL
|
### cURL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl https://app.documenso.com/api/v2/document \
|
curl https://app.documenso.com/api/v2/envelope \
|
||||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||||
```
|
```
|
||||||
|
|
||||||
### JavaScript / TypeScript
|
### JavaScript / TypeScript
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const response = await fetch('https://app.documenso.com/api/v2/document', {
|
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const documents = await response.json();
|
const envelopes = await response.json();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using the TypeScript SDK
|
### Using the TypeScript SDK
|
||||||
@@ -129,7 +127,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
|
|||||||
|
|
||||||
## Token Security
|
## Token Security
|
||||||
|
|
||||||
API tokens grant full access to your account. Follow these practices to keep them secure:
|
API tokens grant full API access to the team they were created for. Follow these practices to keep them secure:
|
||||||
|
|
||||||
- **Never commit tokens to version control.** Use environment variables instead.
|
- **Never commit tokens to version control.** Use environment variables instead.
|
||||||
- **Use descriptive names.** Names like `zapier-prod` or `backend-staging` help you identify token usage.
|
- **Use descriptive names.** Names like `zapier-prod` or `backend-staging` help you identify token usage.
|
||||||
@@ -155,12 +153,11 @@ const client = new Documenso({
|
|||||||
|
|
||||||
## Token Scope
|
## Token Scope
|
||||||
|
|
||||||
API tokens have full access to your account, including:
|
API tokens have full API access to the team they were created for, including:
|
||||||
|
|
||||||
- Creating, reading, updating, and deleting documents
|
- Creating, reading, updating, and deleting documents
|
||||||
- Managing recipients and fields
|
- Managing recipients and fields
|
||||||
- Accessing templates
|
- Accessing templates
|
||||||
- Managing team resources (if the token owner has team access)
|
|
||||||
|
|
||||||
There is currently no way to create tokens with limited scopes or permissions.
|
There is currently no way to create tokens with limited scopes or permissions.
|
||||||
|
|
||||||
@@ -171,7 +168,7 @@ To revoke a token:
|
|||||||
{/* prettier-ignore */}
|
{/* prettier-ignore */}
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step>
|
<Step>
|
||||||
Go to **Settings** > **API Tokens**
|
Go to **Team Settings** → **API Tokens**
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
Find the token you want to revoke
|
Find the token you want to revoke
|
||||||
@@ -194,7 +191,7 @@ Revoked tokens stop working immediately. Any integrations using that token will
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion title="401 Unauthorized — Expired token">Create a new token in settings.</Accordion>
|
<Accordion title="401 Unauthorized — Expired token">Create a new token in settings.</Accordion>
|
||||||
<Accordion title="403 Forbidden — Token doesn't have access to the resource">
|
<Accordion title="403 Forbidden — Token doesn't have access to the resource">
|
||||||
Ensure you're accessing resources owned by the token's account.
|
Ensure you're accessing resources owned by the token's team.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
|
|||||||
@@ -78,12 +78,10 @@ A successful response returns a list of your documents (envelopes):
|
|||||||
"createdAt": "2025-01-15T10:30:00.000Z"
|
"createdAt": "2025-01-15T10:30:00.000Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pagination": {
|
"count": 1,
|
||||||
"page": 1,
|
"currentPage": 1,
|
||||||
"perPage": 10,
|
"perPage": 10,
|
||||||
"totalPages": 1,
|
"totalPages": 1
|
||||||
"totalItems": 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
````
|
````
|
||||||
|
|
||||||
@@ -228,9 +226,12 @@ After creating a document, it's in `DRAFT` status. To send it to recipients, use
|
|||||||
<Tabs items={['curl', 'JavaScript']}>
|
<Tabs items={['curl', 'JavaScript']}>
|
||||||
<Tab value="curl">
|
<Tab value="curl">
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
|
curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
|
||||||
-H "Authorization: YOUR_API_TOKEN" \
|
-H "Authorization: YOUR_API_TOKEN" \
|
||||||
-H "Content-Type: application/json"
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"envelopeId": "envelope_abc123"
|
||||||
|
}'
|
||||||
````
|
````
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -238,16 +239,14 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribu
|
|||||||
```javascript
|
```javascript
|
||||||
const envelopeId = 'envelope_abc123';
|
const envelopeId = 'envelope_abc123';
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
|
||||||
`https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
method: 'POST',
|
Authorization: 'YOUR_API_TOKEN',
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
Authorization: 'YOUR_API_TOKEN',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify({ envelopeId }),
|
||||||
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Document sent:', data);
|
console.log('Document sent:', data);
|
||||||
@@ -337,16 +336,14 @@ async function createAndSendDocument(pdfPath, recipientEmail, recipientName) {
|
|||||||
console.log('Created envelope:', envelope.id);
|
console.log('Created envelope:', envelope.id);
|
||||||
|
|
||||||
// Step 2: Send the document for signing
|
// Step 2: Send the document for signing
|
||||||
const distributeResponse = await fetch(
|
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||||
`${BASE_URL}/envelope/${envelope.id}/distribute`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
method: 'POST',
|
'Authorization': API_TOKEN,
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Authorization': API_TOKEN,
|
},
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({ envelopeId: envelope.id }),
|
||||||
},
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!distributeResponse.ok) {
|
if (!distributeResponse.ok) {
|
||||||
const error = await distributeResponse.json();
|
const error = await distributeResponse.json();
|
||||||
@@ -422,9 +419,12 @@ echo "Created envelope: ${ENVELOPE_ID}"
|
|||||||
# Step 2: Send the document for signing
|
# Step 2: Send the document for signing
|
||||||
|
|
||||||
echo "Sending document..."
|
echo "Sending document..."
|
||||||
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
|
curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||||
-H "Authorization: ${API_TOKEN}" \
|
-H "Authorization: ${API_TOKEN}" \
|
||||||
-H "Content-Type: application/json"
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"envelopeId\": \"${ENVELOPE_ID}\"
|
||||||
|
}"
|
||||||
|
|
||||||
echo "Document sent for signing!"
|
echo "Document sent for signing!"
|
||||||
|
|
||||||
@@ -441,7 +441,7 @@ The API returns standard HTTP status codes and JSON error responses:
|
|||||||
| `400` | Bad request - check your request payload |
|
| `400` | Bad request - check your request payload |
|
||||||
| `401` | Unauthorized - invalid or missing API token |
|
| `401` | Unauthorized - invalid or missing API token |
|
||||||
| `404` | Not found - resource doesn't exist |
|
| `404` | Not found - resource doesn't exist |
|
||||||
| `429` | Rate limited - wait 60 seconds and retry |
|
| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. |
|
||||||
| `500` | Server error - retry or contact support |
|
| `500` | Server error - retry or contact support |
|
||||||
|
|
||||||
### Error Response Format
|
### Error Response Format
|
||||||
@@ -485,7 +485,9 @@ The API returns standard HTTP status codes and JSON error responses:
|
|||||||
|
|
||||||
### Handling Rate Limits
|
### Handling Rate Limits
|
||||||
|
|
||||||
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
|
The API has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically.
|
||||||
|
|
||||||
|
Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||||
@@ -493,8 +495,15 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
|
|||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
console.log('Rate limited, waiting 60 seconds...');
|
const retryAfter = response.headers.get('Retry-After');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60000));
|
|
||||||
|
if (!retryAfter) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAfterSeconds = Number.parseInt(retryAfter, 10);
|
||||||
|
console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
|
| ------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
|
||||||
| `PORT` | Port the application listens on | `3000` |
|
| `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_PASSPHRASE` | Passphrase for the signing certificate | - |
|
||||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
|
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
|
||||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
|
| `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_PUBLIC_WEBAPP_URL="https://sign.example.com" \
|
||||||
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
|
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
|
||||||
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \
|
-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_SIGNING_PASSPHRASE="your-certificate-password" \
|
||||||
-e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \
|
-e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \
|
||||||
-e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \
|
-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.
|
- **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).
|
- **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.
|
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">
|
<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_INTERNAL_WEBAPP_URL=http://localhost:3000
|
||||||
NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
|
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_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_SIGNING_PASSPHRASE=your-certificate-password
|
||||||
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
|
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
|
||||||
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
|
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
|
||||||
@@ -203,6 +211,12 @@ docker run -d \
|
|||||||
|
|
||||||
Documenso provides health check endpoints for monitoring:
|
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 |
|
| Endpoint | Purpose |
|
||||||
| ------------------------- | -------------------------------------------------------------- |
|
| ------------------------- | -------------------------------------------------------------- |
|
||||||
| `/api/health` | Checks database connectivity and certificate status |
|
| `/api/health` | Checks database connectivity and certificate status |
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js 22 or later
|
- Node.js 24 or later
|
||||||
- npm 11 or later
|
- npm 11.17 or later
|
||||||
- PostgreSQL 14 or later
|
- PostgreSQL 14 or later
|
||||||
- A Linux server (for systemd service setup)
|
- A Linux server (for systemd service setup)
|
||||||
|
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
|
|||||||
|
|
||||||
| Requirement | Version |
|
| Requirement | Version |
|
||||||
| ----------- | ------- |
|
| ----------- | ------- |
|
||||||
| Node.js | 22+ |
|
| Node.js | 24+ |
|
||||||
| npm | 11+ |
|
| npm | 11.17+ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ Documenso runs on:
|
|||||||
| MySQL/MariaDB | PostgreSQL-specific features required |
|
| MySQL/MariaDB | PostgreSQL-specific features required |
|
||||||
| SQLite | Not suitable for production workloads |
|
| SQLite | Not suitable for production workloads |
|
||||||
| MongoDB | Relational database required |
|
| 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 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 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). |
|
| **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. |
|
| **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.
|
Document visibility, language, and signature settings can be overridden per document.
|
||||||
|
|||||||
@@ -10,13 +10,12 @@
|
|||||||
"postinstall": "fumadocs-mdx"
|
"postinstall": "fumadocs-mdx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"fumadocs-core": "16.14.3",
|
||||||
"fumadocs-core": "16.5.0",
|
"fumadocs-mdx": "15.2.3",
|
||||||
"fumadocs-mdx": "14.2.6",
|
"fumadocs-ui": "16.14.3",
|
||||||
"fumadocs-ui": "16.5.0",
|
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"next": "16.2.6",
|
"next": "^16.3.3",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -1,142 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Multi purpose CORS lib.
|
* Apply the public statistics API's wildcard CORS policy, without credentials.
|
||||||
* Note: Based on the `cors` package in npm but using only web APIs.
|
|
||||||
* Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts
|
|
||||||
*/
|
*/
|
||||||
|
export default async function cors(req: Request, res: Response): Promise<Response> {
|
||||||
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
|
|
||||||
|
|
||||||
type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise<StaticOrigin>;
|
|
||||||
|
|
||||||
interface CorsOptions {
|
|
||||||
origin?: StaticOrigin | OriginFn;
|
|
||||||
methods?: string | string[];
|
|
||||||
allowedHeaders?: string | string[];
|
|
||||||
exposedHeaders?: string | string[];
|
|
||||||
credentials?: boolean;
|
|
||||||
maxAge?: number;
|
|
||||||
preflightContinue?: boolean;
|
|
||||||
optionsSuccessStatus?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: CorsOptions = {
|
|
||||||
origin: '*',
|
|
||||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
|
||||||
preflightContinue: false,
|
|
||||||
optionsSuccessStatus: 204,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
|
|
||||||
return Array.isArray(allowed)
|
|
||||||
? allowed.some((o) => isOriginAllowed(origin, o))
|
|
||||||
: typeof allowed === 'string'
|
|
||||||
? origin === allowed
|
|
||||||
: allowed instanceof RegExp
|
|
||||||
? allowed.test(origin)
|
|
||||||
: !!allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
if (origin === '*') {
|
|
||||||
headers.set('Access-Control-Allow-Origin', '*');
|
|
||||||
} else if (typeof origin === 'string') {
|
|
||||||
headers.set('Access-Control-Allow-Origin', origin);
|
|
||||||
headers.append('Vary', 'Origin');
|
|
||||||
} else {
|
|
||||||
const allowed = isOriginAllowed(reqOrigin ?? '', origin);
|
|
||||||
|
|
||||||
if (allowed && reqOrigin) {
|
|
||||||
headers.set('Access-Control-Allow-Origin', reqOrigin);
|
|
||||||
}
|
|
||||||
headers.append('Vary', 'Origin');
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) {
|
|
||||||
const reqOrigin = req.headers.get('Origin') || undefined;
|
|
||||||
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return getOriginHeaders(reqOrigin, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllowedHeaders(req: Request, allowed?: string | string[]) {
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
allowed = req.headers.get('Access-Control-Request-Headers')!;
|
|
||||||
headers.append('Vary', 'Access-Control-Request-Headers');
|
|
||||||
} else if (Array.isArray(allowed)) {
|
|
||||||
allowed = allowed.join(',');
|
|
||||||
}
|
|
||||||
if (allowed) {
|
|
||||||
headers.set('Access-Control-Allow-Headers', allowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function cors(req: Request, res: Response, options?: CorsOptions) {
|
|
||||||
const opts = { ...defaultOptions, ...options };
|
|
||||||
const { headers } = res;
|
const { headers } = res;
|
||||||
const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
|
headers.set('Access-Control-Allow-Origin', '*');
|
||||||
const mergeHeaders = (v: string, k: string) => {
|
|
||||||
if (k === 'Vary') {
|
|
||||||
headers.append(k, v);
|
|
||||||
} else {
|
|
||||||
headers.set(k, v);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// If there's no origin we won't touch the response
|
|
||||||
if (!originHeaders) {
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
originHeaders.forEach(mergeHeaders);
|
|
||||||
|
|
||||||
if (opts.credentials) {
|
|
||||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(',') : opts.exposedHeaders;
|
|
||||||
|
|
||||||
if (exposed) {
|
|
||||||
headers.set('Access-Control-Expose-Headers', exposed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the preflight request
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
if (opts.methods) {
|
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
|
||||||
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
|
headers.set('Vary', 'Access-Control-Request-Headers');
|
||||||
|
|
||||||
headers.set('Access-Control-Allow-Methods', methods);
|
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
|
||||||
}
|
|
||||||
|
|
||||||
getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders);
|
if (allowedHeaders) {
|
||||||
|
headers.set('Access-Control-Allow-Headers', allowedHeaders);
|
||||||
if (typeof opts.maxAge === 'number') {
|
|
||||||
headers.set('Access-Control-Max-Age', String(opts.maxAge));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.preflightContinue) {
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.set('Content-Length', '0');
|
headers.set('Content-Length', '0');
|
||||||
return new Response(null, { status: opts.optionsSuccessStatus, headers });
|
return new Response(null, { status: 204, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we got here, it's a normal request
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initCors(options?: CorsOptions) {
|
|
||||||
return async (req: Request, res: Response) => cors(req, res, options);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next": "16.2.6"
|
"next": "^16.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "18.3.27",
|
"@types/react": "^19.2.17",
|
||||||
"typescript": "5.6.2"
|
"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 { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@@ -71,6 +72,7 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
@@ -200,6 +202,12 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'editor',
|
||||||
|
location: 'distribute_document',
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
const errorMessage = getDistributeErrorMessage(error.code);
|
const errorMessage = getDistributeErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
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 { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
@@ -51,6 +52,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -95,6 +97,13 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'editor',
|
||||||
|
location: 'redistribute_document',
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
const errorMessage = getDistributeErrorMessage(error.code);
|
const errorMessage = getDistributeErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({
|
|||||||
if (isDocument) {
|
if (isDocument) {
|
||||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||||
} else {
|
} else {
|
||||||
await trpcUtils.template.findTemplates.invalidate();
|
await Promise.all([
|
||||||
|
trpcUtils.template.findTemplates.invalidate(),
|
||||||
|
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.failedIds.length > 0) {
|
if (result.failedIds.length > 0) {
|
||||||
|
|||||||
@@ -290,15 +290,16 @@ export const EnvelopesBulkDownloadDialog = ({
|
|||||||
{isOverDownloadLimit && (
|
{isOverDownloadLimit && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>
|
<Plural
|
||||||
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
|
value={MAX_BULK_DOWNLOAD_ENVELOPES}
|
||||||
continue.
|
one="You can download up to # document at a time. Deselect some documents to continue."
|
||||||
</Trans>
|
other="You can download up to # documents at a time. Deselect some documents to continue."
|
||||||
|
/>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<fieldset disabled={isDownloading} className="space-y-4">
|
<fieldset disabled={isDownloading} className="min-w-0 space-y-4">
|
||||||
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
|
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
|
||||||
<div className="divide-y divide-border rounded-lg border border-border">
|
<div className="divide-y divide-border rounded-lg border border-border">
|
||||||
{envelopes.map((envelope) => {
|
{envelopes.map((envelope) => {
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({
|
|||||||
if (isDocument) {
|
if (isDocument) {
|
||||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||||
} else {
|
} else {
|
||||||
await trpcUtils.template.findTemplates.invalidate();
|
await Promise.all([
|
||||||
|
trpcUtils.template.findTemplates.invalidate(),
|
||||||
|
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSuccess?.(data.folderId);
|
await onSuccess?.(data.folderId);
|
||||||
|
|||||||
@@ -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 { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
@@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
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 { File as FileIcon, Upload, X } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
@@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({
|
|||||||
|
|
||||||
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
||||||
|
|
||||||
|
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
|
||||||
|
|
||||||
export type TemplateBulkSendDialogProps = {
|
export type TemplateBulkSendDialogProps = {
|
||||||
templateId: number;
|
templateId: number;
|
||||||
recipients: Array<{ email: string; name?: string | null }>;
|
recipients: Array<{ email: string; name?: string | null }>;
|
||||||
@@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
|
||||||
|
|
||||||
const form = useForm<TBulkSendFormSchema>({
|
const form = useForm<TBulkSendFormSchema>({
|
||||||
resolver: zodResolver(ZBulkSendFormSchema),
|
resolver: zodResolver(ZBulkSendFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
|||||||
|
|
||||||
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
|
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 onDownloadTemplate = () => {
|
||||||
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
|
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) => {
|
const onSubmit = async (values: TBulkSendFormSchema) => {
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csv = await values.file.text();
|
const csv = await values.file.text();
|
||||||
|
|
||||||
await uploadBulkSend({
|
const result = await uploadBulkSend({
|
||||||
templateId,
|
templateId,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
csv: csv,
|
csv: csv,
|
||||||
sendImmediately: values.sendImmediately,
|
sendImmediately: values.sendImmediately,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setValidationError(result.error);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
|
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
const error = AppError.parseError(err);
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline" className="shrink-0" size="sm">
|
<Button variant="outline" className="shrink-0" size="sm">
|
||||||
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
onChange(file);
|
onChange(file);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
|||||||
type="button"
|
type="button"
|
||||||
variant="link"
|
variant="link"
|
||||||
className="p-0 text-destructive text-xs hover:text-destructive"
|
className="p-0 text-destructive text-xs hover:text-destructive"
|
||||||
onClick={() => onChange(null)}
|
onClick={() => {
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
form.resetField('file');
|
||||||
|
}}
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sendImmediately"
|
name="sendImmediately"
|
||||||
@@ -240,7 +345,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<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>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
|||||||
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -38,27 +40,64 @@ import { useNavigate } from 'react-router';
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
|
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
const DOCUMENT_NAME_SOURCE = {
|
||||||
distributeDocument: z.boolean(),
|
TEMPLATE: 'template',
|
||||||
useCustomDocument: z.boolean().default(false),
|
UPLOAD: 'upload',
|
||||||
customDocumentData: z
|
CUSTOM: 'custom',
|
||||||
.array(
|
} as const;
|
||||||
|
|
||||||
|
const getUploadedDocumentTitle = (file: File) => {
|
||||||
|
return file.name.replace(/\.[^/.]+$/, '').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the file name can be used as a document title.
|
||||||
|
*/
|
||||||
|
const isUploadedFileNameUsable = (file?: File): file is File => {
|
||||||
|
if (!file) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = getUploadedDocumentTitle(file);
|
||||||
|
|
||||||
|
return title.length > 0 && title.length <= DOCUMENT_TITLE_MAX_LENGTH;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZAddRecipientsForNewDocumentSchema = z
|
||||||
|
.object({
|
||||||
|
distributeDocument: z.boolean(),
|
||||||
|
useCustomDocument: z.boolean().default(false),
|
||||||
|
documentNameSource: z.enum([
|
||||||
|
DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||||
|
DOCUMENT_NAME_SOURCE.UPLOAD,
|
||||||
|
DOCUMENT_NAME_SOURCE.CUSTOM,
|
||||||
|
]),
|
||||||
|
customDocumentName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(DOCUMENT_TITLE_MAX_LENGTH, { message: msg`Document name is too long`.id }),
|
||||||
|
customDocumentData: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
title: z.string(),
|
||||||
|
data: z.instanceof(File).optional(),
|
||||||
|
envelopeItemId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string(),
|
id: z.number(),
|
||||||
data: z.instanceof(File).optional(),
|
email: ZRecipientEmailSchema,
|
||||||
envelopeItemId: z.string(),
|
name: z.string(),
|
||||||
|
signingOrder: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
.optional(),
|
})
|
||||||
recipients: z.array(
|
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
|
||||||
z.object({
|
message: msg`Document name is required`.id,
|
||||||
id: z.number(),
|
path: ['customDocumentName'],
|
||||||
email: ZRecipientEmailSchema,
|
});
|
||||||
name: z.string(),
|
|
||||||
signingOrder: z.number().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
@@ -87,6 +126,7 @@ export function TemplateUseDialog({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [lastUploadedFile, setLastUploadedFile] = useState<File>();
|
||||||
|
|
||||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||||
{
|
{
|
||||||
@@ -106,6 +146,8 @@ export function TemplateUseDialog({
|
|||||||
return {
|
return {
|
||||||
distributeDocument: false,
|
distributeDocument: false,
|
||||||
useCustomDocument: false,
|
useCustomDocument: false,
|
||||||
|
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||||
|
customDocumentName: '',
|
||||||
customDocumentData: envelopeItems.map((item) => ({
|
customDocumentData: envelopeItems.map((item) => ({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
data: undefined,
|
data: undefined,
|
||||||
@@ -140,11 +182,39 @@ export function TemplateUseDialog({
|
|||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
|
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track the most recently uploaded file so its name can be used as the document name.
|
||||||
|
* Files with an unusable name are ignored, and the document name source is reset if
|
||||||
|
* no usable file remains.
|
||||||
|
*/
|
||||||
|
const updateLastUploadedFile = (file?: File) => {
|
||||||
|
const usableFile = isUploadedFileNameUsable(file) ? file : undefined;
|
||||||
|
|
||||||
|
setLastUploadedFile(usableFile);
|
||||||
|
|
||||||
|
if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) {
|
||||||
|
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
|
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
|
||||||
|
return data.customDocumentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) {
|
||||||
|
return getUploadedDocumentTitle(lastUploadedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
try {
|
try {
|
||||||
const customFilesToUpload = (data.customDocumentData || []).filter(
|
const documentTitle = getDocumentTitle(data);
|
||||||
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
|
||||||
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
const customFilesToUpload = (data.customDocumentData ?? []).filter(
|
||||||
|
(item): item is typeof item & { data: File } => item.data !== undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const customDocumentData = await Promise.all(
|
const customDocumentData = await Promise.all(
|
||||||
@@ -163,6 +233,7 @@ export function TemplateUseDialog({
|
|||||||
recipients: data.recipients,
|
recipients: data.recipients,
|
||||||
distributeDocument: data.distributeDocument,
|
distributeDocument: data.distributeDocument,
|
||||||
customDocumentData,
|
customDocumentData,
|
||||||
|
...(documentTitle ? { override: { title: documentTitle } } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -195,9 +266,14 @@ export function TemplateUseDialog({
|
|||||||
name: 'recipients',
|
name: 'recipients',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const useCustomDocument = form.watch('useCustomDocument');
|
||||||
|
const documentNameSource = form.watch('documentNameSource');
|
||||||
|
const canUseUploadedDocumentName = Boolean(lastUploadedFile);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
form.reset(generateDefaultFormValues());
|
form.reset(generateDefaultFormValues());
|
||||||
|
setLastUploadedFile(undefined);
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
@@ -238,9 +314,9 @@ export function TemplateUseDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form className="min-w-0" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
<fieldset className="flex h-full min-w-0 flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
<div className="custom-scrollbar -m-1 max-h-[60vh] w-full min-w-0 max-w-full space-y-4 overflow-y-auto overflow-x-hidden p-1">
|
||||||
{formRecipients.map((recipient, index) => (
|
{formRecipients.map((recipient, index) => (
|
||||||
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||||
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
|
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
|
||||||
@@ -401,7 +477,17 @@ export function TemplateUseDialog({
|
|||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
field.onChange(checked);
|
field.onChange(checked);
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
form.setValue('customDocumentData', undefined);
|
const customDocumentData = form.getValues('customDocumentData');
|
||||||
|
|
||||||
|
form.setValue(
|
||||||
|
'customDocumentData',
|
||||||
|
customDocumentData?.map((item) => ({
|
||||||
|
...item,
|
||||||
|
data: undefined,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
form.clearErrors('customDocumentData');
|
||||||
|
updateLastUploadedFile(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -428,7 +514,7 @@ export function TemplateUseDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch('useCustomDocument') && (
|
{useCustomDocument && (
|
||||||
<div className="my-4 space-y-2">
|
<div className="my-4 space-y-2">
|
||||||
{isLoadingEnvelopeItems ? (
|
{isLoadingEnvelopeItems ? (
|
||||||
<SpinnerBox className="py-16" />
|
<SpinnerBox className="py-16" />
|
||||||
@@ -443,7 +529,7 @@ export function TemplateUseDialog({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
className="flex w-full min-w-0 items-center gap-4 overflow-hidden rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
@@ -451,13 +537,15 @@ export function TemplateUseDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
|
<h4 className="truncate font-medium text-foreground text-sm">
|
||||||
|
{field.value ? getUploadedDocumentTitle(field.value) : item.title}
|
||||||
|
</h4>
|
||||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||||
{field.value ? (
|
{field.value ? (
|
||||||
<div>
|
<span>
|
||||||
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
|
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
|
||||||
</div>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Default file</Trans>
|
<Trans>Default file</Trans>
|
||||||
)}
|
)}
|
||||||
@@ -475,6 +563,18 @@ export function TemplateUseDialog({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
field.onChange(undefined);
|
field.onChange(undefined);
|
||||||
|
|
||||||
|
if (field.value === lastUploadedFile) {
|
||||||
|
// Fall back to any other uploaded file so the option stays available.
|
||||||
|
const remainingUploadedFile = form
|
||||||
|
.getValues('customDocumentData')
|
||||||
|
?.find(
|
||||||
|
(item) =>
|
||||||
|
item.data !== field.value && isUploadedFileNameUsable(item.data),
|
||||||
|
)?.data;
|
||||||
|
|
||||||
|
updateLastUploadedFile(remainingUploadedFile);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4" />
|
||||||
@@ -517,7 +617,7 @@ export function TemplateUseDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (file.type !== 'application/pdf') {
|
if (file.type !== 'application/pdf') {
|
||||||
form.setError('customDocumentData', {
|
form.setError(`customDocumentData.${i}.data`, {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: _(msg`Please select a PDF file`),
|
message: _(msg`Please select a PDF file`),
|
||||||
});
|
});
|
||||||
@@ -526,7 +626,7 @@ export function TemplateUseDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
||||||
form.setError('customDocumentData', {
|
form.setError(`customDocumentData.${i}.data`, {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: _(
|
message: _(
|
||||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||||
@@ -537,6 +637,8 @@ export function TemplateUseDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
field.onChange(file);
|
field.onChange(file);
|
||||||
|
form.clearErrors(`customDocumentData.${i}.data`);
|
||||||
|
updateLastUploadedFile(file);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -550,6 +652,112 @@ export function TemplateUseDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="documentNameSource"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Document name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
aria-label={_(msg`Document name`)}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
|
||||||
|
<label className="text-sm" htmlFor="document-name-source-template">
|
||||||
|
<Trans>Use template name</Trans>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
id="document-name-source-upload"
|
||||||
|
value={DOCUMENT_NAME_SOURCE.UPLOAD}
|
||||||
|
disabled={!canUseUploadedDocumentName}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label
|
||||||
|
className={cn('text-sm', {
|
||||||
|
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
|
||||||
|
})}
|
||||||
|
htmlFor="document-name-source-upload"
|
||||||
|
>
|
||||||
|
<Trans>Use uploaded file name</Trans>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
type="button"
|
||||||
|
aria-label={_(msg`About uploaded file naming`)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="z-[99999] max-w-xs">
|
||||||
|
<Trans>
|
||||||
|
The document name will use the most recently uploaded file name without its
|
||||||
|
extension.
|
||||||
|
</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastUploadedFile && (
|
||||||
|
<p
|
||||||
|
className="max-w-sm truncate text-muted-foreground text-xs"
|
||||||
|
title={lastUploadedFile.name}
|
||||||
|
>
|
||||||
|
{lastUploadedFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canUseUploadedDocumentName && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
<Trans>Upload a custom document to use its file name.</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
|
||||||
|
<label className="text-sm" htmlFor="document-name-source-custom">
|
||||||
|
<Trans>Enter custom document name</Trans>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customDocumentName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="ml-6">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
aria-label={_(msg`Custom document name`)}
|
||||||
|
placeholder={_(msg`Enter a document name`)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { useCallback, useRef } from 'react';
|
|||||||
import type { Control } from 'react-hook-form';
|
import type { Control } from 'react-hook-form';
|
||||||
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
|
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { useCspNonce } from '~/utils/nonce';
|
||||||
|
|
||||||
import { useConfigureDocument } from './configure-document-context';
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps {
|
|||||||
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
|
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { isTemplate } = useConfigureDocument();
|
const { isTemplate } = useConfigureDocument();
|
||||||
|
const cspNonce = useCspNonce();
|
||||||
|
|
||||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||||
|
|
||||||
@@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
|
nonce={cspNonce}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
sensors={[
|
sensors={[
|
||||||
(api: SensorAPI) => {
|
(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 { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
@@ -77,6 +78,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
}: EmbedDirectTemplateClientPageProps) => {
|
}: EmbedDirectTemplateClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -264,6 +266,13 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'direct_template',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(errorMessage.title),
|
title: _(errorMessage.title),
|
||||||
description: _(errorMessage.description),
|
description: _(errorMessage.description),
|
||||||
@@ -308,6 +317,14 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'embed_init',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
setHasFinishedInit(true);
|
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 { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||||
@@ -32,6 +34,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
|||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
@@ -73,6 +76,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
}: EmbedSignDocumentV1ClientPageProps) => {
|
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
|
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
@@ -152,6 +156,14 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
|
|
||||||
setHasCompletedDocument(true);
|
setHasCompletedDocument(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'complete_document',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
documentId,
|
||||||
|
envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
@@ -162,9 +174,12 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(toastMessage.title),
|
||||||
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
|
description: _(toastMessage.description),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -231,6 +246,15 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'embed_init',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
documentId,
|
||||||
|
envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
setHasFinishedInit(true);
|
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 { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
@@ -25,6 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
|
|||||||
allowWhitelabelling = false,
|
allowWhitelabelling = false,
|
||||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
|
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
|
||||||
useRequiredEnvelopeSigningContext();
|
useRequiredEnvelopeSigningContext();
|
||||||
@@ -170,6 +172,14 @@ export const EmbedSignDocumentV2ClientPage = ({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'embed_init',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
setHasFinishedInit(true);
|
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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||||
@@ -26,6 +27,7 @@ import { useState } from 'react';
|
|||||||
import { match, P } from 'ts-pattern';
|
import { match, P } from 'ts-pattern';
|
||||||
|
|
||||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||||
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
||||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
||||||
@@ -56,6 +58,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
}: MultiSignDocumentSigningViewProps) => {
|
}: MultiSignDocumentSigningViewProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
@@ -100,6 +103,13 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'sign_field',
|
||||||
|
recipientId,
|
||||||
|
documentId: document?.id,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while signing the document.`),
|
description: _(msg`An error occurred while signing the document.`),
|
||||||
@@ -119,6 +129,13 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'remove_field',
|
||||||
|
recipientId,
|
||||||
|
documentId: document?.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,11 +156,21 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
recipientId,
|
recipientId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'complete_document',
|
||||||
|
recipientId,
|
||||||
|
documentId: document?.id,
|
||||||
|
});
|
||||||
|
|
||||||
onDocumentError?.();
|
onDocumentError?.();
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(toastMessage.title),
|
||||||
description: _(msg`Failed to complete the document. Please try again.`),
|
description: _(toastMessage.description),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} 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 { authClient } from '@documenso/auth/client';
|
||||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
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 { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -11,20 +11,21 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
export const ZPasswordFormSchema = z
|
import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog';
|
||||||
.object({
|
|
||||||
currentPassword: ZCurrentPasswordSchema,
|
export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({
|
||||||
password: ZPasswordSchema,
|
currentPassword: ZCurrentPasswordSchema,
|
||||||
repeatedPassword: ZPasswordSchema,
|
password: ZPasswordSchema,
|
||||||
})
|
repeatedPassword: ZPasswordSchema,
|
||||||
.refine((data) => data.password === data.repeatedPassword, {
|
}).refine((data) => data.password === data.repeatedPassword, {
|
||||||
message: 'Passwords do not match',
|
message: 'Passwords do not match',
|
||||||
path: ['repeatedPassword'],
|
path: ['repeatedPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
|
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
|
||||||
|
|
||||||
@@ -33,29 +34,51 @@ export type PasswordFormProps = {
|
|||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PasswordForm = ({ className }: PasswordFormProps) => {
|
export const PasswordForm = ({ className, user }: PasswordFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<TPasswordFormSchema>({
|
const form = useForm<TPasswordFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
password: '',
|
password: '',
|
||||||
repeatedPassword: '',
|
repeatedPassword: '',
|
||||||
|
totpCode: '',
|
||||||
|
backupCode: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZPasswordFormSchema),
|
resolver: zodResolver(ZPasswordFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
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 {
|
try {
|
||||||
await authClient.emailPassword.updatePassword({
|
await authClient.emailPassword.updatePassword({
|
||||||
currentPassword,
|
currentPassword,
|
||||||
password,
|
password,
|
||||||
|
totpCode: totpCode || undefined,
|
||||||
|
backupCode: backupCode || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setIsTwoFactorDialogOpen(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Password updated`),
|
title: _(msg`Password updated`),
|
||||||
@@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
const errorMessage = match(error.code)
|
const errorMessage = match(error.code)
|
||||||
.with('NO_PASSWORD', () => msg`User has no password.`)
|
.with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`)
|
||||||
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
|
.with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`)
|
||||||
.with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`)
|
.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(
|
.otherwise(
|
||||||
() => msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
|
() => 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 (
|
return (
|
||||||
<Form {...form}>
|
<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}>
|
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -140,6 +173,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<TwoFactorCodeDialog<TPasswordFormSchema>
|
||||||
|
open={isTwoFactorDialogOpen}
|
||||||
|
onOpenChange={setIsTwoFactorDialogOpen}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
submitLabel={<Trans>Update password</Trans>}
|
||||||
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { SearchIcon, UsersIcon } from 'lucide-react';
|
||||||
|
import type { MouseEvent, ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import type { AnalyticsQueryResult } from '~/utils/analytics';
|
||||||
|
import { formatRelativeDate } from '~/utils/analytics';
|
||||||
|
|
||||||
|
import { AnalyticsQueryError } from './analytics-query-error';
|
||||||
|
|
||||||
|
/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */
|
||||||
|
export type AnalyticsActivityRow = {
|
||||||
|
key: string | number;
|
||||||
|
avatar: {
|
||||||
|
imageId: string | null;
|
||||||
|
fallback: string;
|
||||||
|
};
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
sent: number;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
/** 0-100, null when nothing was sent. */
|
||||||
|
completionRate: number | null;
|
||||||
|
lastActiveAt: Date | null;
|
||||||
|
/** When set the whole row navigates here and the title becomes a link. */
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalyticsActivityTableCardProps = {
|
||||||
|
query: AnalyticsQueryResult<unknown>;
|
||||||
|
/** Rows derived from `query.data`; empty while loading. */
|
||||||
|
rows: AnalyticsActivityRow[];
|
||||||
|
/** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */
|
||||||
|
rangeKey: string;
|
||||||
|
title: ReactNode;
|
||||||
|
description: ReactNode;
|
||||||
|
/** Header of the first column, e.g. "Member". */
|
||||||
|
columnLabel: ReactNode;
|
||||||
|
/** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */
|
||||||
|
renderSummary: (count: number, activeCount: number) => ReactNode;
|
||||||
|
/** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */
|
||||||
|
renderShowing: (visibleCount: number, totalCount: number) => ReactNode;
|
||||||
|
emptyLabel: ReactNode;
|
||||||
|
emptyIcon?: LucideIcon;
|
||||||
|
/** Placeholder for the search input, e.g. "Search members". */
|
||||||
|
searchPlaceholder: string;
|
||||||
|
/** Rendered when the search matches nothing, e.g. "No members match your search". */
|
||||||
|
noSearchResultsLabel: ReactNode;
|
||||||
|
/** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */
|
||||||
|
testIdPrefix: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsActivityTableCard = ({
|
||||||
|
query,
|
||||||
|
rows,
|
||||||
|
rangeKey,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
columnLabel,
|
||||||
|
renderSummary,
|
||||||
|
renderShowing,
|
||||||
|
emptyLabel,
|
||||||
|
emptyIcon: EmptyIcon = UsersIcon,
|
||||||
|
searchPlaceholder,
|
||||||
|
noSearchResultsLabel,
|
||||||
|
testIdPrefix,
|
||||||
|
className,
|
||||||
|
}: AnalyticsActivityTableCardProps) => {
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
|
// Tracks which window "Show all" was pressed for, so it resets whenever the window changes.
|
||||||
|
const [expandedRangeKey, setExpandedRangeKey] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const isExpanded = expandedRangeKey === rangeKey;
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = query;
|
||||||
|
|
||||||
|
const activeCount = rows.filter((row) => row.sent > 0).length;
|
||||||
|
|
||||||
|
const normalisedSearchTerm = searchTerm.trim().toLowerCase();
|
||||||
|
const isSearching = normalisedSearchTerm.length > 0;
|
||||||
|
|
||||||
|
// Search always shows every match; the preview limit only applies to the unfiltered list.
|
||||||
|
const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows;
|
||||||
|
const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT);
|
||||||
|
const hasHiddenRows = filteredRows.length > visibleRows.length;
|
||||||
|
|
||||||
|
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className} data-testid={testId('activity')}>
|
||||||
|
<CardHeader className="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex flex-col space-y-1.5">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data !== undefined && rows.length > 0 && (
|
||||||
|
<p className="shrink-0 text-muted-foreground text-sm tabular-nums" data-testid={testId('summary')}>
|
||||||
|
{renderSummary(rows.length, activeCount)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{isError ? (
|
||||||
|
<AnalyticsQueryError onRetry={refetch} />
|
||||||
|
) : isLoading || data === undefined ? (
|
||||||
|
<ul className="flex flex-col gap-y-3">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-x-3">
|
||||||
|
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-y-1.5">
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-4 w-10" />
|
||||||
|
<Skeleton className="hidden h-4 w-10 md:block" />
|
||||||
|
<Skeleton className="hidden h-4 w-10 md:block" />
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
<Skeleton className="hidden h-4 w-20 md:block" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<EmptyIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="max-w-sm text-muted-foreground text-sm">{emptyLabel}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-y-3">
|
||||||
|
<div className="relative sm:max-w-xs">
|
||||||
|
<SearchIcon
|
||||||
|
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
className="pl-9"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
aria-label={searchPlaceholder}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
data-testid={testId('search')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredRows.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground text-sm" data-testid={testId('no-results')}>
|
||||||
|
{noSearchResultsLabel}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */}
|
||||||
|
<div className="-mx-6">
|
||||||
|
<Table className="[&_td]:px-2 md:[&_td]:px-4 [&_th]:px-2 md:[&_th]:px-4">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className={FIRST_CELL_CLASS}>{columnLabel}</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<Trans>Sent</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden text-right md:table-cell">
|
||||||
|
<Trans>Completed</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden text-right md:table-cell">
|
||||||
|
<Trans>Pending</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className={cn('text-right', LAST_CELL_ON_MOBILE_CLASS)}>
|
||||||
|
<Trans>Completion rate</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className={cn('hidden text-right md:table-cell', LAST_CELL_CLASS)}>
|
||||||
|
<Trans>Last active</Trans>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{visibleRows.map((row) => (
|
||||||
|
<ActivityRow key={row.key} row={row} locale={i18n.locale} testIdPrefix={testIdPrefix} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasHiddenRows && (
|
||||||
|
<div className="flex items-center justify-between gap-x-4 border-border border-t pt-3">
|
||||||
|
<p className="text-muted-foreground text-sm" data-testid={testId('showing')}>
|
||||||
|
{renderShowing(visibleRows.length, filteredRows.length)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-mr-2"
|
||||||
|
onClick={() => setExpandedRangeKey(rangeKey)}
|
||||||
|
data-testid={testId('show-all')}
|
||||||
|
>
|
||||||
|
<Trans>Show all</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivityRowProps = {
|
||||||
|
row: AnalyticsActivityRow;
|
||||||
|
locale: string;
|
||||||
|
testIdPrefix: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isActive = row.sent > 0;
|
||||||
|
|
||||||
|
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
|
||||||
|
|
||||||
|
// Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout.
|
||||||
|
const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground');
|
||||||
|
const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums';
|
||||||
|
|
||||||
|
// role="img" so the aria-label is valid (a bare span has no role that supports it).
|
||||||
|
const notAvailable = (
|
||||||
|
<span role="img" aria-label={_(msg`Not available`)}>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title link is the accessible target; clicking anywhere else on the row
|
||||||
|
* navigates too. Modifier clicks and clicks on the link itself are left to the
|
||||||
|
* browser so open-in-new-tab keeps working, and drag-selecting text does not
|
||||||
|
* navigate.
|
||||||
|
*/
|
||||||
|
const handleRowClick = (event: MouseEvent<HTMLTableRowElement>) => {
|
||||||
|
if (!row.href || event.defaultPrevented || event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target instanceof Element && event.target.closest('a')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.getSelection()?.toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void navigate(row.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
className={cn(row.href && 'cursor-pointer')}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
data-testid={testId('row')}
|
||||||
|
data-active={isActive ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
{/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */}
|
||||||
|
<TableCell truncate={false} className={cn('w-full max-w-0', FIRST_CELL_CLASS)}>
|
||||||
|
<div className="flex min-w-0 items-center gap-x-3">
|
||||||
|
<Avatar className="h-9 w-9 shrink-0">
|
||||||
|
{row.avatar.imageId && <AvatarImage src={formatAvatarUrl(row.avatar.imageId)} />}
|
||||||
|
<AvatarFallback className="text-muted-foreground text-xs">{row.avatar.fallback}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
{row.href ? (
|
||||||
|
<Link
|
||||||
|
to={row.href}
|
||||||
|
className={cn(
|
||||||
|
'truncate font-medium text-sm hover:underline',
|
||||||
|
isActive ? 'text-foreground' : 'text-foreground/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={cn('truncate font-medium text-sm', isActive ? 'text-foreground' : 'text-foreground/80')}>
|
||||||
|
{row.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.subtitle && <span className="truncate text-muted-foreground text-xs">{row.subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className={secondaryNumberClass} data-testid={testId('sent')}>
|
||||||
|
{row.sent.toLocaleString(locale)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className={cn('hidden md:table-cell', primaryNumberClass)} data-testid={testId('completed')}>
|
||||||
|
{row.completed.toLocaleString(locale)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className={cn('hidden md:table-cell', secondaryNumberClass)} data-testid={testId('pending')}>
|
||||||
|
{row.pending.toLocaleString(locale)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className={cn(primaryNumberClass, LAST_CELL_ON_MOBILE_CLASS)} data-testid={testId('completion-rate')}>
|
||||||
|
{row.completionRate === null ? (
|
||||||
|
notAvailable
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<div className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:block" aria-hidden="true">
|
||||||
|
<div className="h-full rounded-full bg-primary" style={{ width: `${row.completionRate}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="w-9 text-right">{Math.round(row.completionRate)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell
|
||||||
|
className={cn('hidden md:table-cell', secondaryNumberClass, LAST_CELL_CLASS)}
|
||||||
|
data-testid={testId('last-active')}
|
||||||
|
>
|
||||||
|
{row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_PREVIEW_LIMIT = 8;
|
||||||
|
|
||||||
|
const matchesSearch = (row: AnalyticsActivityRow, term: string) => {
|
||||||
|
return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table is pulled out to the card edge (-mx-6), so the outer cells get the
|
||||||
|
* card's px-6 gutter to line up with the header. "Last active" is hidden below
|
||||||
|
* md, so "Completion rate" takes the right gutter there.
|
||||||
|
*/
|
||||||
|
const FIRST_CELL_CLASS = '!pl-6';
|
||||||
|
const LAST_CELL_CLASS = '!pr-6';
|
||||||
|
const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4';
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { BarChart3Icon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics';
|
||||||
|
import { getAnalyticsDateRangeDays } from '~/utils/analytics';
|
||||||
|
|
||||||
|
import { AnalyticsQueryError } from './analytics-query-error';
|
||||||
|
|
||||||
|
export type AnalyticsDocumentsOverTimeCardProps = {
|
||||||
|
range: AnalyticsRangeValue;
|
||||||
|
query: AnalyticsQueryResult<TGetTeamAnalyticsDocumentsOverTimeResponse>;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket'];
|
||||||
|
|
||||||
|
export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => {
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = query;
|
||||||
|
|
||||||
|
// The backend decides the bucket, and it must match the points being rendered so
|
||||||
|
// the tick and tooltip formatting line up. Before data arrives it is guessed from
|
||||||
|
// the requested range.
|
||||||
|
const bucket: Bucket = data ? data.range.bucket : guessBucket(range);
|
||||||
|
|
||||||
|
const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col', className)} data-testid="analytics-documents-over-time">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<CardTitle>
|
||||||
|
<Trans>Documents created</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<CardDescription>{bucket === 'month' ? <Trans>Monthly</Trans> : <Trans>Daily</Trans>}</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<p className="shrink-0 whitespace-nowrap" data-testid="analytics-documents-over-time-total">
|
||||||
|
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
|
||||||
|
{data.total.toLocaleString(i18n.locale)}
|
||||||
|
</span>{' '}
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
<Trans>total</Trans>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */}
|
||||||
|
<CardContent className="flex flex-1 flex-col justify-center">
|
||||||
|
{isError ? (
|
||||||
|
<AnalyticsQueryError onRetry={refetch} />
|
||||||
|
) : isLoading || !data ? (
|
||||||
|
<Skeleton className="w-full" style={{ height: CHART_HEIGHT }} />
|
||||||
|
) : data.total === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center gap-y-3 text-center"
|
||||||
|
style={{ height: CHART_HEIGHT }}
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<BarChart3Icon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>No documents created in this period</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
|
<BarChart data={data.points} margin={{ top: 8, right: 0, bottom: 0, left: 0 }} barCategoryGap="20%">
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
|
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
interval={tickInterval}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickFormatter={(value: string) => formatTickLabel(value, bucket, i18n.locale)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={36}
|
||||||
|
tickCount={4}
|
||||||
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={<DocumentsOverTimeTooltip bucket={bucket} locale={i18n.locale} />}
|
||||||
|
cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={28}
|
||||||
|
background={{ fill: 'hsl(var(--muted) / 0.5)', radius: 4 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentsOverTimeTooltipProps = {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ payload: { date: string; count: number } }>;
|
||||||
|
bucket: Bucket;
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => {
|
||||||
|
const point = payload?.[0]?.payload;
|
||||||
|
|
||||||
|
if (!active || !point) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Number(point.count ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-popover px-3 py-2 text-popover-foreground text-sm shadow-md">
|
||||||
|
<p className="text-muted-foreground text-xs">{formatTooltipLabel(point.date, bucket, locale)}</p>
|
||||||
|
|
||||||
|
<p className="mt-0.5 font-medium tabular-nums">
|
||||||
|
<Plural value={count} one="# document" other="# documents" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHART_HEIGHT = 240;
|
||||||
|
|
||||||
|
const TARGET_DAILY_TICK_COUNT = 6;
|
||||||
|
|
||||||
|
/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */
|
||||||
|
const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92;
|
||||||
|
|
||||||
|
const guessBucket = (range: AnalyticsRangeValue): Bucket => {
|
||||||
|
if (range.range === '12m') {
|
||||||
|
return 'month';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.range === 'custom') {
|
||||||
|
return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'day';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month buckets label every month (12 fit at the lg width) and let recharts drop
|
||||||
|
* overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels.
|
||||||
|
*/
|
||||||
|
const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => {
|
||||||
|
if (bucket === 'month') {
|
||||||
|
return 'preserveStartEnd';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointCount <= TARGET_DAILY_TICK_COUNT) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTickLabel = (date: string, bucket: Bucket, locale: string) => {
|
||||||
|
const parsed = DateTime.fromISO(date).setLocale(locale);
|
||||||
|
|
||||||
|
if (bucket === 'month') {
|
||||||
|
return parsed.toLocaleString({ month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toLocaleString({ month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => {
|
||||||
|
const parsed = DateTime.fromISO(date).setLocale(locale);
|
||||||
|
|
||||||
|
if (bucket === 'month') {
|
||||||
|
return parsed.toLocaleString({ month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toLocaleString(DateTime.DATE_FULL);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown while the analytics route's `clientLoader` resolves the browser timezone
|
||||||
|
* during hydration.
|
||||||
|
*/
|
||||||
|
export const AnalyticsHydrateFallback = () => {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-live="polite" data-testid="analytics-loading">
|
||||||
|
<SpinnerBox />
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Loading analytics</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics';
|
||||||
|
|
||||||
|
export type AnalyticsNoActivityAlertProps = {
|
||||||
|
range: TTeamAnalyticsRange;
|
||||||
|
/** Invoked when the user asks to widen the range to the last 12 months. */
|
||||||
|
onShowLastYear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const canWidenRange = range !== '12m';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="neutral" padding="tight" className="mt-6" data-testid="analytics-no-activity">
|
||||||
|
<AlertDescription className="flex min-h-9 flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||||
|
<span className="flex items-center gap-x-2">
|
||||||
|
<InfoIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{_(ANALYTICS_NO_ACTIVITY_LABELS[range])}
|
||||||
|
{canWidenRange && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<Trans>Try a longer range.</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{canWidenRange && (
|
||||||
|
<Button variant="ghost" size="sm" className="-mr-2" onClick={onShowLastYear}>
|
||||||
|
<Trans>Show last 12 months</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { AnalyticsQueryResult } from '~/utils/analytics';
|
||||||
|
|
||||||
|
import { AnalyticsStatCard } from './analytics-stat-card';
|
||||||
|
|
||||||
|
/** The part of the overview response shared by the team and organisation procedures. */
|
||||||
|
export type AnalyticsOverviewData = Pick<TGetTeamAnalyticsOverviewResponse, 'sent' | 'completionRate'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The third card counts the scope's "entities" (team members, organisation teams)
|
||||||
|
* and how many of them were active in the period.
|
||||||
|
*/
|
||||||
|
export type AnalyticsOverviewEntityCard<TData> = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: ReactNode;
|
||||||
|
/** Applied to the value element, e.g. `analytics-members`. */
|
||||||
|
testId: string;
|
||||||
|
select: (data: TData) => { active: number; total: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalyticsOverviewCardsProps<TData extends AnalyticsOverviewData> = {
|
||||||
|
query: AnalyticsQueryResult<TData>;
|
||||||
|
entity: AnalyticsOverviewEntityCard<TData>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsOverviewCards = <TData extends AnalyticsOverviewData>({
|
||||||
|
query,
|
||||||
|
entity,
|
||||||
|
}: AnalyticsOverviewCardsProps<TData>) => {
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = query;
|
||||||
|
|
||||||
|
const entityCounts = data ? entity.select(data) : null;
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => value.toLocaleString(i18n.locale);
|
||||||
|
|
||||||
|
const sharedProps = {
|
||||||
|
isLoading: isLoading || !data,
|
||||||
|
isError,
|
||||||
|
onRetry: refetch,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<AnalyticsStatCard
|
||||||
|
{...sharedProps}
|
||||||
|
icon={SendIcon}
|
||||||
|
title={<Trans>Documents sent</Trans>}
|
||||||
|
value={data ? formatNumber(data.sent.current) : null}
|
||||||
|
badge={data ? <SentDeltaBadge current={data.sent.current} previous={data.sent.previous} /> : null}
|
||||||
|
description={<Trans>vs. previous period</Trans>}
|
||||||
|
testId="analytics-sent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnalyticsStatCard
|
||||||
|
{...sharedProps}
|
||||||
|
icon={CircleCheckIcon}
|
||||||
|
title={<Trans>Completion rate</Trans>}
|
||||||
|
value={data ? formatRate(data.completionRate.rate) : null}
|
||||||
|
badge={
|
||||||
|
data ? (
|
||||||
|
<CompletionRateDeltaBadge rate={data.completionRate.rate} previousRate={data.completionRate.previousRate} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
description={<Trans>of sent documents completed</Trans>}
|
||||||
|
testId="analytics-completion-rate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnalyticsStatCard
|
||||||
|
{...sharedProps}
|
||||||
|
icon={entity.icon}
|
||||||
|
title={entity.title}
|
||||||
|
value={entityCounts ? `${formatNumber(entityCounts.active)}/${formatNumber(entityCounts.total)}` : null}
|
||||||
|
description={
|
||||||
|
entityCounts ? (
|
||||||
|
<Trans>
|
||||||
|
{formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '}
|
||||||
|
inactive
|
||||||
|
</Trans>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
testId={entity.testId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type SentDeltaBadgeProps = {
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => {
|
||||||
|
if (previous === 0 && current === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous === 0) {
|
||||||
|
return (
|
||||||
|
<DeltaBadge tone="new" testId="analytics-sent-delta">
|
||||||
|
<Trans>New</Trans>
|
||||||
|
</DeltaBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = Math.round(((current - previous) / previous) * 100);
|
||||||
|
|
||||||
|
// Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display.
|
||||||
|
const label =
|
||||||
|
delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-sent-delta">
|
||||||
|
{label}
|
||||||
|
</DeltaBadge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompletionRateDeltaBadgeProps = {
|
||||||
|
rate: number | null;
|
||||||
|
previousRate: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => {
|
||||||
|
if (rate === null || previousRate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the rounded values so the delta always agrees with the displayed rate.
|
||||||
|
const delta = Math.round(rate) - Math.round(previousRate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-completion-rate-delta">
|
||||||
|
{formatSignedNumber(delta)}%
|
||||||
|
</DeltaBadge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeltaTone = 'positive' | 'negative' | 'zero' | 'new';
|
||||||
|
|
||||||
|
type DeltaBadgeProps = {
|
||||||
|
tone: DeltaTone;
|
||||||
|
testId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => {
|
||||||
|
const DeltaIcon = DELTA_TONE_ICONS[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-x-0.5 rounded-full px-1.5 py-0.5 font-medium text-xs tabular-nums leading-none',
|
||||||
|
DELTA_TONE_CLASSES[tone],
|
||||||
|
)}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
{DeltaIcon && <DeltaIcon className="-ml-0.5 h-3 w-3" aria-hidden="true" />}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DELTA_TONE_CLASSES: Record<DeltaTone, string> = {
|
||||||
|
positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||||
|
negative: 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||||
|
zero: 'bg-muted text-muted-foreground',
|
||||||
|
new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_DISPLAYED_DELTA_PERCENT = 999;
|
||||||
|
|
||||||
|
const DELTA_TONE_ICONS: Record<DeltaTone, typeof ArrowUpRightIcon | null> = {
|
||||||
|
positive: ArrowUpRightIcon,
|
||||||
|
negative: ArrowDownRightIcon,
|
||||||
|
zero: null,
|
||||||
|
new: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRate = (rate: number | null) => {
|
||||||
|
if (rate === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(rate)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSignedNumber = (value: number) => {
|
||||||
|
if (value > 0) {
|
||||||
|
return `+${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDeltaTone = (delta: number): DeltaTone => {
|
||||||
|
if (delta > 0) {
|
||||||
|
return 'positive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta < 0) {
|
||||||
|
return 'negative';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'zero';
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { AnalyticsRangeValue } from '~/utils/analytics';
|
||||||
|
import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics';
|
||||||
|
|
||||||
|
import { AnalyticsRangePicker } from './analytics-range-picker';
|
||||||
|
|
||||||
|
export type AnalyticsPageHeaderProps = {
|
||||||
|
avatarImageId: string | null;
|
||||||
|
/** The team or organisation name. */
|
||||||
|
name: string;
|
||||||
|
range: AnalyticsRangeValue;
|
||||||
|
onRangeChange: (range: AnalyticsRangeValue) => void;
|
||||||
|
/** Rendered before the range picker, e.g. a link to a related analytics page. */
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsPageHeader = ({
|
||||||
|
avatarImageId,
|
||||||
|
name,
|
||||||
|
range,
|
||||||
|
onRangeChange,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: AnalyticsPageHeaderProps) => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const rangeLabel =
|
||||||
|
range.range === 'custom'
|
||||||
|
? formatAnalyticsDateRange(range.from, range.to, i18n.locale)
|
||||||
|
: _(ANALYTICS_RANGE_LABELS[range.range]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||||
|
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
|
||||||
|
<AvatarFallback className="text-muted-foreground text-xs">{name.slice(0, 1)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-4xl">
|
||||||
|
<Trans>Analytics</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
Usage overview for {name} · {rangeLabel}
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
|
||||||
|
<AnalyticsRangePicker value={range} onValueChange={onRangeChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type AnalyticsQueryErrorProps = {
|
||||||
|
onRetry: () => Promise<unknown>;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => {
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
setIsRetrying(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onRetry();
|
||||||
|
} finally {
|
||||||
|
setIsRetrying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="neutral" padding="tight" className={className} data-testid="analytics-error">
|
||||||
|
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span>
|
||||||
|
<Trans>This data could not be loaded.</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void handleRetry()} loading={isRetrying}>
|
||||||
|
<Trans>Retry</Trans>
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
|
||||||
|
import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { CalendarProps } from '@documenso/ui/primitives/calendar';
|
||||||
|
import { Calendar } from '@documenso/ui/primitives/calendar';
|
||||||
|
import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics';
|
||||||
|
import {
|
||||||
|
ANALYTICS_PRESET_RANGES,
|
||||||
|
ANALYTICS_RANGE_LABELS,
|
||||||
|
formatAnalyticsDate,
|
||||||
|
formatAnalyticsDateRange,
|
||||||
|
getAnalyticsDateRangeDays,
|
||||||
|
} from '~/utils/analytics';
|
||||||
|
|
||||||
|
export type AnalyticsRangePickerProps = {
|
||||||
|
value: AnalyticsRangeValue;
|
||||||
|
onValueChange: (value: AnalyticsRangeValue) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */
|
||||||
|
type DraftRange = {
|
||||||
|
from: Date | undefined;
|
||||||
|
to: Date | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A single react-day-picker matcher, e.g. `{ after: Date }`. */
|
||||||
|
type DayMatcher = Exclude<CalendarProps['disabled'], undefined | unknown[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preset select with a "Custom range…" item that opens a two month range
|
||||||
|
* calendar anchored to the select. The custom window is only committed when
|
||||||
|
* "Apply" is pressed.
|
||||||
|
*/
|
||||||
|
export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
const { width } = useWindowSize();
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<DraftRange | undefined>();
|
||||||
|
|
||||||
|
const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1;
|
||||||
|
|
||||||
|
const today = DateTime.local().startOf('day');
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
setDraft(
|
||||||
|
value.range === 'custom'
|
||||||
|
? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsPickerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePicker = () => {
|
||||||
|
setIsPickerOpen(false);
|
||||||
|
setDraft(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectValueChange = (nextValue: string) => {
|
||||||
|
if (nextValue === CUSTOM_RANGE_VALUE) {
|
||||||
|
openPicker();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue);
|
||||||
|
|
||||||
|
if (!preset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueChange({ range: preset });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picking a day starts a new window unless one end is already pending, in which
|
||||||
|
* case it completes it. This replaces react-day-picker's default, which extends
|
||||||
|
* a completed window instead of starting over.
|
||||||
|
*/
|
||||||
|
const handleDaySelect = (_nextRange: unknown, day: Date) => {
|
||||||
|
if (draft?.from && !draft.to) {
|
||||||
|
setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft({ from: day, to: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (!draft?.from || !draft.to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) });
|
||||||
|
|
||||||
|
closePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only the last year (plus a day) up to today is selectable.
|
||||||
|
const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
|
||||||
|
const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }];
|
||||||
|
|
||||||
|
// Open on the month of the pending window (or today), keeping the current month
|
||||||
|
// as the right-most one so no fully disabled future month is shown.
|
||||||
|
const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month');
|
||||||
|
const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 });
|
||||||
|
const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate();
|
||||||
|
|
||||||
|
const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null;
|
||||||
|
const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null;
|
||||||
|
const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0;
|
||||||
|
|
||||||
|
const customLabel =
|
||||||
|
value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={isPickerOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
* The select never holds "custom" as its value so choosing "Custom range…" always
|
||||||
|
* fires a change, letting an active custom window be adjusted. The trigger shows
|
||||||
|
* the formatted window through the placeholder instead.
|
||||||
|
*/}
|
||||||
|
<Select value={value.range === 'custom' ? '' : value.range} onValueChange={handleSelectValueChange}>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<SelectTrigger
|
||||||
|
ref={triggerRef}
|
||||||
|
className="w-full sm:w-auto sm:min-w-44"
|
||||||
|
aria-label={_(msg`Date range`)}
|
||||||
|
data-testid="analytics-range"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={customLabel} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</PopoverAnchor>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{ANALYTICS_PRESET_OPTIONS.map(({ value: optionValue, label }) => (
|
||||||
|
<SelectItem key={optionValue} value={optionValue}>
|
||||||
|
{_(label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SelectSeparator />
|
||||||
|
|
||||||
|
<SelectItem value={CUSTOM_RANGE_VALUE} data-testid="analytics-range-custom">
|
||||||
|
<Trans>Custom range…</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
ref={contentRef}
|
||||||
|
align="end"
|
||||||
|
className="w-auto p-0"
|
||||||
|
// The select refocuses its trigger (asynchronously) as it closes, which
|
||||||
|
// would otherwise dismiss the popover that has just opened and strand
|
||||||
|
// keyboard focus outside it. Pointer interaction with the trigger still
|
||||||
|
// dismisses the popover so the select can be reopened.
|
||||||
|
onFocusOutside={(event) => {
|
||||||
|
if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const content = contentRef.current;
|
||||||
|
const firstTabbable = content?.querySelector<HTMLElement>(TABBABLE_SELECTOR);
|
||||||
|
|
||||||
|
(firstTabbable ?? content)?.focus();
|
||||||
|
}}
|
||||||
|
// There is no popover trigger element, so hand focus back to the select.
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-testid="analytics-range-calendar">
|
||||||
|
<Calendar
|
||||||
|
mode="range"
|
||||||
|
selected={draft}
|
||||||
|
onSelect={handleDaySelect}
|
||||||
|
numberOfMonths={numberOfMonths}
|
||||||
|
// Adjacent months would otherwise show the same days twice.
|
||||||
|
showOutsideDays={false}
|
||||||
|
defaultMonth={defaultMonth}
|
||||||
|
fromDate={earliestDay.toJSDate()}
|
||||||
|
toDate={today.toJSDate()}
|
||||||
|
disabled={disabledDays}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2">
|
||||||
|
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||||
|
{draftFrom && draftTo ? (
|
||||||
|
<>
|
||||||
|
{formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '}
|
||||||
|
<Plural value={draftDays} one="# day" other="# days" />
|
||||||
|
</>
|
||||||
|
) : draftFrom ? (
|
||||||
|
<Trans>Pick an end date</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Pick a start date</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={closePicker}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={!draftFrom || !draftTo}
|
||||||
|
data-testid="analytics-range-apply"
|
||||||
|
>
|
||||||
|
<Trans>Apply</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CUSTOM_RANGE_VALUE = 'custom';
|
||||||
|
|
||||||
|
/** Tailwind `sm` breakpoint; two months are shown from here up. */
|
||||||
|
const SM_BREAKPOINT = 640;
|
||||||
|
|
||||||
|
/** First element the popover should focus: the calendar's month navigation, then the days. */
|
||||||
|
const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]';
|
||||||
|
|
||||||
|
const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({
|
||||||
|
value,
|
||||||
|
label: ANALYTICS_RANGE_LABELS[value],
|
||||||
|
}));
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { AnalyticsQueryError } from './analytics-query-error';
|
||||||
|
|
||||||
|
export type AnalyticsStatCardProps = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: ReactNode;
|
||||||
|
value: ReactNode;
|
||||||
|
description: ReactNode;
|
||||||
|
badge?: ReactNode;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
onRetry: () => Promise<unknown>;
|
||||||
|
testId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsStatCard = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
onRetry,
|
||||||
|
testId,
|
||||||
|
}: AnalyticsStatCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col p-5">
|
||||||
|
<div className="flex items-center justify-between gap-x-3">
|
||||||
|
<h3 className="font-medium text-muted-foreground text-sm">{title}</h3>
|
||||||
|
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<AnalyticsQueryError onRetry={onRetry} className="mt-3" />
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="mt-3 flex flex-col gap-y-2">
|
||||||
|
<Skeleton className="h-9 w-24" />
|
||||||
|
<Skeleton className="h-3.5 w-32" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<p className="font-semibold text-3xl text-foreground tabular-nums tracking-tight" data-testid={testId}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import type { AnalyticsQueryResult } from '~/utils/analytics';
|
||||||
|
|
||||||
|
import { AnalyticsQueryError } from './analytics-query-error';
|
||||||
|
|
||||||
|
export type AnalyticsStatusBreakdownCardProps = {
|
||||||
|
query: AnalyticsQueryResult<TGetTeamAnalyticsStatusBreakdownResponse>;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = query;
|
||||||
|
|
||||||
|
const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col', className)} data-testid="analytics-status-breakdown">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<CardTitle>
|
||||||
|
<Trans>Status breakdown</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<CardDescription>
|
||||||
|
<Trans>Documents created in this period</Trans>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<p className="shrink-0 whitespace-nowrap">
|
||||||
|
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
|
||||||
|
{data.total.toLocaleString(i18n.locale)}
|
||||||
|
</span>{' '}
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
<Trans>total</Trans>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-1 flex-col">
|
||||||
|
{isError ? (
|
||||||
|
<AnalyticsQueryError onRetry={refetch} />
|
||||||
|
) : isLoading || !data ? (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<Skeleton className="h-2.5 w-full rounded-full" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
{STATUS_ROWS.slice(0, 3).map((row) => (
|
||||||
|
<Skeleton key={row.key} className="h-5 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : data.total === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col gap-y-4">
|
||||||
|
<StatusBar segments={[]} label={_(msg`No documents in this period`)} />
|
||||||
|
|
||||||
|
<p className="flex flex-1 items-center justify-center text-center text-muted-foreground text-sm">
|
||||||
|
<Trans>No documents in this period</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<StatusBar segments={rows} label={_(msg`Document status distribution`)} />
|
||||||
|
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<li key={row.key} className="flex items-center justify-between gap-x-3 py-2.5 text-sm">
|
||||||
|
<div className="flex min-w-0 items-center gap-x-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: row.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="truncate text-foreground">{_(row.label)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-baseline gap-x-2 tabular-nums">
|
||||||
|
<span className="font-medium text-foreground" data-testid={`analytics-status-${row.key}`}>
|
||||||
|
{row.count.toLocaleString(i18n.locale)}
|
||||||
|
</span>
|
||||||
|
<span className="w-10 text-right text-muted-foreground">{row.percent}%</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusBarProps = {
|
||||||
|
segments: Array<{ key: string; percent: number; color: string }>;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stacked horizontal bar. Segment widths come from the largest-remainder
|
||||||
|
* percentages so they always add up to the full width; an empty list renders
|
||||||
|
* the muted track on its own.
|
||||||
|
*/
|
||||||
|
const StatusBar = ({ segments, label }: StatusBarProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-2.5 w-full gap-px overflow-hidden rounded-full bg-muted" role="img" aria-label={label}>
|
||||||
|
{segments.map((segment) => (
|
||||||
|
<div
|
||||||
|
key={segment.key}
|
||||||
|
className="h-full"
|
||||||
|
style={{ width: `${segment.percent}%`, backgroundColor: segment.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled';
|
||||||
|
|
||||||
|
type StatusRow = {
|
||||||
|
key: StatusKey;
|
||||||
|
label: MessageDescriptor;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for status colours so the bar and the legend cannot drift.
|
||||||
|
*/
|
||||||
|
const STATUS_ROWS: StatusRow[] = [
|
||||||
|
{ key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' },
|
||||||
|
{ key: 'pending', label: msg`Pending`, color: '#f59e0b' },
|
||||||
|
{ key: 'rejected', label: msg`Rejected`, color: '#ef4444' },
|
||||||
|
{ key: 'cancelled', label: msg`Cancelled`, color: '#f97316' },
|
||||||
|
{ key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign integer percentages to the non-zero rows using largest-remainder
|
||||||
|
* allocation so the values always sum to exactly 100, with every non-zero row
|
||||||
|
* shown as at least 1%.
|
||||||
|
*/
|
||||||
|
const allocatePercentages = <T extends { count: number }>(rows: T[]): Array<T & { percent: number }> => {
|
||||||
|
const visibleRows = rows.filter((row) => row.count > 0);
|
||||||
|
const total = visibleRows.reduce((sum, row) => sum + row.count, 0);
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allocations = visibleRows.map((row, index) => {
|
||||||
|
const exact = (row.count / total) * 100;
|
||||||
|
const floored = Math.floor(exact);
|
||||||
|
|
||||||
|
return { index, percent: floored, remainder: exact - floored };
|
||||||
|
});
|
||||||
|
|
||||||
|
let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0);
|
||||||
|
|
||||||
|
const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index);
|
||||||
|
|
||||||
|
for (const allocation of byRemainder) {
|
||||||
|
if (remaining <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
allocation.percent += 1;
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every non-zero row must display at least 1%; take the difference from the largest rows.
|
||||||
|
const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index);
|
||||||
|
|
||||||
|
for (const allocation of allocations) {
|
||||||
|
if (allocation.percent > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
allocation.percent = 1;
|
||||||
|
|
||||||
|
const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1);
|
||||||
|
|
||||||
|
if (donor) {
|
||||||
|
donor.percent -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent }));
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import type { AnalyticsQueryResult } from '~/utils/analytics';
|
||||||
|
import { formatRelativeDate } from '~/utils/analytics';
|
||||||
|
|
||||||
|
import { AnalyticsQueryError } from './analytics-query-error';
|
||||||
|
|
||||||
|
/** The template shape shared by the team and organisation procedures. */
|
||||||
|
export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number];
|
||||||
|
|
||||||
|
export type AnalyticsTemplateUsageCardProps<TTemplate extends AnalyticsTemplate> = {
|
||||||
|
query: AnalyticsQueryResult<{ templates: TTemplate[] }>;
|
||||||
|
/** Where the template title links to. Return null to render a plain title. */
|
||||||
|
getTemplateHref: (template: TTemplate) => string | null;
|
||||||
|
/** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */
|
||||||
|
renderTemplateMeta?: (template: TTemplate) => ReactNode;
|
||||||
|
/** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */
|
||||||
|
templatesHref?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsTemplateUsageCard = <TTemplate extends AnalyticsTemplate>({
|
||||||
|
query,
|
||||||
|
getTemplateHref,
|
||||||
|
renderTemplateMeta,
|
||||||
|
templatesHref,
|
||||||
|
className,
|
||||||
|
}: AnalyticsTemplateUsageCardProps<TTemplate>) => {
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className} data-testid="analytics-template-usage">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<Trans>Template usage</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<CardDescription>
|
||||||
|
<Trans>Documents created from templates</Trans>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{isError ? (
|
||||||
|
<AnalyticsQueryError onRetry={refetch} />
|
||||||
|
) : isLoading || !data ? (
|
||||||
|
<ul className="flex flex-col gap-y-3">
|
||||||
|
{Array.from({ length: 3 }, (_, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-x-3">
|
||||||
|
<Skeleton className="h-4 w-5" />
|
||||||
|
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-y-1.5">
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : data.templates.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<FileTextIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="max-w-sm text-muted-foreground text-sm">
|
||||||
|
<Trans>No documents were created from templates in this period</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{templatesHref && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={templatesHref}>
|
||||||
|
<Trans>View templates</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="flex flex-col divide-y divide-border">
|
||||||
|
{data.templates.map((template, index) => {
|
||||||
|
const href = template.title === null ? null : getTemplateHref(template);
|
||||||
|
const meta = renderTemplateMeta?.(template);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={template.id}
|
||||||
|
className="flex items-center gap-x-3 py-3 first:pt-0 last:pb-0"
|
||||||
|
data-testid="analytics-template-row"
|
||||||
|
>
|
||||||
|
<span className="w-5 shrink-0 text-muted-foreground text-xs tabular-nums" aria-hidden="true">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||||
|
<FileTextIcon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{template.title === null ? (
|
||||||
|
<span className="truncate text-muted-foreground text-sm">
|
||||||
|
<Trans>Unavailable template</Trans>
|
||||||
|
</span>
|
||||||
|
) : href !== null ? (
|
||||||
|
<Link to={href} className="truncate font-medium text-foreground text-sm hover:underline">
|
||||||
|
{template.title}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate font-medium text-foreground text-sm">{template.title}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(meta || template.updatedAt !== null) && (
|
||||||
|
<span className="truncate text-muted-foreground text-xs">
|
||||||
|
{meta}
|
||||||
|
{meta && template.updatedAt !== null && ' · '}
|
||||||
|
{template.updatedAt !== null && (
|
||||||
|
<Trans>Updated {formatRelativeDate(template.updatedAt, i18n.locale)}</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="shrink-0 rounded-md border bg-muted px-2 py-0.5 font-medium text-foreground text-xs tabular-nums">
|
||||||
|
<Plural value={template.count} one="# use" other="# uses" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,9 +17,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
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 { keepPreviousData } from '@tanstack/react-query';
|
||||||
import { commandScore } from 'cmdk/dist/command-score';
|
import { defaultFilter as commandScore } from 'cmdk';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@@ -596,9 +596,13 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
|
|||||||
|
|
||||||
<span className="ml-auto text-muted-foreground text-xs">
|
<span className="ml-auto text-muted-foreground text-xs">
|
||||||
{hasValidSearch ? (
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import LogoImage from '@documenso/assets/logo.png';
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
|
||||||
|
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
@@ -22,6 +25,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
const { organisations } = useSession();
|
const { organisations } = useSession();
|
||||||
|
|
||||||
const currentTeam = useOptionalCurrentTeam();
|
const currentTeam = useOptionalCurrentTeam();
|
||||||
|
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||||
|
|
||||||
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
|
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
|
||||||
{
|
{
|
||||||
@@ -37,18 +41,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
const menuNavigationLinks = useMemo(() => {
|
const menuNavigationLinks = useMemo(() => {
|
||||||
let teamUrl = currentTeam?.url || null;
|
const navigationTeam =
|
||||||
|
currentTeam ??
|
||||||
|
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
|
||||||
|
|
||||||
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
|
if (!navigationTeam) {
|
||||||
teamUrl = organisations[0].teams[0].url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!teamUrl) {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
href: '/inbox',
|
href: '/inbox',
|
||||||
text: t`Inbox`,
|
text: t`Inbox`,
|
||||||
},
|
},
|
||||||
|
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
|
||||||
|
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
href: '/settings/profile',
|
href: '/settings/profile',
|
||||||
text: t`Settings`,
|
text: t`Settings`,
|
||||||
@@ -56,6 +61,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const teamUrl = navigationTeam.url;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
href: `/t/${teamUrl}/documents`,
|
href: `/t/${teamUrl}/documents`,
|
||||||
@@ -69,12 +76,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
href: '/inbox',
|
href: '/inbox',
|
||||||
text: t`Inbox`,
|
text: t`Inbox`,
|
||||||
},
|
},
|
||||||
|
...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole)
|
||||||
|
? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
href: '/settings/profile',
|
href: '/settings/profile',
|
||||||
text: t`Settings`,
|
text: t`Settings`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [currentTeam, organisations]);
|
}, [currentTeam, currentOrganisation, organisations, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
|
||||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { DocumentStatus } from '@prisma/client';
|
|
||||||
|
|
||||||
import { StackAvatar } from './stack-avatar';
|
|
||||||
|
|
||||||
export type AvatarWithRecipientProps = {
|
|
||||||
recipient: TRecipientLite;
|
|
||||||
documentStatus: DocumentStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
|
|
||||||
const [, copy] = useCopyToClipboard();
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
|
|
||||||
|
|
||||||
const onRecipientClick = () => {
|
|
||||||
if (!signingToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Copied to clipboard`),
|
|
||||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn('my-1 flex items-center gap-2', {
|
|
||||||
'cursor-pointer hover:underline': signingToken,
|
|
||||||
})}
|
|
||||||
role={signingToken ? 'button' : undefined}
|
|
||||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
|
||||||
onClick={onRecipientClick}
|
|
||||||
>
|
|
||||||
<StackAvatar
|
|
||||||
first={true}
|
|
||||||
key={recipient.id}
|
|
||||||
type={getRecipientType(recipient)}
|
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="text-muted-foreground text-sm"
|
|
||||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
|
||||||
>
|
|
||||||
<p>{recipient.email || recipient.name}</p>
|
|
||||||
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import type { TTemplate } from '@documenso/lib/types/template';
|
import type { TTemplate } from '@documenso/lib/types/template';
|
||||||
@@ -41,6 +42,7 @@ export const DirectTemplatePageView = ({
|
|||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
|
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
|
||||||
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
|
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
|
||||||
@@ -124,6 +126,13 @@ export const DirectTemplatePageView = ({
|
|||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'direct_template',
|
||||||
|
recipientId: directTemplateRecipient.id,
|
||||||
|
envelopeId: template.envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(errorMessage.title),
|
title: _(errorMessage.title),
|
||||||
description: _(errorMessage.description),
|
description: _(errorMessage.description),
|
||||||
|
|||||||
+28
-5
@@ -1,5 +1,7 @@
|
|||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password';
|
||||||
|
|
||||||
export type DocumentSigningAuthPasswordProps = {
|
export type DocumentSigningAuthPasswordProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({
|
|||||||
}: DocumentSigningAuthPasswordProps) => {
|
}: DocumentSigningAuthPasswordProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||||
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>({
|
const form = useForm<TPasswordAuthFormSchema>({
|
||||||
resolver: zodResolver(ZPasswordAuthFormSchema),
|
resolver: zodResolver(ZPasswordAuthFormSchema),
|
||||||
@@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({
|
|||||||
|
|
||||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
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) => {
|
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
|
||||||
try {
|
try {
|
||||||
setIsCurrentlyAuthenticating(true);
|
setIsCurrentlyAuthenticating(true);
|
||||||
@@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({
|
|||||||
|
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
setFormErrorCode(error.code);
|
setFormErrorCode(error.code);
|
||||||
|
|
||||||
// Todo: Alert.
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,9 +89,22 @@ export const DocumentSigningAuthPassword = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open]);
|
}, [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 (
|
return (
|
||||||
<Form {...form}>
|
<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}>
|
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{formErrorCode && (
|
{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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -39,6 +40,7 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||||
|
|
||||||
@@ -126,6 +128,13 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -157,6 +166,13 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the field.`),
|
description: _(msg`An error occurred while removing the field.`),
|
||||||
@@ -216,6 +232,13 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while updating the signature.`),
|
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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth';
|
import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { Field, Recipient } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
@@ -27,6 +29,8 @@ import { useEmbedSigningContext } from '~/components/embed/embed-signing-context
|
|||||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
|
||||||
export type DocumentSigningCompleteDialogProps = {
|
export type DocumentSigningCompleteDialogProps = {
|
||||||
@@ -85,7 +89,9 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
position,
|
position,
|
||||||
disableNameInput = false,
|
disableNameInput = false,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const { t } = useLingui();
|
const analytics = useAnalytics();
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
@@ -174,6 +180,23 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
return;
|
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 { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@@ -39,6 +40,7 @@ export const DocumentSigningDateField = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||||
|
|
||||||
@@ -85,6 +87,13 @@ export const DocumentSigningDateField = ({
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -113,6 +122,13 @@ export const DocumentSigningDateField = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the field.`),
|
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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -35,6 +36,7 @@ export const DocumentSigningDropdownField = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||||
|
|
||||||
@@ -86,6 +88,13 @@ export const DocumentSigningDropdownField = ({
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -120,6 +129,13 @@ export const DocumentSigningDropdownField = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the field.`),
|
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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -33,6 +34,7 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { email: providedEmail } = useRequiredDocumentSigningContext();
|
const { email: providedEmail } = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
@@ -78,6 +80,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -106,6 +115,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the field.`),
|
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 { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
@@ -19,6 +20,8 @@ import { useId, useMemo, useState } from 'react';
|
|||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
|
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
@@ -100,9 +103,12 @@ export const DocumentSigningForm = ({
|
|||||||
try {
|
try {
|
||||||
await completeDocument({ nextSigner });
|
await completeDocument({ nextSigner });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(toastMessage.title),
|
||||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
description: _(toastMessage.description),
|
||||||
variant: 'destructive',
|
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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -38,6 +39,7 @@ export const DocumentSigningInitialsField = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { fullName } = useRequiredDocumentSigningContext();
|
const { fullName } = useRequiredDocumentSigningContext();
|
||||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||||
@@ -84,6 +86,13 @@ export const DocumentSigningInitialsField = ({
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -112,6 +121,13 @@ export const DocumentSigningInitialsField = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the field.`),
|
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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -39,6 +40,7 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
|
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
@@ -116,6 +118,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -144,6 +153,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the field.`),
|
description: _(msg`An error occurred while removing the field.`),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -47,6 +48,7 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||||
|
|
||||||
@@ -142,6 +144,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -193,6 +202,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the field.`),
|
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 { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
@@ -83,8 +82,6 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
@@ -118,12 +115,6 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
|
|
||||||
await completeDocumentWithToken(payload);
|
await completeDocumentWithToken(payload);
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
|
||||||
signerId: recipient.id,
|
|
||||||
documentId: document.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (documentMeta?.redirectUrl) {
|
if (documentMeta?.redirectUrl) {
|
||||||
window.location.href = documentMeta.redirectUrl;
|
window.location.href = documentMeta.redirectUrl;
|
||||||
} else {
|
} 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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -31,6 +32,7 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
|
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||||
|
|
||||||
@@ -91,6 +93,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -120,6 +129,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the selection.`),
|
description: _(msg`An error occurred while removing the selection.`),
|
||||||
@@ -146,14 +162,17 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
|||||||
{isLoading && <DocumentSigningFieldsLoader />}
|
{isLoading && <DocumentSigningFieldsLoader />}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!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) => (
|
{values?.map((item, index) => (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
className="h-3 w-3 shrink-0"
|
className="h-3 w-3 shrink-0"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
id={`option-${field.id}-${item.id}`}
|
id={`option-${field.id}-${item.id}`}
|
||||||
checked={item.checked}
|
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{!item.value.includes('empty-value-') && item.value && (
|
{!item.value.includes('empty-value-') && item.value && (
|
||||||
@@ -167,14 +186,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{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) => (
|
{values?.map((item, index) => (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
id={`option-${field.id}-${item.id}`}
|
id={`option-${field.id}-${item.id}`}
|
||||||
checked={item.value === field.customText}
|
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{!item.value.includes('empty-value-') && item.value && (
|
{!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 { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +42,7 @@ export function DocumentSigningRejectDialog({
|
|||||||
}: DocumentSigningRejectDialogProps) {
|
}: DocumentSigningRejectDialogProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const analytics = useAnalytics();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -76,6 +78,12 @@ export function DocumentSigningRejectDialog({
|
|||||||
window.location.href = `/sign/${token}/rejected`;
|
window.location.href = `/sign/${token}/rejected`;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'reject_document',
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Error`,
|
||||||
description: t`An error occurred while rejecting the document. Please try again.`,
|
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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -47,6 +48,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { recipient } = useDocumentSigningRecipientContext();
|
const { recipient } = useDocumentSigningRecipientContext();
|
||||||
|
|
||||||
@@ -157,6 +159,13 @@ export const DocumentSigningSignatureField = ({
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while signing the document.`),
|
description: _(msg`An error occurred while signing the document.`),
|
||||||
@@ -183,6 +192,13 @@ export const DocumentSigningSignatureField = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the signature.`),
|
description: _(msg`An error occurred while removing the signature.`),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
@@ -50,6 +51,7 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||||
|
|
||||||
@@ -170,6 +172,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: isAssistantMode
|
description: isAssistantMode
|
||||||
@@ -200,6 +209,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'remove_field',
|
||||||
|
fieldType: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`An error occurred while removing the field.`),
|
description: _(msg`An error occurred while removing the field.`),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useNavigate, useSearchParams } from 'react-router';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
import { useCspNonce } from '~/utils/nonce';
|
||||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
export type DocumentEditFormProps = {
|
export type DocumentEditFormProps = {
|
||||||
@@ -42,6 +43,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
|
|||||||
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
|
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
const cspNonce = useCspNonce();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -473,6 +475,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
|
|||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
onAutoSave={onAddSignersFormAutoSave}
|
onAutoSave={onAddSignersFormAutoSave}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
|
nonce={cspNonce}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { useQueryState } from 'nuqs';
|
import { useQueryStates } from 'nuqs';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||||
@@ -10,16 +10,26 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
|
|||||||
export const DocumentSearch = () => {
|
export const DocumentSearch = () => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
|
const [{ query }, setSearchParams] = useQueryStates(
|
||||||
|
{
|
||||||
|
query: documentsSearchParams.query,
|
||||||
|
page: documentsSearchParams.page,
|
||||||
|
},
|
||||||
|
{ history: 'push' },
|
||||||
|
);
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedSearchTerm !== (query ?? '')) {
|
if (debouncedSearchTerm !== (query ?? '')) {
|
||||||
void setQuery(debouncedSearchTerm || null);
|
// Reset pagination so a new search never lands on an empty page.
|
||||||
|
void setSearchParams({
|
||||||
|
query: debouncedSearchTerm || null,
|
||||||
|
page: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [debouncedSearchTerm, query, setQuery]);
|
}, [debouncedSearchTerm, query, setSearchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
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 { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
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 team = useCurrentTeam();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const analytics = useAnalytics();
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const userTimezone =
|
const userTimezone =
|
||||||
@@ -103,12 +101,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
|||||||
description: _(msg`Your document has been uploaded successfully.`),
|
description: _(msg`Your document has been uploaded successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Document Uploaded', {
|
|
||||||
userId: user.id,
|
|
||||||
documentId: id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle legacy template creation.
|
// 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 { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
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 { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||||
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
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 }) => {
|
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
const analytics = useAnalytics();
|
||||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
@@ -276,6 +281,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
|||||||
unsafeRenderFieldOnLayer(field);
|
unsafeRenderFieldOnLayer(field);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'editor',
|
||||||
|
location: 'envelope_page_render',
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
setRenderError(true);
|
setRenderError(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -350,6 +362,9 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
|||||||
shouldOverdrawWholeArea: true,
|
shouldOverdrawWholeArea: true,
|
||||||
ignoreStroke: true,
|
ignoreStroke: true,
|
||||||
flipEnabled: false,
|
flipEnabled: false,
|
||||||
|
anchorStyleFunc: (anchor) => {
|
||||||
|
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / scale);
|
||||||
|
},
|
||||||
boundBoxFunc: (oldBox, newBox) => {
|
boundBoxFunc: (oldBox, newBox) => {
|
||||||
// Enforce minimum size
|
// Enforce minimum size
|
||||||
if (newBox.width < 30 || newBox.height < 20) {
|
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 { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
import { useCspNonce } from '~/utils/nonce';
|
||||||
|
|
||||||
export const EnvelopeEditorRecipientForm = () => {
|
export const EnvelopeEditorRecipientForm = () => {
|
||||||
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
|
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
|
||||||
@@ -52,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
const cspNonce = useCspNonce();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -795,6 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
|
nonce={cspNonce}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
sensors={[
|
sensors={[
|
||||||
(api: SensorAPI) => {
|
(api: SensorAPI) => {
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ import {
|
|||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
} from '@documenso/lib/types/document-meta';
|
} from '@documenso/lib/types/document-meta';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
|
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
|
||||||
import { canAccessTeamDocument, DocumentSignatureType, extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
import { canAccessTeamDocument, DocumentSignatureType, extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -97,7 +97,7 @@ export const ZAddSettingsFormSchema = z.object({
|
|||||||
redirectUrl: z
|
redirectUrl: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
|
.refine((value) => value === undefined || value === '' || isHttpUrl(value), {
|
||||||
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
|
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
|
||||||
}),
|
}),
|
||||||
language: z
|
language: z
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
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 { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
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 { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
||||||
|
import { useCspNonce } from '~/utils/nonce';
|
||||||
|
|
||||||
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||||
@@ -41,10 +43,12 @@ type LocalFile = {
|
|||||||
|
|
||||||
export const EnvelopeEditorUploadPage = () => {
|
export const EnvelopeEditorUploadPage = () => {
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
const cspNonce = useCspNonce();
|
||||||
|
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
envelope,
|
envelope,
|
||||||
@@ -213,6 +217,12 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
const { data } = await createPromise.catch((error) => {
|
const { data } = await createPromise.catch((error) => {
|
||||||
console.error(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.
|
// Set error state on files in batch upload.
|
||||||
setLocalFiles((prev) =>
|
setLocalFiles((prev) =>
|
||||||
prev.map((uploadingFile) =>
|
prev.map((uploadingFile) =>
|
||||||
@@ -290,6 +300,12 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
analytics.captureException(error, {
|
||||||
|
source: isEmbedded ? 'embed' : 'editor',
|
||||||
|
location: 'replace_pdf',
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Replace failed`,
|
title: t`Replace failed`,
|
||||||
description: t`Something went wrong while replacing the PDF`,
|
description: t`Something went wrong while replacing the PDF`,
|
||||||
@@ -480,7 +496,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
|
|
||||||
{/* Uploaded Files List */}
|
{/* Uploaded Files List */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext nonce={cspNonce} onDragEnd={onDragEnd}>
|
||||||
<Droppable droppableId="files">
|
<Droppable droppableId="files">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<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 { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import {
|
import {
|
||||||
type PageRenderData,
|
type PageRenderData,
|
||||||
@@ -18,6 +19,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
|||||||
|
|
||||||
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
envelopeStatus,
|
envelopeStatus,
|
||||||
@@ -114,6 +116,13 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
|||||||
unsafeRenderFieldOnLayer(field);
|
unsafeRenderFieldOnLayer(field);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'editor',
|
||||||
|
location: 'envelope_page_render',
|
||||||
|
envelopeId: currentEnvelopeItem?.envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
setRenderError(true);
|
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 { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import {
|
import {
|
||||||
type PageRenderData,
|
type PageRenderData,
|
||||||
@@ -53,6 +54,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
|
|
||||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
envelopeData,
|
envelopeData,
|
||||||
@@ -426,6 +428,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
|
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'page_render',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
setRenderError(true);
|
setRenderError(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -507,6 +517,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'signing',
|
||||||
|
location: 'sign_field',
|
||||||
|
fieldType: payload.type,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Error`,
|
||||||
description: t`An error occurred while signing the field.`,
|
description: t`An error occurred while signing the field.`,
|
||||||
|
|||||||
+15
-14
@@ -118,12 +118,6 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
title: t`Document already signed`,
|
title: t`Document already signed`,
|
||||||
description: t`This document was already signed and no further action was taken.`,
|
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) {
|
if (onDocumentCompleted) {
|
||||||
@@ -148,15 +142,18 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
||||||
toast({
|
analytics.captureException(err, {
|
||||||
title: t`Something went wrong`,
|
source: 'signing',
|
||||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
location: 'complete_document',
|
||||||
variant: 'destructive',
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
onDocumentError?.();
|
onDocumentError?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
|
||||||
|
// toast a specific completion error message.
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -224,14 +221,18 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('err', err);
|
console.log('err', err);
|
||||||
toast({
|
|
||||||
title: t`Something went wrong`,
|
analytics.captureException(err, {
|
||||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
source: 'signing',
|
||||||
variant: 'destructive',
|
location: 'complete_document_next_signer',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
onDocumentError?.();
|
onDocumentError?.();
|
||||||
|
|
||||||
|
// Rethrow so DocumentSigningCompleteDialog can toast a specific
|
||||||
|
// completion error message.
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,14 +93,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
|||||||
duration: 5000,
|
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 pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||||
|
|
||||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||||
@@ -109,6 +101,11 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'editor',
|
||||||
|
location: 'upload_document',
|
||||||
|
});
|
||||||
|
|
||||||
const errorMessage = getUploadErrorMessage(error.code);
|
const errorMessage = getUploadErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
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 { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
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 { t, i18n } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
@@ -112,6 +114,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'editor',
|
||||||
|
location: 'upload_document',
|
||||||
|
});
|
||||||
|
|
||||||
const errorMessage = getUploadErrorMessage(error.code);
|
const errorMessage = getUploadErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user