mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
Merge branch 'main' into feat/recipient-partial-pdf-download
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.
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,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",
|
||||||
@@ -157,11 +157,11 @@ Retrieve all documents belonging to the team:
|
|||||||
```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" \
|
||||||
-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?status=PENDING" \
|
||||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||||
````
|
````
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -191,7 +191,7 @@ Templates created with a team token are shared across the team.
|
|||||||
<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/template/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={
|
||||||
"title": "NDA Template",
|
"title": "NDA Template",
|
||||||
@@ -269,7 +269,7 @@ console.log('Created team template:', template.id);
|
|||||||
<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/template" \
|
||||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||||
````
|
````
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|||||||
@@ -1000,9 +1000,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -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.0",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next": "16.2.6"
|
"next": "16.3.0"
|
||||||
},
|
},
|
||||||
"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({
|
||||||
|
|||||||
@@ -290,10 +290,11 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
||||||
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="https://documen.so/enterprise-cta"
|
to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
|
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -17,7 +20,9 @@ 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 { 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,67 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{validationError !== null && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription className="max-h-32 overflow-y-auto">
|
||||||
|
{match(validationError)
|
||||||
|
.with({ type: 'PARSE_ERROR' }, () => (
|
||||||
|
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
|
||||||
|
))
|
||||||
|
.with({ type: 'EMPTY' }, () => (
|
||||||
|
<Trans>
|
||||||
|
The CSV does not contain any rows. Please add at least one row of recipient details.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
|
||||||
|
<Trans>
|
||||||
|
The CSV contains {rowCount} rows. A maximum of {maxRows} rows is allowed per upload.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
|
||||||
|
<>
|
||||||
|
<Trans>
|
||||||
|
The CSV is missing the following required columns. Please download the template CSV for the
|
||||||
|
correct format.
|
||||||
|
</Trans>
|
||||||
|
|
||||||
|
<ul className="mt-1 list-inside list-disc">
|
||||||
|
{missingColumns.map((column) => (
|
||||||
|
<li key={column} className="font-mono">
|
||||||
|
{column}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
|
||||||
|
<>
|
||||||
|
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
|
||||||
|
|
||||||
|
<ul className="mt-1 list-inside list-disc">
|
||||||
|
{rowErrors.map((rowError, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<Trans>
|
||||||
|
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
|
||||||
|
email or empty
|
||||||
|
</Trans>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
|
||||||
|
code === AppErrorCode.LIMIT_EXCEEDED ? (
|
||||||
|
<Trans>The CSV exceeds the maximum file size.</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.exhaustive()}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sendImmediately"
|
name="sendImmediately"
|
||||||
@@ -240,7 +340,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
@@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
|||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_REDIRECT_PATH = '/';
|
const LOGIN_REDIRECT_PATH = formatPath('/');
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: zEmail().min(1),
|
email: zEmail().min(1),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DOCUMENTS_PAGE_SHORTCUT,
|
DOCUMENTS_PAGE_SHORTCUT,
|
||||||
@@ -16,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,
|
||||||
@@ -595,9 +596,21 @@ 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>
|
||||||
@@ -862,7 +875,7 @@ const PromptLanguageCommands = ({
|
|||||||
|
|
||||||
formData.append('lang', lang);
|
formData.append('lang', lang);
|
||||||
|
|
||||||
const response = await fetch('/api/locale', {
|
const response = await fetch(formatPath('/api/locale'), {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,5 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } 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';
|
||||||
@@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
redirectPath: formatPath(
|
||||||
|
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
|
|||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
let redirectPath = '/signin';
|
let redirectPath = formatPath('/signin');
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
|
|||||||
+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.`),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -45,6 +46,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
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 +215,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 +298,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`,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -68,6 +69,7 @@ export default function PDFViewer({
|
|||||||
}: PDFViewerProps) {
|
}: PDFViewerProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const $el = useRef<HTMLDivElement>(null);
|
const $el = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -150,6 +152,11 @@ export default function PDFViewer({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
setLoadingState('error');
|
setLoadingState('error');
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'pdf_viewer',
|
||||||
|
location: 'pdf_load',
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Error`,
|
||||||
description: t`An error occurred while loading the document.`,
|
description: t`An error occurred while loading the document.`,
|
||||||
@@ -215,7 +222,7 @@ export default function PDFViewer({
|
|||||||
|
|
||||||
type VirtualizedPageListProps = {
|
type VirtualizedPageListProps = {
|
||||||
scrollParentRef: ScrollTarget;
|
scrollParentRef: ScrollTarget;
|
||||||
constraintRef: React.RefObject<HTMLDivElement>;
|
constraintRef: React.RefObject<HTMLDivElement | null>;
|
||||||
pages: PageMeta[];
|
pages: PageMeta[];
|
||||||
numPages: number;
|
numPages: number;
|
||||||
pdf: pdfjsLib.PDFDocumentProxy;
|
pdf: pdfjsLib.PDFDocumentProxy;
|
||||||
@@ -366,6 +373,8 @@ const PdfViewerPage = ({
|
|||||||
* Manages rendering a page from a pdf.
|
* Manages rendering a page from a pdf.
|
||||||
*/
|
*/
|
||||||
const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => {
|
const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
|
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
|
||||||
|
|
||||||
const [imageUrl, setImageUrl] = useState('');
|
const [imageUrl, setImageUrl] = useState('');
|
||||||
@@ -457,6 +466,12 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
|
|||||||
|
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
analytics.captureException(err, {
|
||||||
|
source: 'pdf_viewer',
|
||||||
|
location: 'pdf_page_render',
|
||||||
|
});
|
||||||
|
|
||||||
setImageLoadingState('error');
|
setImageLoadingState('error');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { EASE, POP, SPRING } from './motion';
|
||||||
|
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||||
|
import { useTimedCycle } from './use-timed-cycle';
|
||||||
|
|
||||||
|
const DEMO_BRANDS = [
|
||||||
|
{
|
||||||
|
name: 'Documenso',
|
||||||
|
letter: 'D',
|
||||||
|
domain: 'noreply@app.documenso.com',
|
||||||
|
accent: '#A2E771',
|
||||||
|
ink: '#162C07',
|
||||||
|
tint: '#F2FBEA',
|
||||||
|
sheen: 'rgba(162, 231, 113, 0.32)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Documenso',
|
||||||
|
letter: 'D',
|
||||||
|
domain: 'noreply@app.documenso.com',
|
||||||
|
accent: '#387BC7',
|
||||||
|
ink: '#ffffff',
|
||||||
|
tint: '#EDF3FA',
|
||||||
|
sheen: 'rgba(56, 123, 199, 0.28)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Documenso',
|
||||||
|
letter: 'D',
|
||||||
|
domain: 'noreply@app.documenso.com',
|
||||||
|
accent: '#9747F5',
|
||||||
|
ink: '#ffffff',
|
||||||
|
tint: '#F4EDFE',
|
||||||
|
sheen: 'rgba(151, 71, 245, 0.26)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds each brand is shown before cycling to the next.
|
||||||
|
*/
|
||||||
|
const BRAND_CYCLE_INTERVAL_MS = 2400;
|
||||||
|
|
||||||
|
export const BrandingUpsell = () => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const isReducedMotion = useReducedMotion();
|
||||||
|
const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS));
|
||||||
|
|
||||||
|
const isStatic = isReducedMotion ?? false;
|
||||||
|
|
||||||
|
const brand = DEMO_BRANDS[brandIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsUpsellCard
|
||||||
|
planLabel={<Trans>Teams</Trans>}
|
||||||
|
title={<Trans>Unlock Branding Preferences</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>Put your own brand on every document you send. Branding is available on the Teams plan and above.</Trans>
|
||||||
|
}
|
||||||
|
features={[
|
||||||
|
<Trans key="logo">Your logo on signing pages and emails</Trans>,
|
||||||
|
<Trans key="details">Company details and website in email footers</Trans>,
|
||||||
|
<Trans key="teams">Separate branding per team</Trans>,
|
||||||
|
]}
|
||||||
|
preview={
|
||||||
|
<div className="mx-auto w-full max-w-xs">
|
||||||
|
<div className="flex h-8 items-center justify-between px-1">
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||||
|
<Trans>Brand accent</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{DEMO_BRANDS.map((dotBrand, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{
|
||||||
|
scale: index === brandIndex ? 1.25 : 1,
|
||||||
|
opacity: index === brandIndex ? 1 : 0.42,
|
||||||
|
boxShadow:
|
||||||
|
index === brandIndex ? '0 0 0 3px rgba(15, 23, 42, 0.08)' : '0 0 0 0 rgba(15, 23, 42, 0)',
|
||||||
|
}}
|
||||||
|
transition={SPRING}
|
||||||
|
className="h-[13px] w-[13px] rounded-full"
|
||||||
|
style={{ backgroundColor: dotBrand.accent }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-3 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.tint }}
|
||||||
|
transition={{ duration: 0.45, ease: EASE }}
|
||||||
|
className="flex items-center gap-2.5 border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
{/* The sender identity never changes — only the tile colours tween per brand. */}
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||||
|
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg font-semibold text-sm"
|
||||||
|
>
|
||||||
|
{brand.letter}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Hardcoded inks (not theme tokens): this row sits on the
|
||||||
|
* hardcoded light `tint` band, so it pairs with hardcoded ink
|
||||||
|
* colours the same way the email sibling pairs its hardcoded
|
||||||
|
* avatar surfaces (hardcoded surface => hardcoded ink).
|
||||||
|
*/}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-[#0f172a] text-sm">
|
||||||
|
<p className="truncate">{brand.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-[#64748b] text-xs">
|
||||||
|
<p className="truncate">{brand.domain}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
<Trans>Please sign: Example.pdf</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
|
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */}
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||||
|
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||||
|
className="mt-3 inline-block rounded-md px-3 py-1.5 font-medium text-xs"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
key={brandIndex}
|
||||||
|
initial={isStatic ? false : { scale: 0.96 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ ...POP, delay: 0.06 }}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
<Trans>Sign</Trans>
|
||||||
|
</motion.span>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center gap-2.5 border-t bg-muted px-4 py-2.5">
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||||
|
<Trans>Company details</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.accent }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="h-1.5 w-[54px] rounded-full"
|
||||||
|
style={{ opacity: 0.45 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.accent }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="h-1.5 w-[34px] rounded-full"
|
||||||
|
style={{ opacity: 0.22 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Keyed remount replays the sweep per brand. No opacity envelope —
|
||||||
|
* keyframe arrays are unreliable on strict-mode remounts; both
|
||||||
|
* endpoints sit outside the overflow-hidden card, so the clip
|
||||||
|
* provides the fade in/out instead.
|
||||||
|
*/}
|
||||||
|
<motion.div
|
||||||
|
key={`sheen-${brandIndex}`}
|
||||||
|
initial={isStatic ? false : { x: '-130%' }}
|
||||||
|
animate={{ x: '240%' }}
|
||||||
|
transition={{ duration: 1.15, ease: 'easeOut' }}
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||||
|
style={{ background: `linear-gradient(105deg, transparent, ${brand.sheen}, transparent)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||||
|
import { BadgeCheckIcon, MailIcon } from 'lucide-react';
|
||||||
|
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||||
|
import { EASE, POP, SPRING } from './motion';
|
||||||
|
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||||
|
import { useTimedCycle } from './use-timed-cycle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named sender identities cycled through while the preview is in its branded
|
||||||
|
* state — one per branded cycle step, shown as the sender name and address.
|
||||||
|
*/
|
||||||
|
const BRANDED_SENDERS = [
|
||||||
|
{ name: 'Support', email: 'support@example.com' },
|
||||||
|
{ name: 'Team', email: 'hello@example.com' },
|
||||||
|
{ name: 'Sales', email: 'sales@example.com' },
|
||||||
|
{ name: 'Example', email: 'noreply@example.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the initial unbranded (Documenso default) state is shown before
|
||||||
|
* the first flip starts. Shown exactly once — the cycle never returns to it.
|
||||||
|
*/
|
||||||
|
const INITIAL_STATE_DURATION_MS = 2500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long each branded sender identity is shown before cycling to the next,
|
||||||
|
* giving the viewer time to read the changed address.
|
||||||
|
*/
|
||||||
|
const BRANDED_STATE_DURATION_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One duration per cycle step: the unbranded state first, then one step per
|
||||||
|
* named sender identity, derived from the identity count so the two cannot
|
||||||
|
* drift.
|
||||||
|
*/
|
||||||
|
const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)];
|
||||||
|
|
||||||
|
export const EmailDomainsUpsell = () => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const isReducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
// Loop from index 1: the unbranded Documenso intro plays exactly once,
|
||||||
|
// then the cycle rotates through the branded senders only.
|
||||||
|
const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1);
|
||||||
|
|
||||||
|
const isBranded = cycleIndex > 0;
|
||||||
|
const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0];
|
||||||
|
|
||||||
|
const isStatic = isReducedMotion ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsUpsellCard
|
||||||
|
planLabel={<Trans>Enterprise</Trans>}
|
||||||
|
title={<Trans>Unlock Email Domains</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>Send documents from your own domain. Email domains are available on the Enterprise plan.</Trans>
|
||||||
|
}
|
||||||
|
features={[
|
||||||
|
<Trans key="journey">Send emails to recipients from your domain</Trans>,
|
||||||
|
<Trans key="dns">Easy DNS setup with auto-generated DKIM and SPF records</Trans>,
|
||||||
|
<Trans key="senders">Named senders with defaults per team, template or document</Trans>,
|
||||||
|
]}
|
||||||
|
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||||
|
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||||
|
ctaExternal
|
||||||
|
preview={
|
||||||
|
<div className="mx-auto w-full max-w-xs">
|
||||||
|
<div className="relative h-8">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={isBranded ? 'chip-on' : 'chip-off'}
|
||||||
|
initial={isStatic ? false : { opacity: 0, y: 8, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||||
|
transition={SPRING}
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 flex items-center gap-2 rounded-full border bg-background px-3 font-mono text-xs',
|
||||||
|
isBranded ? 'border-documenso-300 text-documenso-800' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBranded ? (
|
||||||
|
<BadgeCheckIcon className="h-3.5 w-3.5 shrink-0 text-documenso-700" />
|
||||||
|
) : (
|
||||||
|
<MailIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="truncate">
|
||||||
|
{isBranded ? <Trans>Sending from your domain</Trans> : <Trans>Sending from app.documenso.com</Trans>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-4 overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 border-b p-4">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-semibold text-sm">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={`logo-${cycleIndex}`}
|
||||||
|
initial={isStatic ? false : { opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.22 }}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
* Remounts with its keyed parent on every cycle step so the
|
||||||
|
* pop replays each change. Single-value spring (POP
|
||||||
|
* overshoots past 1) instead of scale keyframes — keyframe
|
||||||
|
* arrays are unreliable on strict-mode remounts.
|
||||||
|
*/}
|
||||||
|
<motion.span
|
||||||
|
initial={isStatic ? false : { scale: 0.8 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ scale: POP }}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
{isBranded ? (
|
||||||
|
<Avatar className="h-8 w-8 border border-solid">
|
||||||
|
{organisation.avatarImageId && (
|
||||||
|
<AvatarImage src={formatAvatarUrl(organisation.avatarImageId)} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-sm">{brandedSender.name[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
) : (
|
||||||
|
<BrandingLogoIcon className="h-8 w-8" />
|
||||||
|
)}
|
||||||
|
</motion.span>
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={`name-${cycleIndex}`}
|
||||||
|
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.28, ease: EASE }}
|
||||||
|
className="flex min-w-0 items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{isBranded ? brandedSender.name : 'Documenso'}</span>
|
||||||
|
|
||||||
|
{/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */}
|
||||||
|
{isBranded && (
|
||||||
|
<motion.span
|
||||||
|
initial={isStatic ? false : { scale: 0, rotate: -40 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ ...POP, delay: 0.12 }}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<BadgeCheckIcon className="h-3.5 w-3.5 text-documenso-700" />
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-muted-foreground text-xs">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.p
|
||||||
|
key={`addr-${cycleIndex}`}
|
||||||
|
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.28, ease: EASE }}
|
||||||
|
className="truncate"
|
||||||
|
>
|
||||||
|
{isBranded ? brandedSender.email : 'noreply@app.documenso.com'}
|
||||||
|
</motion.p>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
<Trans>Please sign: Example.pdf</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
|
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Keyed remount replays the sweep on every cycle step. No opacity
|
||||||
|
* envelope — keyframe arrays are unreliable on strict-mode
|
||||||
|
* remounts; both endpoints sit outside the overflow-hidden card,
|
||||||
|
* so the clip provides the fade in/out instead.
|
||||||
|
*/}
|
||||||
|
<motion.div
|
||||||
|
key={`sheen-${cycleIndex}`}
|
||||||
|
initial={isStatic ? false : { x: '-130%' }}
|
||||||
|
animate={{ x: '240%' }}
|
||||||
|
transition={{ duration: 1.2, ease: 'easeOut' }}
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||||
|
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.32), transparent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Shared motion vocabulary for the settings upsell previews. Values ported
|
||||||
|
* from the design prototype (`design/SSO Upsell.dc.html`).
|
||||||
|
*/
|
||||||
|
export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const;
|
||||||
|
|
||||||
|
export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const;
|
||||||
|
|
||||||
|
export const EASE = [0.22, 0.61, 0.36, 1] as const;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
export type SettingsUpsellCardProps = {
|
||||||
|
planLabel: ReactNode;
|
||||||
|
title: ReactNode;
|
||||||
|
description: ReactNode;
|
||||||
|
features: ReactNode[];
|
||||||
|
preview: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CTA label. Defaults to "Upgrade Plan".
|
||||||
|
*/
|
||||||
|
ctaLabel?: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CTA destination. Defaults to the organisation billing settings page.
|
||||||
|
*/
|
||||||
|
ctaTo?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the CTA as an external link (new tab) instead of an internal route.
|
||||||
|
*/
|
||||||
|
ctaExternal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared split-card layout for claim-gated settings upsells on Documenso
|
||||||
|
* Cloud. The left pane pitches the feature (plan badge, title, description,
|
||||||
|
* feature list, upgrade CTA); the right pane renders a decorative scenario
|
||||||
|
* preview supplied by the caller.
|
||||||
|
*
|
||||||
|
* Callers decide *when* to render this (cloud + missing claim flag).
|
||||||
|
*/
|
||||||
|
export const SettingsUpsellCard = ({
|
||||||
|
planLabel,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
features,
|
||||||
|
preview,
|
||||||
|
ctaLabel,
|
||||||
|
ctaTo,
|
||||||
|
ctaExternal = false,
|
||||||
|
}: SettingsUpsellCardProps) => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
|
||||||
|
|
||||||
|
const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`;
|
||||||
|
|
||||||
|
const ctaContent = (
|
||||||
|
<>
|
||||||
|
{ctaLabel ?? <Trans>Upgrade Plan</Trans>}
|
||||||
|
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 overflow-hidden rounded-xl border-2 ring-4 ring-muted/70 md:grid md:grid-cols-[1.08fr_0.92fr] xl:-mx-8">
|
||||||
|
{/*
|
||||||
|
* `min-w-0` on both grid items: `fr` tracks have an `auto` content
|
||||||
|
* minimum, so long preview content (e.g. a wide mono domain line) would
|
||||||
|
* otherwise widen the right track beyond its 0.92fr share — and
|
||||||
|
* re-balance the whole grid on every preview cycle (layout shift).
|
||||||
|
*/}
|
||||||
|
<div className="flex min-w-0 flex-col items-start p-6 md:p-8">
|
||||||
|
<Badge size="small">
|
||||||
|
<LockIcon className="mr-1 h-3 w-3" />
|
||||||
|
<span className="uppercase">{planLabel}</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<h3 className="mt-4 font-semibold text-xl">{title}</h3>
|
||||||
|
|
||||||
|
<p className="mt-2 max-w-[40ch] text-muted-foreground text-sm">{description}</p>
|
||||||
|
|
||||||
|
<ul className="mt-6 space-y-3">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2.5 text-sm">
|
||||||
|
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-documenso-200">
|
||||||
|
<CheckIcon className="h-3 w-3 text-documenso-800" strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{canManageBilling ? (
|
||||||
|
<Button className="mt-8" asChild>
|
||||||
|
{ctaExternal ? (
|
||||||
|
<a href={ctaHref} target="_blank" rel="noreferrer">
|
||||||
|
{ctaContent}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link to={ctaHref}>{ctaContent}</Link>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="mt-8 text-muted-foreground text-xs">
|
||||||
|
<Trans>Contact your organisation owner to upgrade plans.</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex min-w-0 flex-col justify-center gap-3 border-t bg-muted p-6 md:border-t-0 md:border-l md:p-8"
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||||
|
import { FingerprintIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { EASE, POP, SPRING } from './motion';
|
||||||
|
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||||
|
import { useTimedCycle } from './use-timed-cycle';
|
||||||
|
|
||||||
|
export const SsoPortalUpsell = () => {
|
||||||
|
const isReducedMotion = useReducedMotion();
|
||||||
|
const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsUpsellCard
|
||||||
|
planLabel={<Trans>Enterprise</Trans>}
|
||||||
|
title={<Trans>Unlock the Organisation SSO Portal</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>
|
||||||
|
Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan.
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
features={[
|
||||||
|
<Trans key="oidc">Works with any OIDC provider — Okta, Entra ID, Google and more</Trans>,
|
||||||
|
<Trans key="jit">Accounts are automatically added to your organisation on sign-in</Trans>,
|
||||||
|
<Trans key="control">Restrict sign-ins by email domain and choose the default role</Trans>,
|
||||||
|
]}
|
||||||
|
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||||
|
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||||
|
ctaExternal
|
||||||
|
preview={
|
||||||
|
<div className="mx-auto w-full max-w-xs">
|
||||||
|
<div className="relative h-[236px]">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{sceneIndex === 0 && <PortalScene key="portal" isStatic={isReducedMotion ?? false} />}
|
||||||
|
{sceneIndex === 1 && <RedirectScene key="redirect" />}
|
||||||
|
{sceneIndex === 2 && <SuccessScene key="success" />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute-positioned panel each scene renders in, handling the shared
|
||||||
|
* slide-and-fade transition between scenes.
|
||||||
|
*/
|
||||||
|
const ScenePanel = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.34, ease: EASE }}
|
||||||
|
className="absolute inset-0 flex flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-6 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene 1: the organisation's SSO portal, with a timed faux press on the
|
||||||
|
* "Continue with SSO" button (cursor flies in, button dips, sheen sweeps).
|
||||||
|
*
|
||||||
|
* When `isStatic` is set (reduced motion) every element renders with
|
||||||
|
* `initial={false}`, skipping entrance and press animations.
|
||||||
|
*/
|
||||||
|
const PortalScene = ({ isStatic }: { isStatic: boolean }) => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const rise = (delay: number) => ({
|
||||||
|
initial: isStatic ? false : { y: 10, opacity: 0 },
|
||||||
|
animate: { y: 0, opacity: 1 },
|
||||||
|
transition: { ...SPRING, delay },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScenePanel>
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : { scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ ...POP, delay: 0.04 }}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-documenso-200 font-semibold text-documenso-900">
|
||||||
|
{([...organisation.name][0] ?? 'D').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.p {...rise(0.12)} className="mt-3.5 font-semibold text-sm">
|
||||||
|
<Trans>Welcome to {organisation.name}</Trans>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.p {...rise(0.18)} className="mt-1 text-muted-foreground text-xs">
|
||||||
|
<Trans>Single sign-on</Trans>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<div className="relative mt-4 w-full">
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : { y: 10, opacity: 0, scale: 1 }}
|
||||||
|
animate={{ y: 0, opacity: 1, scale: [1, 1, 0.955, 1] }}
|
||||||
|
transition={{
|
||||||
|
y: { ...SPRING, delay: 0.24 },
|
||||||
|
opacity: { duration: 0.3, delay: 0.24 },
|
||||||
|
scale: { duration: 2.6, times: [0, 0.63, 0.72, 0.84], ease: 'easeOut' },
|
||||||
|
}}
|
||||||
|
className="relative flex h-[38px] w-full items-center justify-center overflow-hidden rounded-md bg-foreground font-semibold text-background text-sm"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Trans>Continue with SSO</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : { x: '-130%' }}
|
||||||
|
animate={{ x: '150%' }}
|
||||||
|
transition={{ duration: 0.85, delay: 1.75, ease: 'easeOut' }}
|
||||||
|
className="absolute top-0 bottom-0 left-[20%] w-3/5"
|
||||||
|
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.45), transparent)' }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.svg
|
||||||
|
initial={isStatic ? false : { x: 30, y: 30, opacity: 0, scale: 1 }}
|
||||||
|
animate={{
|
||||||
|
x: [30, 30, 0, 0, 0],
|
||||||
|
y: [30, 30, 0, 0, 0],
|
||||||
|
opacity: [0, 1, 1, 1, 0],
|
||||||
|
scale: [1, 1, 1, 0.82, 1],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 2.6, times: [0, 0.3, 0.63, 0.72, 0.94], ease: EASE }}
|
||||||
|
width={17}
|
||||||
|
height={17}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="absolute right-[26px] -bottom-2.5 fill-foreground stroke-background"
|
||||||
|
>
|
||||||
|
<path d="M4 2.5 19 12l-6.6 1.4L9.7 19.6z" />
|
||||||
|
</motion.svg>
|
||||||
|
</div>
|
||||||
|
</ScenePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene 2: redirecting to the identity provider, with a rotating ring around
|
||||||
|
* a fingerprint tile and a filling progress bar.
|
||||||
|
*/
|
||||||
|
const RedirectScene = () => {
|
||||||
|
return (
|
||||||
|
<ScenePanel>
|
||||||
|
<div className="relative flex h-[46px] w-[46px] items-center justify-center">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 0.95, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
|
||||||
|
className="absolute inset-0 rounded-full border-2"
|
||||||
|
style={{ borderTopColor: '#A2E771' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-muted">
|
||||||
|
<FingerprintIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.6} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ y: 8, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ ...SPRING, delay: 0.08 }}
|
||||||
|
className="mt-3.5 max-w-[22ch] text-sm"
|
||||||
|
>
|
||||||
|
<Trans>Redirecting to your identity provider</Trans>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<div className="mt-4 h-1 w-[140px] overflow-hidden rounded-full bg-border">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: '0%' }}
|
||||||
|
animate={{ width: '100%' }}
|
||||||
|
transition={{ duration: 1.35, ease: 'easeInOut' }}
|
||||||
|
className="h-full rounded-full bg-documenso"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScenePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene 3: signed in, with an expanding pulse ring, a popping green circle,
|
||||||
|
* a drawn checkmark and the signed-in member's email.
|
||||||
|
*/
|
||||||
|
const SuccessScene = () => {
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScenePanel>
|
||||||
|
<div className="relative h-10 w-10">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.7, opacity: 0.85 }}
|
||||||
|
animate={{ scale: 2.1, opacity: 0 }}
|
||||||
|
transition={{ duration: 1.1, ease: 'easeOut' }}
|
||||||
|
className="absolute inset-0 rounded-full border-2"
|
||||||
|
style={{ borderColor: '#A2E771' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.4, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={POP}
|
||||||
|
className="absolute inset-0 flex items-center justify-center rounded-full bg-documenso-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={19}
|
||||||
|
height={19}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.4}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-documenso-900"
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
d="M20 6 9 17l-5-5"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.12, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ y: 10, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ ...SPRING, delay: 0.16 }}
|
||||||
|
className="mt-3.5 font-semibold text-sm"
|
||||||
|
>
|
||||||
|
<Trans>Signed in</Trans>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ y: 10, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ ...SPRING, delay: 0.24 }}
|
||||||
|
className="mt-1 font-mono text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</motion.p>
|
||||||
|
</ScenePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds each scene is shown before advancing: portal, redirect,
|
||||||
|
* success.
|
||||||
|
*/
|
||||||
|
const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000];
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useReducedMotion } from 'framer-motion';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycles an index through `durations.length` steps, waiting `durations[i]`
|
||||||
|
* milliseconds on step `i` before advancing to the next.
|
||||||
|
*
|
||||||
|
* When the cycle wraps past the last step it continues from `loopStartIndex`
|
||||||
|
* (default `0`), letting consumers play intro-only steps exactly once and
|
||||||
|
* then loop through the remaining steps forever.
|
||||||
|
*
|
||||||
|
* Under `prefers-reduced-motion` the cycle never starts and the index stays
|
||||||
|
* at 0, so consumers render their initial state statically.
|
||||||
|
*
|
||||||
|
* Pass module-level constants for `durations` and `loopStartIndex` — their
|
||||||
|
* identities are intentionally not dependencies.
|
||||||
|
*/
|
||||||
|
export const useTimedCycle = (durations: number[], loopStartIndex = 0) => {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
const isReducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReducedMotion || durations.length === 0) {
|
||||||
|
setIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = 0;
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const next = current + 1;
|
||||||
|
|
||||||
|
current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next;
|
||||||
|
|
||||||
|
setIndex(current);
|
||||||
|
timeout = setTimeout(tick, durations[current]);
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout = setTimeout(tick, durations[0]);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [isReducedMotion]);
|
||||||
|
|
||||||
|
return index;
|
||||||
|
};
|
||||||
@@ -39,13 +39,14 @@ const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin list pages which support prefilling their search from the URL, used
|
* Admin list pages which support prefilling their search from the URL, used
|
||||||
* for the "View all results" links on capped groups. Teams, recipients and
|
* for the "View all results" links on capped groups. Teams and
|
||||||
* subscriptions have no admin list pages.
|
* subscriptions have no admin list pages.
|
||||||
*/
|
*/
|
||||||
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
||||||
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
||||||
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
||||||
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
||||||
|
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseAdminSearchCategoriesOptions = {
|
export type UseAdminSearchCategoriesOptions = {
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ import './utils/polyfills/promise-with-resolvers';
|
|||||||
* the page early, leaving dead event handlers (broken dropdowns, native form
|
* the page early, leaving dead event handlers (broken dropdowns, native form
|
||||||
* submits).
|
* submits).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Signing and direct template URLs contain recipient tokens which must never
|
||||||
|
* be sent to PostHog. Recipient context is attached explicitly via
|
||||||
|
* `recipientId` where needed instead.
|
||||||
|
*/
|
||||||
|
const redactTokensFromUrl = (value: string) => {
|
||||||
|
return value.replace(/(\/(?:sign|d|direct)\/)([^/?#]+)/g, '$1:token');
|
||||||
|
};
|
||||||
|
|
||||||
|
const URL_EVENT_PROPERTIES = [
|
||||||
|
'$current_url',
|
||||||
|
'$pathname',
|
||||||
|
'$referrer',
|
||||||
|
'$initial_referrer',
|
||||||
|
'$prev_pageview_pathname',
|
||||||
|
] as const;
|
||||||
|
|
||||||
function initPosthog() {
|
function initPosthog() {
|
||||||
const postHogConfig = extractPostHogConfig();
|
const postHogConfig = extractPostHogConfig();
|
||||||
|
|
||||||
@@ -25,12 +42,59 @@ function initPosthog() {
|
|||||||
void import('posthog-js').then(({ default: posthog }) => {
|
void import('posthog-js').then(({ default: posthog }) => {
|
||||||
posthog.init(postHogConfig.key, {
|
posthog.init(postHogConfig.key, {
|
||||||
api_host: postHogConfig.host,
|
api_host: postHogConfig.host,
|
||||||
|
// Only create person profiles for identified (authenticated) users,
|
||||||
|
// anonymous recipients on signing pages stay anonymous.
|
||||||
|
person_profiles: 'identified_only',
|
||||||
|
// Explicit events only, autocapture on signing pages blows up usage
|
||||||
|
// without providing actionable data.
|
||||||
|
autocapture: false,
|
||||||
|
capture_pageview: true,
|
||||||
|
capture_pageleave: false,
|
||||||
capture_exceptions: true,
|
capture_exceptions: true,
|
||||||
|
before_send: (event) => {
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const property of URL_EVENT_PROPERTIES) {
|
||||||
|
const value = event.properties?.[property];
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
event.properties[property] = redactTokensFromUrl(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.$set_once && typeof event.$set_once['$initial_current_url'] === 'string') {
|
||||||
|
event.$set_once['$initial_current_url'] = redactTokensFromUrl(event.$set_once['$initial_current_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces hydration recoveries (React 19 discards the server HTML and
|
||||||
|
* re-renders on the client instead of dying) so we can track how often
|
||||||
|
* extensions/early clicks interfere with hydration in the wild.
|
||||||
|
*/
|
||||||
|
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
|
||||||
|
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
|
||||||
|
|
||||||
|
if (extractPostHogConfig()) {
|
||||||
|
void import('posthog-js').then(({ default: posthog }) => {
|
||||||
|
if (posthog.__loaded) {
|
||||||
|
posthog.capture('$hydration_recoverable_error', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const locale = detect(fromHtmlTag('lang')) || 'en';
|
const locale = detect(fromHtmlTag('lang')) || 'en';
|
||||||
|
|
||||||
@@ -44,6 +108,7 @@ async function main() {
|
|||||||
<HydratedRouter />
|
<HydratedRouter />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
{ onRecoverableError },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+20
-7
@@ -1,5 +1,7 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { getBasePath } from '@documenso/lib/constants/app';
|
||||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||||
import { createPublicEnv } from '@documenso/lib/utils/env';
|
import { createPublicEnv } from '@documenso/lib/utils/env';
|
||||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||||
@@ -8,6 +10,7 @@ import { getOrganisationSession } from '@documenso/trpc/server/organisation-rout
|
|||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
data,
|
data,
|
||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
@@ -20,7 +23,6 @@ import {
|
|||||||
useMatches,
|
useMatches,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||||
|
|
||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
import stylesheet from './app.css?url';
|
import stylesheet from './app.css?url';
|
||||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||||
@@ -68,6 +70,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
|||||||
lang,
|
lang,
|
||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
disableAnimations,
|
disableAnimations,
|
||||||
|
basePath: getBasePath(),
|
||||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||||
// routes that need it) can carry the matching nonce attribute.
|
// routes that need it) can carry the matching nonce attribute.
|
||||||
@@ -90,10 +93,10 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const { theme } = useLoaderData<typeof loader>() || {};
|
const { theme, basePath } = useLoaderData<typeof loader>() || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
<ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
|
||||||
<LayoutContent>{children}</LayoutContent>
|
<LayoutContent>{children}</LayoutContent>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
@@ -111,6 +114,8 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const [theme] = useTheme();
|
const [theme] = useTheme();
|
||||||
|
|
||||||
|
const basePath = data.basePath ?? '';
|
||||||
|
|
||||||
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
||||||
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
||||||
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
||||||
@@ -126,11 +131,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links nonce={nonce(cspNonce)} />
|
<Links nonce={nonce(cspNonce)} />
|
||||||
@@ -197,11 +202,19 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
|
|
||||||
if (errorCode !== 404) {
|
if (errorCode !== 404) {
|
||||||
console.error('[RootErrorBoundary]', error);
|
console.error('[RootErrorBoundary]', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorCode !== 404) {
|
||||||
|
analytics.captureException(error, { source: 'app', location: 'root_boundary' });
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
return <GenericErrorLayout errorCode={errorCode} />;
|
return <GenericErrorLayout errorCode={errorCode} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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 { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
@@ -30,6 +30,12 @@ export default function AdminDocumentsPage() {
|
|||||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||||
|
|
||||||
|
const urlTerm = searchParams?.get?.('term') ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTerm(urlTerm);
|
||||||
|
}, [urlTerm]);
|
||||||
|
|
||||||
const { data: findDocumentsData, isPending: isFindDocumentsLoading } = trpc.admin.document.find.useQuery(
|
const { data: findDocumentsData, isPending: isFindDocumentsLoading } = trpc.admin.document.find.useQuery(
|
||||||
{
|
{
|
||||||
query: debouncedTerm,
|
query: debouncedTerm,
|
||||||
@@ -131,7 +137,7 @@ export default function AdminDocumentsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={_(msg`Search by document title, team:123 or user:123`)}
|
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
|
||||||
value={term}
|
value={term}
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type TBrandingPreferencesFormSchema,
|
type TBrandingPreferencesFormSchema,
|
||||||
} from '~/components/forms/branding-preferences-form';
|
} from '~/components/forms/branding-preferences-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
@@ -121,11 +122,18 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
? t`Here you can set branding preferences for your team.`
|
? t`Here you can set branding preferences for your team.`
|
||||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||||
|
|
||||||
|
const brandingPreferencesFormEnabled =
|
||||||
|
organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
<SettingsHeader
|
||||||
|
title={settingsHeaderText}
|
||||||
|
subtitle={settingsHeaderSubtitle}
|
||||||
|
hideDivider={!brandingPreferencesFormEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
|
{brandingPreferencesFormEnabled ? (
|
||||||
<section>
|
<section>
|
||||||
<BrandingPreferencesForm
|
<BrandingPreferencesForm
|
||||||
context="Organisation"
|
context="Organisation"
|
||||||
@@ -160,6 +168,8 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
) : IS_DOCUMENSO_CLOUD() ? (
|
||||||
|
<BrandingUpsell />
|
||||||
) : (
|
) : (
|
||||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||||
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
||||||
@@ -27,8 +27,9 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
|
|||||||
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
|
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||||
|
|
||||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
|
||||||
|
|
||||||
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -96,10 +97,23 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
|||||||
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
||||||
}, [organisation]);
|
}, [organisation]);
|
||||||
|
|
||||||
|
const pageHeader = t`Email Domain Settings`;
|
||||||
|
const pageSubtitle = t`Manage your email domain settings.`;
|
||||||
|
|
||||||
if (!IS_BILLING_ENABLED()) {
|
if (!IS_BILLING_ENABLED()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
|
||||||
|
|
||||||
|
<EmailDomainsUpsell />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoadingEmailDomain) {
|
if (isLoadingEmailDomain) {
|
||||||
return <SpinnerBox className="py-32" />;
|
return <SpinnerBox className="py-32" />;
|
||||||
}
|
}
|
||||||
@@ -132,7 +146,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader hideDivider title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
|
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
|
||||||
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
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';
|
||||||
@@ -9,6 +9,7 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
|
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||||
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
|
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ export default function OrganisationSettingsEmailDomains() {
|
|||||||
<section>
|
<section>
|
||||||
<OrganisationEmailDomainsDataTable />
|
<OrganisationEmailDomainsDataTable />
|
||||||
</section>
|
</section>
|
||||||
|
) : IS_DOCUMENSO_CLOUD() ? (
|
||||||
|
<EmailDomainsUpsell />
|
||||||
) : (
|
) : (
|
||||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { SsoPortalUpsell } from '~/components/general/settings-upsell/sso-portal-upsell';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
|
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
|
||||||
@@ -63,10 +65,33 @@ export default function OrganisationSettingSSOLoginPage() {
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const isAuthenticationPortalEnabled = organisation.organisationClaim.flags.authenticationPortal === true;
|
||||||
|
|
||||||
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
|
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
|
||||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
|
trpc.enterprise.organisation.authenticationPortal.get.useQuery(
|
||||||
organisationId: organisation.id,
|
{
|
||||||
});
|
organisationId: organisation.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The endpoint rejects orgs without the claim flag, so don't fire
|
||||||
|
// requests that are guaranteed to error.
|
||||||
|
enabled: isAuthenticationPortalEnabled,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAuthenticationPortalEnabled && IS_DOCUMENSO_CLOUD()) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
hideDivider
|
||||||
|
title={t`Organisation SSO Portal`}
|
||||||
|
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SsoPortalUpsell />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
||||||
return <SpinnerBox className="py-32" />;
|
return <SpinnerBox className="py-32" />;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
@@ -7,6 +8,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { ChevronLeftIcon } from 'lucide-react';
|
import { ChevronLeftIcon } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import type { ShouldRevalidateFunctionArgs } from 'react-router';
|
import type { ShouldRevalidateFunctionArgs } from 'react-router';
|
||||||
import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router';
|
import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router';
|
||||||
|
|
||||||
@@ -66,8 +68,18 @@ export default function DocumentsLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.captureException(error, {
|
||||||
|
source: 'editor',
|
||||||
|
location: 'editor_layout_boundary',
|
||||||
|
envelopeId: params.id,
|
||||||
|
});
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
const errorCodeMap = {
|
const errorCodeMap = {
|
||||||
404: {
|
404: {
|
||||||
subHeading: msg`404 Document not found`,
|
subHeading: msg`404 Document not found`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type TBrandingPreferencesFormSchema,
|
type TBrandingPreferencesFormSchema,
|
||||||
} from '~/components/forms/branding-preferences-form';
|
} from '~/components/forms/branding-preferences-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
export default function TeamsSettingsPage() {
|
||||||
@@ -155,6 +156,8 @@ export default function TeamsSettingsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
) : IS_DOCUMENSO_CLOUD() ? (
|
||||||
|
<BrandingUpsell />
|
||||||
) : (
|
) : (
|
||||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
|
|||||||
@@ -68,6 +68,20 @@ export default function ApiTokensPage() {
|
|||||||
return i18n.date(row.original.expires);
|
return i18n.date(row.original.expires);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t`Last Used`,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.lastUsedAt) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<Trans>Never</Trans>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="text-foreground">{i18n.date(row.original.lastUsedAt)}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: t`Actions`,
|
header: t`Actions`,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -146,6 +160,9 @@ export default function ApiTokensPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-4 w-16 rounded-full" />
|
<Skeleton className="h-4 w-16 rounded-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-16 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
@@ -8,6 +9,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { ChevronLeftIcon } from 'lucide-react';
|
import { ChevronLeftIcon } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router';
|
import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router';
|
||||||
|
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
@@ -84,8 +86,18 @@ export default function TemplatesLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.captureException(error, {
|
||||||
|
source: 'editor',
|
||||||
|
location: 'editor_layout_boundary',
|
||||||
|
envelopeId: params.id,
|
||||||
|
});
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
const errorCodeMap = {
|
const errorCodeMap = {
|
||||||
404: {
|
404: {
|
||||||
subHeading: msg`404 Template not found`,
|
subHeading: msg`404 Template not found`,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
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';
|
||||||
@@ -5,6 +6,7 @@ import { i18n } from '@lingui/core';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
|
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
|
||||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
@@ -48,8 +50,14 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.captureException(error, { source: 'signing', location: 'recipient_layout_boundary' });
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericErrorLayout
|
<GenericErrorLayout
|
||||||
errorCode={errorCode}
|
errorCode={errorCode}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
||||||
|
|
||||||
export const loader = () => {
|
export const loader = async () => {
|
||||||
try {
|
try {
|
||||||
const certStatus = getCertificateStatus();
|
const certStatus = await getCertificateStatus();
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
isAvailable: certStatus.isAvailable,
|
isAvailable: certStatus.isAvailable,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const loader = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const certStatus = getCertificateStatus();
|
const certStatus = await getCertificateStatus();
|
||||||
|
|
||||||
if (certStatus.isAvailable) {
|
if (certStatus.isAvailable) {
|
||||||
checks.certificate = { status: 'ok' };
|
checks.certificate = { status: 'ok' };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import {
|
import {
|
||||||
IS_GOOGLE_SSO_ENABLED,
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
IS_MICROSOFT_SSO_ENABLED,
|
IS_MICROSOFT_SSO_ENABLED,
|
||||||
@@ -5,6 +6,7 @@ import {
|
|||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { isRouteErrorResponse, Outlet, useRouteError } from 'react-router';
|
import { isRouteErrorResponse, Outlet, useRouteError } from 'react-router';
|
||||||
|
|
||||||
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
||||||
@@ -47,10 +49,30 @@ export default function Layout() {
|
|||||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
||||||
|
|
||||||
|
const analytics = useAnalytics();
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
|
|
||||||
console.log({ routeError: error });
|
console.log({ routeError: error });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isExpectedEmbedResponse =
|
||||||
|
isRouteErrorResponse(error) &&
|
||||||
|
[
|
||||||
|
'embed-authentication-required',
|
||||||
|
'embed-paywall',
|
||||||
|
'embed-waiting-for-turn',
|
||||||
|
'embed-recipient-expired',
|
||||||
|
'embed-document-rejected',
|
||||||
|
'embed-document-completed',
|
||||||
|
].includes(error.data?.type);
|
||||||
|
|
||||||
|
if (isExpectedEmbedResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
analytics.captureException(error, { source: 'embed', location: 'embed_layout_boundary' });
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
|
|||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||||
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
||||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { data } from 'react-router';
|
import { data } from 'react-router';
|
||||||
@@ -93,6 +95,30 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: template.teamId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
organisationId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: user?.id,
|
||||||
|
organisationId: team?.organisationId,
|
||||||
|
teamId: template.teamId,
|
||||||
|
properties: {
|
||||||
|
type: 'signing',
|
||||||
|
version: 'v0',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: template.envelopeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
@@ -194,6 +220,30 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelope.teamId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
organisationId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: user?.id,
|
||||||
|
organisationId: team?.organisationId,
|
||||||
|
teamId: envelope.teamId,
|
||||||
|
properties: {
|
||||||
|
type: 'signing',
|
||||||
|
version: 'v0',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
|
|||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||||
@@ -13,6 +14,7 @@ import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/
|
|||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||||
@@ -139,6 +141,30 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: document.teamId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
organisationId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: user?.id,
|
||||||
|
organisationId: team?.organisationId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
properties: {
|
||||||
|
type: 'signing',
|
||||||
|
version: 'v0',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: document.envelopeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
@@ -272,6 +298,30 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
|||||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelope.teamId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
organisationId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: user?.id,
|
||||||
|
organisationId: team?.organisationId,
|
||||||
|
teamId: envelope.teamId,
|
||||||
|
properties: {
|
||||||
|
type: 'signing',
|
||||||
|
version: 'v0',
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
*
|
*
|
||||||
* No translations required.
|
* No translations required.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
export const loader = () => {
|
export const loader = () => {
|
||||||
@@ -147,7 +148,7 @@ export default function EmbedPlaygroundPage() {
|
|||||||
return inputToken;
|
return inputToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/v2/embedding/create-presign-token', {
|
const response = await fetch(formatPath('/api/v2/embedding/create-presign-token'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${inputToken}`,
|
Authorization: `Bearer ${inputToken}`,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
|
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema';
|
import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema';
|
||||||
|
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@@ -35,6 +38,40 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
|
allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
|
||||||
|
|
||||||
|
// Derive the kind of authoring session from the child route path, e.g.
|
||||||
|
// /embed/v1/authoring/document/create -> resource 'document', mode 'create'.
|
||||||
|
// Completed and error pages are not new sessions and are skipped.
|
||||||
|
const [resource, mode] = url.pathname.split('/').filter(Boolean).slice(3);
|
||||||
|
|
||||||
|
const isAuthoringSession =
|
||||||
|
(resource === 'document' || resource === 'template') && (mode === 'create' || mode === 'edit');
|
||||||
|
|
||||||
|
if (isAuthoringSession) {
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: result.teamId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
organisationId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: result.userId,
|
||||||
|
organisationId: team?.organisationId,
|
||||||
|
teamId: result.teamId,
|
||||||
|
properties: {
|
||||||
|
type: 'authoring',
|
||||||
|
version: 'v1',
|
||||||
|
resource,
|
||||||
|
mode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||||
|
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { SigningStatus } from '@prisma/client';
|
import { SigningStatus } from '@prisma/client';
|
||||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
@@ -42,6 +45,15 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const firstDocument = envelopes[0]?.document;
|
const firstDocument = envelopes[0]?.document;
|
||||||
|
|
||||||
if (!firstDocument) {
|
if (!firstDocument) {
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: user?.id,
|
||||||
|
properties: {
|
||||||
|
type: 'signing',
|
||||||
|
version: 'v1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
envelopes,
|
envelopes,
|
||||||
user,
|
user,
|
||||||
@@ -55,6 +67,29 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
|
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: firstDocument.teamId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
organisationId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: user?.id,
|
||||||
|
organisationId: team?.organisationId,
|
||||||
|
teamId: firstDocument.teamId,
|
||||||
|
properties: {
|
||||||
|
type: 'signing',
|
||||||
|
version: 'v1',
|
||||||
|
envelopeId: firstDocument.envelopeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
envelopes,
|
envelopes,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
@@ -12,7 +13,7 @@ import type { OrganisationSession } from '@documenso/trpc/server/organisation-ro
|
|||||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
|
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
|
||||||
import { useLayoutEffect, useState } from 'react';
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
import { isRouteErrorResponse, Outlet, useLoaderData } from 'react-router';
|
import { isRouteErrorResponse, Outlet, useLoaderData } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -166,8 +167,17 @@ export default function AuthoringLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.captureException(error, {
|
||||||
|
source: 'embed',
|
||||||
|
location: 'embed_authoring_boundary',
|
||||||
|
});
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{match(errorCode)
|
{match(errorCode)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||||
|
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
ZEmbedCreateEnvelopeAuthoringSchema,
|
ZEmbedCreateEnvelopeAuthoringSchema,
|
||||||
} from '@documenso/lib/types/envelope-editor';
|
} from '@documenso/lib/types/envelope-editor';
|
||||||
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
|
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||||
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
|
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
|
||||||
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
|
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
@@ -76,6 +78,30 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const team = result.teamId
|
||||||
|
? await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: result.teamId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
organisationId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: result.userId,
|
||||||
|
organisationId: team?.organisationId,
|
||||||
|
teamId: result.teamId ?? undefined,
|
||||||
|
properties: {
|
||||||
|
type: 'authoring',
|
||||||
|
version: 'v2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
token,
|
token,
|
||||||
tokenUserId: result.userId,
|
tokenUserId: result.userId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||||
|
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
|
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
|
||||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||||
@@ -94,6 +95,18 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
|||||||
brandingLogo = settings.brandingLogo;
|
brandingLogo = settings.brandingLogo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureServerEvent({
|
||||||
|
event: 'App: Embed Session Started',
|
||||||
|
userId: result.userId,
|
||||||
|
organisationId: envelope.team.organisationId,
|
||||||
|
teamId: result.teamId ?? undefined,
|
||||||
|
properties: {
|
||||||
|
type: 'authoring',
|
||||||
|
version: 'v2',
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
token,
|
token,
|
||||||
envelope,
|
envelope,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user