Compare commits

...
Author SHA1 Message Date
David Nguyen 724f5f62c5 Merge branch 'main' into feat/add-content-fields 2026-09-29 17:03:35 +10:00
David Nguyen 58b5e6b5d3 fix: embed z-index 2026-09-29 16:53:28 +10:00
David Nguyen 573c928a0e feat: add recipient grouping (#3319) 2026-09-29 16:52:43 +10:00
David Nguyen d87ac2b552 feat: add embed content support 2026-09-29 16:21:48 +10:00
Lucas Smith be94bddd40 fix: use legacy pdfjs build for older devices (#3410) 2026-09-29 16:13:39 +10:00
Lucas Smith 5a123be46c fix: embed signing completion and reload states (#3409)
Send completed/rejected events when reopening an actioned v1 embed,
show the completed page after signing in v2, and tidy the completed
page.
2026-09-29 15:44:25 +10:00
Lucas Smith 586b1f5cb5 v2.19.0 2026-09-29 14:37:03 +10:00
David Nguyen b90aa86e24 fix: test 2026-09-27 22:04:46 +10:00
David Nguyen 3c84d25d5a fix: various improvements 2026-09-27 20:08:47 +10:00
David Nguyen c96ee7c84f fix: correctly deselect settings when changing envelope items 2026-09-26 16:47:27 +10:00
David Nguyen 941f4a1e4c fix: auto show and hide editor action bar 2026-09-26 16:33:03 +10:00
David Nguyen 30a0979573 fix: stuff 2026-09-26 12:23:47 +10:00
Lucas Smith a1d4bec143 fix: accept owner-password protected pdfs (#3396)
Strip encryption from PDFs that open with an empty user password via
libpdf's ignorePermissions, still rejecting user-password PDFs.

Upgrade @libpdf/core to 0.5.1, which also keeps overlapping and layered
text intact during text extraction.

Resolves #3303
2026-09-26 11:23:33 +10:00
David Nguyen 94793c7cce feat: add content fields 2026-09-25 12:02:52 +10:00
Lucas Smith 39ae85483a fix: add date-fns to packages/ui (#3388) 2026-09-24 10:29:26 +10:00
Ephraim Duncan 638e92d534 feat: add team document analytics dashboard (#3355) 2026-09-24 09:50:35 +10:00
Catalin Pit c81bc72c4c fix: bulk download dialog overflow on long titles (#3380) 2026-09-21 19:23:11 +10:00
Zonaib Bokhari 1164d9578e Respect hidden sender details in document invite emails (#3061) 2026-09-21 09:20:39 +03:00
David Nguyen e658cc5818 feat: add inbox filters (#3372) 2026-09-17 17:21:34 +10:00
Ephraim Duncan 0c6249744a feat(ui): redesign recipient avatar stack hover popover (#3072) 2026-09-17 13:36:15 +10:00
Ephraim Duncan cf326f825d feat: replace template view tabs with a filter pill (#3148) 2026-09-17 10:24:19 +10:00
Ephraim Duncan da934c01a6 feat: allow signing reason override (#2848) 2026-09-16 14:41:55 +10:00
Ephraim Duncan 0693a4195b refactor(openpage): simplify the cors policy (#3338) 2026-09-16 14:10:55 +10:00
Ephraim Duncan 2618d4d4be chore(embedding): remove unregistered multi-sign mutation (#3339) 2026-09-16 14:07:04 +10:00
Ephraim Duncan c5a6ff42ee chore(ui): remove unused application skeletons (#3340) 2026-09-16 14:06:03 +10:00
Ephraim Duncan 132c4b08c5 fix(trpc): v2 field position updates silently dropped; rewrite fields API docs (#3136) 2026-09-16 13:59:40 +10:00
Ephraim Duncan 9542512ce9 refactor(lib): reuse the http url validator (#3346) 2026-09-16 13:54:52 +10:00
Ephraim Duncan 0f71a8b67d chore(prisma): remove unregistered middleware (#3345) 2026-09-16 13:53:19 +10:00
Ephraim Duncan b795883fc7 chore(lib): remove unused timezone labels (#3342) 2026-09-16 13:48:03 +10:00
Ephraim Duncan 643954ddd1 refactor(server): simplify request context middleware (#3341) 2026-09-16 13:46:01 +10:00
Catalin Pit a20e8a2376 feat: add document naming options when using templates (#3086) 2026-09-16 01:00:40 +10:00
Lucas Smith e73450dd08 chore: upgrade deps (#3367) 2026-09-14 16:14:44 +10:00
Christopher Ryan 5603a9e59d fix: preserve base path in email asset URLs (#3327) 2026-09-11 20:44:05 +10:00
Durgesh Shekhawat 20ae849a1a fix: use nuqs prevent infinite re-render loop (#2868) 2026-09-11 20:29:24 +10:00
Lucas Smith 6a99b40cba fix: improve action auth flow for passwordless users (#3358)
Selecting password auth failed with a generic "Unauthorized" for users
who signed up via OAuth or passkey, with no way to set one.

Detect the missing password and email the existing reset link from the
signing dialog and security settings. Require a 2FA code and rate limit
update-password.
2026-09-11 14:17:34 +10:00
Catalin Pit e1ad4a2c55 docs: add externalId to send-for-signature workflow example (#3325) 2026-09-10 14:41:05 +03:00
Konrad 82918163c5 fix(i18n): add missing plural in bulk template upload error handling (#3357) 2026-09-10 21:01:17 +10:00
Catalin Pit b97c22a607 fix: pass csp nonce to @hello-pangea/dnd style elements (#3354) 2026-09-10 15:29:41 +10:00
Lucas Smith 389390c884 v2.18.0 2026-09-09 11:34:10 +10:00
Ephraim Duncan c5acba538e chore(auth): remove commented account creation code (#3344) 2026-09-09 11:04:15 +10:00
Lucas Smith f5ab8a8ae3 fix: resolve build errors (#3352) 2026-09-09 11:04:01 +10:00
Ephraim Duncan 3b20c2f960 chore(ui): remove unused text fitting component (#3337) 2026-09-09 10:51:35 +10:00
Lucas Smith 5e8a434141 fix: use react router middleware (#3351) 2026-09-09 10:51:26 +10:00
David Nguyen 6a8bb4be04 fix: improve invalid bulk template upload error handling (#3326) 2026-09-08 21:03:02 +10:00
Martin Glaser 2cac63a000 fix: block SSRF via IPv4-mapped IPv6 webhook URLs (#2901) (#3166) 2026-09-04 15:42:39 +10:00
Catalin Pit 4aa3583e89 fix: temporary fix for British to US spelling (#3329) 2026-09-04 15:41:11 +10:00
Lucas Smith 30a6b19b47 chore: upgrade to node 24 lts and clean up docker image (#3332)
Upgrade to Node 24 LTS, using the alpine 3.23 tag to handle issues with
streaming zip files on 24.16 which hangs npm ci.

Pin npm to 11.19.1 for min-release-age-exclude support.

Slim the runner image by dropping dev deps, the react-email CLI, and
esbuild,
none of which run in production.

Install turbo from the lockfile version instead of a hardcoded one.
2026-09-04 12:29:02 +10:00
Nathan Delhaye dabb7b7a0d fix: increase signature placeholder size when a timestamp authority is configured (#3328) 2026-09-04 12:26:01 +10:00
Catalin Pit cbb1cf7bef fix: default unset signing transport to local (#3309)
`/api/health` and `/api/certificate-status` reported the cert as
available when `NEXT_PRIVATE_SIGNING_TRANSPORT` was unset, even though
sealing defaults to the local P12 and fails if it is missing,
unreadable, or expired.
2026-09-04 08:17:42 +10:00
Lucas Smith 3ec877a68b fix: add patches back to dockerfile (#3324) 2026-09-01 16:12:08 +10:00
Lucas Smith bf406aa943 chore: upgrade libpdf (#3323) 2026-09-01 14:11:58 +10:00
Catalin Pit a2745ed3c2 docs: clarify team API token and certificate behavior (#3317) 2026-09-01 13:54:57 +10:00
Catalin Pit 937e9376d8 fix: enlarge envelope field resize handle hit area (#3315) 2026-09-01 13:54:20 +10:00
David Nguyen eba71d9be4 fix: prevent dialogs from closing on blur (#3314) 2026-09-01 13:17:48 +10:00
Konrad 5082b475a0 chore: update Polish translation (#3316) 2026-08-28 17:08:53 +10:00
Lucas Smith 4285c88f12 chore: add provider request template (#3311) 2026-08-27 11:25:37 +10:00
Lucas Smith ae4b56dd88 feat: optimise posthog usage and add error tracking (#3301)
Disable autocapture and person profiles for anonymous users, and
redact signing tokens from captured URLs.

Manually wire exception capture into the signing, editor and embed
flows with recipient/envelope context for debugging recipient-reported
issues.

Track activation events (webhooks, api tokens, direct links, embed
sessions) server-side with org attribution, and drop client events
already tracked in-app.
2026-08-27 11:11:51 +10:00
Arunendra Tripathi ee8360585b fix: render adjacent email template variables correctly (#3153) 2026-08-26 11:57:35 +10:00
David Nguyen 9dc83bdb06 fix: allow zooming and dragging uploaded signatures (#3173) 2026-08-26 11:21:40 +10:00
github-actions[bot] e532843aa6 chore: extract translations (#3171) 2026-08-25 21:37:33 +10:00
Ephraim Duncan dd08da7ea7 refactor(trpc): use ts-pattern for envelope document-data resolution (#3222) 2026-08-25 18:29:24 +10:00
Catalin Pit 10f2b80025 feat: last used column for api tokens (#3230) 2026-08-25 18:07:31 +10:00
David Nguyen 73d58bdf31 feat: allow admin recipient search (#3277) 2026-08-25 11:20:11 +10:00
water 1de57a9e43 fix(emails): remove trailing period from document pending email subject (#3213) 2026-08-24 17:05:43 +10:00
Rodrigo Flávio 7f11c163fe fix(i18n): add plural forms for command menu results and item counts (#3207) (#3262) 2026-08-24 16:59:37 +10:00
Rodrigo Flávio 5beb57dbf0 fix(i18n): add Plural support for bulk download limit alert (#3209) (#3260) 2026-08-24 16:53:01 +10:00
misinierijon4-debug ead3104896 fix: add type button to multiselect remove and clear buttons (#3256) 2026-08-24 16:22:48 +10:00
Lucas Smith 75330166cc v2.17.0 2026-08-19 20:34:18 +10:00
572 changed files with 47188 additions and 9248 deletions
+2
View File
@@ -80,6 +80,8 @@ NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Reason to embed in PDF signatures. Defaults to "Signed by Documenso".
NEXT_PRIVATE_SIGNING_REASON=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
@@ -0,0 +1,61 @@
name: 'One-Click Deploy Provider Request'
description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README
title: 'One-Click Deploy Provider Request: [Provider Name]'
labels: ['deploy-provider-request']
body:
- type: markdown
attributes:
value: |
Thanks for your interest in adding a one-click deploy option for Documenso!
Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest.
**How this works:**
- 👍 this issue if you'd like to see Documenso deployable on this provider.
- If community interest is high enough, we'll consider adding it to the README.
- Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed.
- type: input
attributes:
label: Provider Name
placeholder: e.g. Railway
validations:
required: true
- type: input
attributes:
label: Provider Website
placeholder: e.g. https://railway.com
validations:
required: true
- type: input
attributes:
label: Deploy/Template URL
description: A link to an existing deployment template or deploy button URL, if one exists.
- type: dropdown
attributes:
label: Who creates and maintains the deployment template?
options:
- The provider
- Me / the community
- Nobody yet
validations:
required: true
- type: textarea
attributes:
label: Testing & Maintenance
description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)?
validations:
required: true
- type: textarea
attributes:
label: Why this provider?
description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc.
validations:
required: true
- type: checkboxes
attributes:
label: Please check the boxes that apply to this request.
options:
- label: I have searched existing issues to make sure this provider has not already been requested.
- label: I understand that inclusion depends on community interest and is not guaranteed.
- label: I understand that PRs adding deploy badges without a prior issue will be closed.
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Setup node'
inputs:
node_version:
required: false
default: v22.x
default: v24.x
runs:
using: 'composite'
+31 -15
View File
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js (v22 or above)
- Node.js (v24 or above)
- Postgres SQL Database
- Docker (optional)
@@ -186,21 +186,37 @@ For full instructions, requirements, and configuration details, see the [Self Ho
### One-Click Deploys
#### Railway
> [!NOTE]
> Want to see another provider listed here? Please [open a provider request](https://github.com/documenso/documenso/issues/new?template=deploy-provider-request.yml) instead of a PR so the community can signal interest. PRs adding deploy badges without a prior issue will be closed.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
#### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
#### Elestio
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso)
<table>
<tr>
<td align="center" width="200">
<a href="https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40" />
</a>
</td>
<td align="center" width="200">
<a href="https://render.com/deploy?repo=https://github.com/documenso/documenso">
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40" />
</a>
</td>
<td align="center" width="200">
<a href="https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile">
<img src="https://www.koyeb.com/static/images/deploy/button.svg" alt="Deploy to Koyeb" height="40" />
</a>
</td>
</tr>
<tr>
<td align="center" width="200">
<a href="https://elest.io/open-source/documenso">
<img src="https://elest.io/images/logos/deploy-to-elestio-btn.png" alt="Deploy on Elestio" height="40" />
</a>
</td>
<td align="center" width="200"></td>
<td align="center" width="200"></td>
</tr>
</table>
## Security
@@ -15,6 +15,8 @@ This guide provides a comprehensive troubleshooting matrix for the standard erro
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
| `MISSING_ENV_VAR` | A required environment variable is not configured on the server (500). | Primarily affects self-hosted instances: set the environment variable named in the error message and restart. On Documenso Cloud, contact support. |
| `MISSING_SIGNATURE_FIELD` | A signer has no signature field placed on the document (400). Returned when distributing an envelope. | Add at least one signature field for every recipient with a signing role before calling `/envelope/distribute`. |
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
@@ -37,7 +39,28 @@ The following errors occur when attempting to perform actions on an envelope tha
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
| `ENVELOPE_CANCELLED` | The action cannot be performed because the envelope was cancelled (400). | Create a new envelope if you need to restart the signing process. |
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
| `ENVELOPE_TSP_LOCKED` | An AES/QES envelope cannot be modified after it leaves the draft state (400). | Make changes while the envelope is in `DRAFT`, or create a new envelope. |
## CSC Signing Errors
These errors apply to Cloud Signature Consortium (CSC) signing flows.
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `CSC_INSTANCE_MODE_MISMATCH` | The requested signature level does not match the instance's CSC mode (400). | Use the signature level supported by the instance's signing configuration. |
| `CSC_UNLICENSED` | CSC signing is not licensed for this instance (403). | Enable the CSC signing license before retrying. |
| `CSC_PROVIDER_INFO_FAILED` | The CSC provider's discovery request failed or returned unusable information (500). | Check the provider URL, availability, and OAuth configuration. |
| `CSC_PROVIDER_NO_TSA` | A timestamp authority is unavailable or unusable for CSC signing (500). | Configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` and verify provider timestamp access. |
| `CSC_CREDENTIAL_LIST_EMPTY` | The CSC provider returned no signing credentials for the authenticated user (400). | Enrol a signing credential with the provider, then authenticate again. |
| `CSC_CERT_INVALID` | The selected signing certificate is missing, invalid, or outside its validity period (400). | Select or renew a valid certificate, then authenticate again. |
| `CSC_ALGORITHM_REFUSED` | The signing credential uses an unsupported key or digest algorithm (400). | Select a credential that satisfies the instance's CSC algorithm policy. |
| `CSC_SAD_EXPIRED_PRE_SIGN` | The signature activation data is missing, expired, or unreadable before signing (400). | Repeat the credential authorization flow. |
| `CSC_TSP_TIMEOUT` | The trust service provider did not complete the signing request before the timeout (408). | Retry the signing request after checking provider availability. |
| `CSC_EMBED_FAILED` | The returned CSC signature could not be embedded into the envelope items (400). | Restart the signing attempt. If it fails again, contact support. |
| `CSC_BASE_DOCUMENT_MUTATED` | The document changed between signature preparation and signing (500). | Restart signing from the current envelope state. |
| `CSC_REQUEST_FAILED` | A CSC provider request failed without a more specific CSC error (500). | Check provider availability and configuration, then retry. |
## See Also
+193 -88
View File
@@ -6,6 +6,8 @@ description: Add signature and form fields to documents via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -19,13 +21,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| `secondaryId` | string | Secondary identifier for audit logs |
| `type` | string | Field type (see [Field Types](#field-types)) |
| `recipientId` | number | ID of the recipient assigned to this field |
| `envelopeId` | number | ID of the parent envelope |
| `envelopeId` | string | ID of the parent envelope |
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
| `page` | number | Page number (1-indexed) |
| `positionX` | number | X coordinate as percentage (0-100) |
| `positionY` | number | Y coordinate as percentage (0-100) |
| `width` | number | Width as percentage of page (0-100) |
| `height` | number | Height as percentage of page (0-100) |
| `positionX` | string | X coordinate as percentage (0-100), a decimal serialized as a string |
| `positionY` | string | Y coordinate as percentage (0-100), a decimal serialized as a string |
| `width` | string | Width as percentage of page (0-100), a decimal serialized as a string |
| `height` | string | Height as percentage of page (0-100), a decimal serialized as a string |
| `customText` | string | Value entered by the recipient |
| `inserted` | boolean | Whether the field has been completed |
| `fieldMeta` | object \| null | Type-specific configuration options |
@@ -38,18 +40,19 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
"secondaryId": "field_abc123",
"type": "SIGNATURE",
"recipientId": 123,
"envelopeId": 789,
"envelopeItemId": "envelope_item_xyz",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"required": true
"required": true,
"overflow": "auto"
}
}
```
@@ -61,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| Type | Description | Auto-filled |
| ---------------- | ----------------------------------------- | ----------- |
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No |
| `INITIALS` | Recipient's initials | No |
| `NAME` | Recipient's full name | Yes |
| `EMAIL` | Recipient's email address | Yes |
@@ -134,10 +137,12 @@ POST /envelope/field/create-many
### Request Body
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------- |
| `documentId`| number | Yes | The document ID |
| `fields` | array | Yes | Array of field configurations |
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------------- |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field configurations |
Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`.
### Code Examples
@@ -148,32 +153,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"documentId": 123,
"fields": [
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"type": "SIGNATURE",
"recipientId": 456,
"pageNumber": 1,
"pageX": 10,
"pageY": 80,
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
},
{
"type": "DATE",
"recipientId": 456,
"pageNumber": 1,
"pageX": 50,
"pageY": 80,
"page": 1,
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
},
{
"type": "TEXT",
"recipientId": 456,
"pageNumber": 1,
"pageX": 10,
"pageY": 70,
"page": 1,
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4,
"fieldMeta": {
@@ -199,32 +204,32 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: 123,
fields: [
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
type: 'SIGNATURE',
recipientId: 456,
pageNumber: 1,
pageX: 10,
pageY: 80,
page: 1,
positionX: 10,
positionY: 80,
width: 30,
height: 5,
},
{
type: 'DATE',
recipientId: 456,
pageNumber: 1,
pageX: 50,
pageY: 80,
page: 1,
positionX: 50,
positionY: 80,
width: 20,
height: 3,
},
{
type: 'TEXT',
recipientId: 456,
pageNumber: 1,
pageX: 10,
pageY: 70,
page: 1,
positionX: 10,
positionY: 70,
width: 40,
height: 4,
fieldMeta: {
@@ -239,8 +244,8 @@ const response = await fetch(
}
);
const { fields } = await response.json();
console.log(`Created ${fields.length} fields`);
const { data } = await response.json();
console.log(`Created ${data.length} fields`);
````
</Tab>
@@ -250,36 +255,68 @@ console.log(`Created ${fields.length} fields`);
```json
{
"fields": [
"data": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
"positionX": "50",
"positionY": "80",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
},
{
"id": 103,
"secondaryId": "field_ghi789",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "TEXT",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4
"positionX": "10",
"positionY": "70",
"width": "40",
"height": "4",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "text",
"label": "Job Title",
"placeholder": "Enter your job title",
"required": true
}
}
]
}
@@ -299,8 +336,10 @@ POST /envelope/field/update-many
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------- |
| `documentId` | number | Yes | The document ID |
| `fields` | array | Yes | Array of field update objects |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field update objects |
Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates.
### Code Examples
@@ -311,17 +350,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"documentId": 123,
"fields": [
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"id": 101,
"type": "SIGNATURE",
"pageY": 85
"positionY": 85
},
{
"id": 102,
"type": "DATE",
"pageY": 85
"positionY": 85
}
]
}'
@@ -338,16 +377,16 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: 123,
fields: [
{ id: 101, type: 'SIGNATURE', pageY: 85 },
{ id: 102, type: 'DATE', pageY: 85 },
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{ id: 101, type: 'SIGNATURE', positionY: 85 },
{ id: 102, type: 'DATE', positionY: 85 },
],
}),
}
);
const { fields } = await response.json();
const { data } = await response.json();
````
</Tab>
@@ -357,9 +396,48 @@ const { fields } = await response.json();
```json
{
"fields": [
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
{ "id": 102, "type": "DATE", "positionY": 85 }
"data": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "85",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": "50",
"positionY": "85",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
}
]
}
````
@@ -443,8 +521,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
(0,0) ─────────────────────────── (100,0)
│ │
│ ┌─────────┐ │
│ │ Field │ (pageX: 10, │
│ │ │ pageY: 20, │
│ │ Field │ (positionX: 10, │
│ │ │ positionY: 20, │
│ └─────────┘ width: 30, │
│ height: 5) │
│ │
@@ -457,9 +535,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
const field = {
type: 'SIGNATURE',
recipientId: 123,
pageNumber: 1,
pageX: 60, // 60% from left
pageY: 85, // 85% from top (near bottom)
page: 1,
positionX: 60, // 60% from left
positionY: 85, // 85% from top (near bottom)
width: 30, // 30% of page width
height: 8, // 8% of page height
};
@@ -479,6 +557,33 @@ This approach is useful when generating PDFs programmatically or using templates
See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options.
### Placeholder Positioning via the API
`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass:
| Field | Type | Required | Description |
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. |
| `width` | number | No | Override the field width. Defaults to the width of the matched text. |
| `height` | number | No | Override the field height. Defaults to the height of the matched text. |
| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. |
```json
{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"type": "SIGNATURE",
"recipientId": 456,
"placeholder": "{{signature}}",
"matchAll": true
}
]
}
```
`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only.
---
## Field Meta Options
@@ -643,15 +748,15 @@ All field types support these base options:
Create a document with a signature block containing multiple field types:
```typescript
async function addSignatureBlock(documentId: number, recipientId: number) {
const fields = [
async function addSignatureBlock(envelopeId: string, recipientId: number) {
const data = [
// Signature
{
type: 'SIGNATURE',
recipientId,
pageNumber: 1,
pageX: 10,
pageY: 80,
page: 1,
positionX: 10,
positionY: 80,
width: 30,
height: 8,
fieldMeta: {
@@ -663,9 +768,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'NAME',
recipientId,
pageNumber: 1,
pageX: 10,
pageY: 90,
page: 1,
positionX: 10,
positionY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -677,9 +782,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'DATE',
recipientId,
pageNumber: 1,
pageX: 50,
pageY: 80,
page: 1,
positionX: 50,
positionY: 80,
width: 20,
height: 4,
fieldMeta: {
@@ -691,9 +796,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'TEXT',
recipientId,
pageNumber: 1,
pageX: 50,
pageY: 90,
page: 1,
positionX: 50,
positionY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -710,7 +815,7 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ documentId, fields }),
body: JSON.stringify({ envelopeId, data }),
});
return response.json();
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
href="/docs/developers/api/templates"
/>
<Card
title="Teams"
description="Manage teams and team members."
title="Team-scoped access"
description="Use team-scoped API tokens with envelope endpoints."
href="/docs/developers/api/teams"
/>
</Cards>
@@ -6,6 +6,8 @@ description: Add and manage envelope recipients via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -16,7 +18,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
```json
{
"id": 123,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -134,7 +136,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"email": "signer@example.com",
@@ -164,7 +166,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'signer@example.com',
@@ -196,7 +198,7 @@ const { data: recipients } = await response.json();
"data": [
{
"id": 789,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -209,7 +211,7 @@ const { data: recipients } = await response.json();
},
{
"id": 790,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "approver@example.com",
"name": "Jane Smith",
"role": "APPROVER",
@@ -262,7 +264,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"id": 789,
@@ -284,7 +286,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
id: 789,
@@ -387,7 +389,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'approver@example.com',
@@ -462,7 +464,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'signer@example.com',
+34 -47
View File
@@ -1,44 +1,29 @@
---
title: Teams API
description: Manage team resources, documents, and templates with team-scoped API tokens.
title: Team-Scoped API Access
description: Use team-scoped API tokens with document and template envelopes.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Team Object
## Team Context
A team object contains the following properties:
<Callout type="info">
The V2 REST API does not expose `/team/*` endpoints. Create and manage teams, members, and team
settings in the Documenso web application. This page explains how a team-scoped token applies
that team context to supported API resources.
</Callout>
| Property | Type | Description |
| ----------------- | -------------- | --------------------------------------------------- |
| `id` | number | Unique team identifier |
| `name` | string | Team display name |
| `url` | string | Unique team URL slug |
| `createdAt` | string | ISO 8601 timestamp |
| `avatarImageId` | string \| null | ID of the team's avatar image |
| `organisationId` | string | ID of the parent organisation |
| `currentTeamRole` | string | Your role in the team: `ADMIN`, `MANAGER`, `MEMBER` |
### Example Team Object
```json
{
"id": 123,
"name": "Engineering",
"url": "engineering",
"createdAt": "2025-01-15T10:30:00.000Z",
"avatarImageId": null,
"organisationId": "org_abc123",
"currentTeamRole": "ADMIN"
}
```
The API resolves the team from your token. You do not pass a team ID when creating, listing, or
using envelopes. The token's team ID determines which resources the request can access.
## Team-Scoped API Tokens
@@ -95,7 +80,7 @@ Documents created with a team token belong to that team:
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "DOCUMENT",
@@ -156,26 +141,26 @@ Retrieve all documents belonging to the team:
<Tab value="curl">
```bash
# List all team documents
curl -X GET "https://app.documenso.com/api/v2/envelope" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
},
});
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} team documents`);
const { data, count } = await response.json();
console.log(`Found ${count} team documents`);
````
</Tab>
@@ -190,10 +175,11 @@ Templates created with a team token are shared across the team.
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/template/create" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "TEMPLATE",
"title": "NDA Template",
"recipients": [
{
@@ -223,6 +209,7 @@ curl -X POST "https://app.documenso.com/api/v2/template/create" \
const form = new FormData();
const payload = {
type: 'TEMPLATE',
title: 'NDA Template',
recipients: [
{
@@ -249,7 +236,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
contentType: 'application/pdf',
});
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
method: 'POST',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -257,8 +244,8 @@ const response = await fetch('https://app.documenso.com/api/v2/template/create',
body: form,
});
const template = await response.json();
console.log('Created team template:', template.id);
const { id } = await response.json();
console.log('Created team template envelope:', id);
````
</Tab>
</Tabs>
@@ -268,14 +255,14 @@ console.log('Created team template:', template.id);
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/template', {
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -330,19 +317,19 @@ const SALES_TEAM_TOKEN = process.env.SALES_TEAM_API_TOKEN;
const LEGAL_TEAM_TOKEN = process.env.LEGAL_TEAM_API_TOKEN;
// Get pending documents from sales team
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=PENDING', {
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING', {
headers: { Authorization: SALES_TEAM_TOKEN },
});
const salesDocs = await salesResponse.json();
// Get completed documents from legal team
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', {
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=COMPLETED', {
headers: { Authorization: LEGAL_TEAM_TOKEN },
});
const legalDocs = await legalResponse.json();
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
console.log(`Sales team: ${salesDocs.count} pending`);
console.log(`Legal team: ${legalDocs.count} completed`);
```
## Error Responses
@@ -13,7 +13,194 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Template Object
## Use a Template Envelope
New integrations should create a document from a template envelope with the Envelope API.
```
POST /envelope/use
Content-Type: multipart/form-data
```
The request uses `multipart/form-data`:
| Part | Type | Required | Description |
| --------- | ------- | -------- | ------------------------------------------------------------------ |
| `payload` | JSON | Yes | Template envelope ID, recipient details, and document settings |
| `files` | File(s) | No | Replacement PDFs referenced by entries in `customDocumentData` |
### Payload Schema
| Field | Type | Required | Description |
| -------------------- | ------- | -------- | ------------------------------------------------------------------------ |
| `envelopeId` | string | Yes | ID of the template envelope |
| `externalId` | string | No | Your identifier for the created document envelope |
| `recipients` | array | No | Recipient details mapped to recipients in the template |
| `distributeDocument` | boolean | No | If `true`, create the document as pending and distribute it |
| `customDocumentData` | array | No | Maps uploaded replacement PDFs to template envelope items |
| `folderId` | string | No | Folder in which to create the document |
| `prefillFields` | array | No | Field values to prefill before distribution |
| `override` | object | No | Template values to override for the created document |
| `attachments` | array | No | Link attachments to add to the document |
| `formValues` | object | No | PDF form values to apply |
Each recipient entry accepts the following fields:
| Field | Type | Required | Description |
| -------------- | ------- | -------- | -------------------------------------------- |
| `id` | number | Yes | Recipient ID from the template envelope |
| `email` | string | Yes | Recipient email address |
| `name` | string | No | Recipient display name |
| `signingOrder` | number | No | Recipient position in sequential signing |
Each `customDocumentData` entry maps an uploaded file to a template item:
| Field | Type | Required | Description |
| ---------------- | ---------------- | -------- | --------------------------------------------------------------- |
| `identifier` | string \| number | Yes | Uploaded filename or zero-based file index |
| `envelopeItemId` | string | Yes | Template envelope item whose PDF the uploaded file replaces |
### Code Examples
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/use" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-F 'payload={
"envelopeId": "envelope_template123",
"externalId": "contract-2025-001",
"recipients": [
{
"id": 1,
"email": "john.doe@example.com",
"name": "John Doe"
}
],
"prefillFields": [
{
"id": 101,
"type": "text",
"value": "Senior Software Engineer"
}
],
"distributeDocument": false
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const form = new FormData();
form.append(
'payload',
JSON.stringify({
envelopeId: 'envelope_template123',
externalId: 'contract-2025-001',
recipients: [
{
id: 1,
email: 'john.doe@example.com',
name: 'John Doe',
},
],
prefillFields: [
{
id: 101,
type: 'text',
value: 'Senior Software Engineer',
},
],
distributeDocument: false,
}),
);
const response = await fetch('https://app.documenso.com/api/v2/envelope/use', {
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
body: form,
});
const document = await response.json();
console.log('Created document envelope:', document.id);
```
</Tab>
</Tabs>
### Response
```json
{
"id": "envelope_document123",
"recipients": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"token": "recipient_token",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/recipient_token"
}
]
}
```
### Distribute the Created Envelope
If you leave `distributeDocument` unset or set it to `false`, distribute the created document with
`POST /envelope/distribute`. Its response confirms delivery and includes each recipient's signing URL.
```typescript
const distributionResponse = await fetch(
'https://app.documenso.com/api/v2/envelope/distribute',
{
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.id,
}),
},
);
const distribution = await distributionResponse.json();
console.log('Signing URL:', distribution.recipients[0].signingUrl);
```
```json
{
"success": true,
"id": "envelope_document123",
"recipients": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"token": "recipient_token",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/recipient_token"
}
]
}
```
---
## Deprecated Template Endpoint Reference
<Callout type="warn">
Every `/template/*` endpoint below is deprecated. Use the Envelope API for new integrations and
follow [Migrating to Envelopes](/docs/developers/api/migrate-to-envelopes) to replace existing calls.
The legacy reference remains here to support migrations.
</Callout>
## Legacy Template Object
A template object contains the following properties:
@@ -91,7 +278,7 @@ A template object contains the following properties:
}
```
## List Templates
## List Templates (Deprecated)
Retrieve a paginated list of templates.
@@ -139,8 +326,8 @@ const response = await fetch(`${BASE_URL}/template`, {
},
});
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} templates`);
const { data, count } = await response.json();
console.log(`Found ${count} templates`);
// Filter by type
const privateResponse = await fetch(
@@ -181,18 +368,16 @@ const privateTemplates = await privateResponse.json();
]
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 3,
"totalItems": 25
}
"count": 25,
"currentPage": 1,
"perPage": 10,
"totalPages": 3
}
```
---
## Get Template
## Get Template (Deprecated)
Retrieve a single template by ID.
@@ -238,9 +423,9 @@ Returns the full template object including recipients, fields, and metadata.
---
## Create Document from Template
## Create Document from Template (Deprecated)
Create a new document using a template. This is the primary way to use templates programmatically.
Create a new document using the deprecated template endpoint.
<Callout type="info">
This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning.
@@ -415,32 +600,57 @@ const prefilledDocument = await prefillResponse.json();
### Response
Returns the created document object with recipients and signing URLs.
The endpoint returns the full legacy document object. The selected fields below show both the numeric
legacy `id` and canonical `envelopeId`. Recipient entries do not include a `signingUrl`.
```json
{
"id": "envelope_xyz789",
"type": "DOCUMENT",
"id": 789,
"envelopeId": "envelope_xyz789",
"status": "PENDING",
"title": "Employment Contract",
"source": "TEMPLATE",
"title": "Employment Contract",
"externalId": "contract-2025-001",
"recipients": [
{
"id": 1,
"envelopeId": "envelope_xyz789",
"documentId": 789,
"templateId": null,
"email": "john.doe@example.com",
"name": "John Doe",
"role": "SIGNER",
"signingStatus": "NOT_SIGNED",
"signingUrl": "https://app.documenso.com/sign/abc123"
"signingOrder": 1
}
]
}
````
```
To send a document created with `distributeDocument: false` and receive signing links, call
`POST /envelope/distribute` with its `envelopeId`:
```typescript
const document = await response.json();
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.envelopeId,
}),
});
const distribution = await distributionResponse.json();
console.log('Signing URL:', distribution.recipients[0].signingUrl);
```
---
## Override Template Settings
## Override Template Settings (Deprecated)
When creating a document from a template, you can override various settings:
@@ -488,7 +698,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Prefill Fields
## Prefill Fields (Deprecated)
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
@@ -577,7 +787,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Update Template
## Update Template (Deprecated)
Update a template's properties.
@@ -643,7 +853,7 @@ const template = await response.json();
---
## Duplicate Template
## Duplicate Template (Deprecated)
Create a copy of an existing template.
@@ -695,7 +905,7 @@ console.log('New template ID:', duplicatedTemplate.id);
---
## Delete Template
## Delete Template (Deprecated)
Delete a template.
@@ -754,7 +964,7 @@ const { success } = await response.json();
---
## Direct Link Templates
## Direct Link Templates (Deprecated)
Direct link templates allow recipients to create and sign documents without requiring you to explicitly create each document. When a recipient visits the direct link, a new document is automatically created from the template.
@@ -898,7 +1108,7 @@ const { success } = await response.json();
---
## Custom Document Data
## Custom Document Data (Deprecated)
When creating a document from a template, you can replace the template's PDF with a custom PDF by using the `customDocumentData` parameter. This is useful when you need to generate the PDF dynamically while reusing the template's recipient and field configuration.
@@ -913,7 +1123,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Template Types
## Template Types (Legacy)
| Type | Description |
| --------- | ------------------------------------------------------------------ |
@@ -922,7 +1132,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Complete Example: Contract Workflow
## Complete Legacy Example: Contract Workflow (Deprecated)
This example demonstrates a complete workflow for using templates to send contracts.
@@ -996,16 +1206,29 @@ async function sendEmploymentContract(employeeData: {
subject: `Employment Contract for ${employeeData.name}`,
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
},
distributeDocument: true,
distributeDocument: false,
externalId: `emp-contract-${Date.now()}`,
}),
});
const document = await documentResponse.json();
// 5. Distribute the envelope and get recipient signing links
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.envelopeId,
}),
});
const distribution = await distributionResponse.json();
return {
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
envelopeId: document.envelopeId,
signingUrl: distribution.recipients[0].signingUrl,
};
}
@@ -1018,7 +1241,7 @@ const result = await sendEmploymentContract({
startDate: '2025-03-01',
});
console.log('Document created:', result.documentId);
console.log('Document created:', result.envelopeId);
console.log('Signing URL:', result.signingUrl);
````
@@ -120,20 +120,25 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin
### Component Classes
| Class | Description |
| --------------------------------- | --------------------------------------------- |
| `.embed--Root` | Main container for the embedded experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
| `.embed--DocumentCompleted` | Completion screen after signing |
| `.field--FieldRootContainer` | Base container for document fields |
| Class | Description |
| ---------------------------------------- | --------------------------------------------- |
| `.embed--Root` | Main container for the embedded experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
| `.embed--DocumentCompleted` | Completion screen after signing |
| `.embed--DocumentCompletedCard` | Signature card on the completion screen |
| `.embed--DocumentCompletedTitle` | Title on the completion screen |
| `.embed--DocumentCompletedStatus` | Status line on the completion screen |
| `.embed--DocumentCompletedDescription` | Description text on the completion screen |
| `.embed--DocumentRejected` | Rejection screen after rejecting the document |
| `.field--FieldRootContainer` | Base container for document fields |
### Field Data Attributes
@@ -51,6 +51,7 @@ async function createAndSendDocument(
pdfBuffer: Buffer,
filename: string,
title: string,
externalId: string,
recipients: Recipient[],
): Promise<CreateAndSendResult> {
const recipientPayload = recipients.map((recipient, index) => ({
@@ -89,6 +90,7 @@ async function createAndSendDocument(
JSON.stringify({
type: 'DOCUMENT',
title,
externalId,
recipients: recipientPayload,
meta: {
subject: `Please sign: ${title}`,
@@ -145,6 +147,7 @@ const result = await createAndSendDocument(
pdfBuffer,
'contract.pdf',
'Service Agreement',
'nda-contract-ndac214',
[
{ email: 'client@example.com', name: 'John Smith', role: 'SIGNER' },
{ email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' },
@@ -172,6 +175,7 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \
-F 'payload={
"type": "DOCUMENT",
"title": "Service Agreement",
"externalId": "nda-contract-ndac214",
"recipients": [
{
"email": "client@example.com",
@@ -241,6 +245,8 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}'
</Tab>
</Tabs>
`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents).
---
## Workflow 2: Create Document from Template with Custom Data
@@ -256,8 +262,11 @@ Use templates for repeatable document workflows. This example creates an employm
Map template fields by label and build a <code>prefillFields</code> array
</Step>
<Step>
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
<code>distributeDocument: true</code>
Call <code>POST /template/use</code> with recipients and prefill data
</Step>
<Step>
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
links
</Step>
</Steps>
@@ -291,7 +300,7 @@ type TemplateRecipient = {
async function sendEmploymentContract(
templateId: number,
employee: EmployeeData,
): Promise<{ documentId: string; signingUrl: string }> {
): Promise<{ documentId: number; signingUrl: string }> {
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
headers: { Authorization: API_TOKEN },
});
@@ -368,7 +377,6 @@ async function sendEmploymentContract(
subject: `Your Employment Contract at ${employee.department}`,
message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`,
},
distributeDocument: true,
externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`,
}),
});
@@ -380,9 +388,25 @@ async function sendEmploymentContract(
const document = await createResponse.json();
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: document.envelopeId }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
throw new Error(`Failed to send document: ${error.message}`);
}
const distributeResult = await distributeResponse.json();
return {
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
signingUrl: distributeResult.recipients[0].signingUrl,
};
}
@@ -447,12 +471,17 @@ RESPONSE=$(curl -s -X POST "${BASE_URL}/template/use" \
\"subject\": \"Your Employment Contract\",
\"message\": \"Please review and sign your employment contract.\"
},
\"distributeDocument\": true,
\"externalId\": \"emp-$(date +%s)-alice\"
}")
echo "Document created:"
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
echo "Document created: $(echo $RESPONSE | jq -r '.id')"
echo "Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
````
</Tab>
@@ -470,8 +499,10 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
Fetch the template and get the signer recipient slot ID
</Step>
<Step>
For each recipient, call <code>POST /template/use</code> with{' '}
<code>distributeDocument: true</code>
For each recipient, call <code>POST /template/use</code>
</Step>
<Step>
Distribute each returned envelope via <code>POST /envelope/distribute</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
@@ -534,7 +565,6 @@ async function bulkSendFromTemplate(
recipients: [
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
],
distributeDocument: true,
externalId: `bulk-${Date.now()}-${recipient.email}`,
}),
});
@@ -545,10 +575,26 @@ async function bulkSendFromTemplate(
}
const document = await response.json();
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: document.envelopeId }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
throw new Error(error.message || 'Failed to distribute document');
}
const distributeResult = await distributeResponse.json();
return {
email: recipient.email,
envelopeId: document.id,
signingUrl: document.recipients[0].signingUrl,
envelopeId: document.envelopeId,
signingUrl: distributeResult.recipients[0].signingUrl,
};
}),
);
@@ -621,14 +667,24 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
\"email\": \"${EMAIL}\",
\"name\": \"${NAME}\"
}],
\"distributeDocument\": true,
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
}")
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
echo " Success: $(echo $RESPONSE | jq -r '.id')"
if echo $RESPONSE | jq -e '.envelopeId' > /dev/null 2>&1; then
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
if echo $DISTRIBUTE_RESPONSE | jq -e '.success' > /dev/null 2>&1; then
echo " Success: ${ENVELOPE_ID}"
echo " Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
else
echo " Failed to distribute: $(echo $DISTRIBUTE_RESPONSE | jq -r '.message')"
fi
else
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
fi
# Rate limiting delay
@@ -831,13 +887,15 @@ After a document is completed, download the signed PDF with all signatures embed
</Step>
</Steps>
The `version` query parameter accepts `original`, `pending`, or `signed`.
<Tabs items={['TypeScript', 'curl']}>
<Tab value="TypeScript">
```typescript
const API_TOKEN = process.env.DOCUMENSO_API_TOKEN;
const BASE_URL = 'https://app.documenso.com/api/v2';
type DownloadVersion = 'signed' | 'original';
type DownloadVersion = 'original' | 'pending' | 'signed';
async function downloadDocument(
envelopeId: string,
@@ -891,7 +949,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
{ headers: { Authorization: API_TOKEN } },
);
const { data, pagination } = await response.json();
const { data, count, currentPage, perPage, totalPages } = await response.json();
for (const envelope of data) {
try {
@@ -905,8 +963,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
}
hasMore = page < pagination.totalPages;
page++;
hasMore = currentPage < totalPages;
page = currentPage + 1;
}
}
@@ -24,20 +24,18 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
{/* prettier-ignore */}
<Steps>
<Step>
### Open settings
### Select a team
- Log in to your Documenso account
- Click your avatar in the top right corner
- Select **Settings** from the dropdown menu
![User dropdown menu](/public-api-images/documenso-user-dropdown-menu.webp)
Log in to Documenso and open the team that the integration should access. API tokens are scoped to a
team.
</Step>
<Step>
### Navigate to the API Tokens tab
### Open API Tokens
Go to **Settings** and open the **API Tokens** tab.
Go to **Team Settings** → **API Tokens**, or open
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
![API tokens page](/public-api-images/api-tokens-page-documenso.webp)
@@ -48,7 +46,7 @@ Go to **Settings** and open the **API Tokens** tab.
- Click **Create Token**
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
- Click **Create Token**
</Step>
@@ -75,21 +73,21 @@ Include the token in the `Authorization` header of your HTTP requests.
### cURL
```bash
curl https://app.documenso.com/api/v2/document \
curl https://app.documenso.com/api/v2/envelope \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
```
### JavaScript / TypeScript
```typescript
const response = await fetch('https://app.documenso.com/api/v2/document', {
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
method: 'GET',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
});
const documents = await response.json();
const envelopes = await response.json();
```
### Using the TypeScript SDK
@@ -129,7 +127,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
## Token Security
API tokens grant full access to your account. Follow these practices to keep them secure:
API tokens grant full API access to the team they were created for. Follow these practices to keep them secure:
- **Never commit tokens to version control.** Use environment variables instead.
- **Use descriptive names.** Names like `zapier-prod` or `backend-staging` help you identify token usage.
@@ -155,12 +153,11 @@ const client = new Documenso({
## Token Scope
API tokens have full access to your account, including:
API tokens have full API access to the team they were created for, including:
- Creating, reading, updating, and deleting documents
- Managing recipients and fields
- Accessing templates
- Managing team resources (if the token owner has team access)
There is currently no way to create tokens with limited scopes or permissions.
@@ -171,7 +168,7 @@ To revoke a token:
{/* prettier-ignore */}
<Steps>
<Step>
Go to **Settings** > **API Tokens**
Go to **Team Settings** → **API Tokens**
</Step>
<Step>
Find the token you want to revoke
@@ -194,7 +191,7 @@ Revoked tokens stop working immediately. Any integrations using that token will
</Accordion>
<Accordion title="401 Unauthorized — Expired token">Create a new token in settings.</Accordion>
<Accordion title="403 Forbidden — Token doesn't have access to the resource">
Ensure you're accessing resources owned by the token's account.
Ensure you're accessing resources owned by the token's team.
</Accordion>
</Accordions>
@@ -78,12 +78,10 @@ A successful response returns a list of your documents (envelopes):
"createdAt": "2025-01-15T10:30:00.000Z"
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 1,
"totalItems": 1
}
"count": 1,
"currentPage": 1,
"perPage": 10,
"totalPages": 1
}
````
@@ -228,9 +226,12 @@ After creating a document, it's in `DRAFT` status. To send it to recipients, use
<Tabs items={['curl', 'JavaScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
-H "Authorization: YOUR_API_TOKEN" \
-H "Content-Type: application/json"
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123"
}'
````
</Tab>
@@ -238,16 +239,14 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribu
```javascript
const envelopeId = 'envelope_abc123';
const response = await fetch(
`https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`,
{
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
);
body: JSON.stringify({ envelopeId }),
});
const data = await response.json();
console.log('Document sent:', data);
@@ -337,16 +336,14 @@ async function createAndSendDocument(pdfPath, recipientEmail, recipientName) {
console.log('Created envelope:', envelope.id);
// Step 2: Send the document for signing
const distributeResponse = await fetch(
`${BASE_URL}/envelope/${envelope.id}/distribute`,
{
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
}
);
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: envelope.id }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
@@ -422,9 +419,12 @@ echo "Created envelope: ${ENVELOPE_ID}"
# Step 2: Send the document for signing
echo "Sending document..."
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json"
-H "Content-Type: application/json" \
-d "{
\"envelopeId\": \"${ENVELOPE_ID}\"
}"
echo "Document sent for signing!"
@@ -441,7 +441,7 @@ The API returns standard HTTP status codes and JSON error responses:
| `400` | Bad request - check your request payload |
| `401` | Unauthorized - invalid or missing API token |
| `404` | Not found - resource doesn't exist |
| `429` | Rate limited - wait 60 seconds and retry |
| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,7 +485,9 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
The API has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically.
Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information.
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -493,8 +495,15 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
console.log('Rate limited, waiting 60 seconds...');
await new Promise((resolve) => setTimeout(resolve, 60000));
const retryAfter = response.headers.get('Retry-After');
if (!retryAfter) {
return response;
}
const retryAfterSeconds = Number.parseInt(retryAfter, 10);
console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`);
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
continue;
}
@@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| Variable | Description | Default |
| ------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
| `PORT` | Port the application listens on | `3000` |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — set to the volume-mount path (e.g. `/opt/documenso/cert.p12`). Only Docker Compose defaults this; plain `docker run` must set it explicitly | - |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
@@ -136,6 +136,7 @@ docker run -d \
-e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \
-e NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH="/opt/documenso/cert.p12" \
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \
-e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \
-e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \
@@ -154,6 +155,12 @@ A signing certificate is required for document signing. You have two options for
- **Volume mount** — mount a `.p12` file from the host into the container at `/opt/documenso/cert.p12` (shown above). This is the simplest approach for small to moderate deployments.
- **Base64-encoded contents** — set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` with the base64-encoded certificate string. Use this when file mounting is not available (e.g., Railway, Vercel).
<Callout type="warn">
Plain `docker run` deployments must set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` explicitly. This
prevents production deployments from accidentally using the insecure example certificate.
Docker Compose sets the file path for you.
</Callout>
For production deployments that require Adobe Approved Trust List recognition, consider using a [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) or another external HSM.
<Callout type="warn">
@@ -178,6 +185,7 @@ NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
@@ -203,6 +211,12 @@ docker run -d \
Documenso provides health check endpoints for monitoring:
<Callout type="info">
If a certificate is mounted but signing fails, ensure `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH`
explicitly points to its path inside the container. Production does not use the development
example certificate as a fallback.
</Callout>
| Endpoint | Purpose |
| ------------------------- | -------------------------------------------------------------- |
| `/api/health` | Checks database connectivity and certificate status |
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
## Prerequisites
- Node.js 22 or later
- npm 11 or later
- Node.js 24 or later
- npm 11.17 or later
- PostgreSQL 14 or later
- A Linux server (for systemd service setup)
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
| Requirement | Version |
| ----------- | ------- |
| Node.js | 22+ |
| npm | 11+ |
| Node.js | 24+ |
| npm | 11.17+ |
---
@@ -169,7 +169,7 @@ Documenso runs on:
| MySQL/MariaDB | PostgreSQL-specific features required |
| SQLite | Not suitable for production workloads |
| MongoDB | Relational database required |
| Node.js < 22 | Modern JavaScript features required |
| Node.js < 24 | Modern JavaScript features required |
---
@@ -34,7 +34,7 @@ To access the preferences, navigate to either the organisation or teams settings
| **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. |
| **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). |
| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). |
| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. |
| **Delegate Document Ownership** | By default, documents created with a team API token are owned by the user who created the token. Enable this setting to let supported API requests assign ownership to another team member. |
| **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. |
Document visibility, language, and signature settings can be overridden per document.
+1 -1
View File
@@ -15,7 +15,7 @@
"fumadocs-ui": "16.14.3",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.3.0",
"next": "^16.3.3",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
+9 -128
View File
@@ -1,142 +1,23 @@
/**
* Multi purpose CORS lib.
* Note: Based on the `cors` package in npm but using only web APIs.
* Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts
* Apply the public statistics API's wildcard CORS policy, without credentials.
*/
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise<StaticOrigin>;
interface CorsOptions {
origin?: StaticOrigin | OriginFn;
methods?: string | string[];
allowedHeaders?: string | string[];
exposedHeaders?: string | string[];
credentials?: boolean;
maxAge?: number;
preflightContinue?: boolean;
optionsSuccessStatus?: number;
}
const defaultOptions: CorsOptions = {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204,
};
function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
return Array.isArray(allowed)
? allowed.some((o) => isOriginAllowed(origin, o))
: typeof allowed === 'string'
? origin === allowed
: allowed instanceof RegExp
? allowed.test(origin)
: !!allowed;
}
function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
const headers = new Headers();
if (origin === '*') {
headers.set('Access-Control-Allow-Origin', '*');
} else if (typeof origin === 'string') {
headers.set('Access-Control-Allow-Origin', origin);
headers.append('Vary', 'Origin');
} else {
const allowed = isOriginAllowed(reqOrigin ?? '', origin);
if (allowed && reqOrigin) {
headers.set('Access-Control-Allow-Origin', reqOrigin);
}
headers.append('Vary', 'Origin');
}
return headers;
}
async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) {
const reqOrigin = req.headers.get('Origin') || undefined;
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
if (!value) {
return;
}
return getOriginHeaders(reqOrigin, value);
}
function getAllowedHeaders(req: Request, allowed?: string | string[]) {
const headers = new Headers();
if (!allowed) {
allowed = req.headers.get('Access-Control-Request-Headers')!;
headers.append('Vary', 'Access-Control-Request-Headers');
} else if (Array.isArray(allowed)) {
allowed = allowed.join(',');
}
if (allowed) {
headers.set('Access-Control-Allow-Headers', allowed);
}
return headers;
}
export default async function cors(req: Request, res: Response, options?: CorsOptions) {
const opts = { ...defaultOptions, ...options };
export default async function cors(req: Request, res: Response): Promise<Response> {
const { headers } = res;
const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
const mergeHeaders = (v: string, k: string) => {
if (k === 'Vary') {
headers.append(k, v);
} else {
headers.set(k, v);
}
};
headers.set('Access-Control-Allow-Origin', '*');
// If there's no origin we won't touch the response
if (!originHeaders) {
return res;
}
originHeaders.forEach(mergeHeaders);
if (opts.credentials) {
headers.set('Access-Control-Allow-Credentials', 'true');
}
const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(',') : opts.exposedHeaders;
if (exposed) {
headers.set('Access-Control-Expose-Headers', exposed);
}
// Handle the preflight request
if (req.method === 'OPTIONS') {
if (opts.methods) {
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
headers.set('Vary', 'Access-Control-Request-Headers');
headers.set('Access-Control-Allow-Methods', methods);
}
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders);
if (typeof opts.maxAge === 'number') {
headers.set('Access-Control-Max-Age', String(opts.maxAge));
}
if (opts.preflightContinue) {
return res;
if (allowedHeaders) {
headers.set('Access-Control-Allow-Headers', allowedHeaders);
}
headers.set('Content-Length', '0');
return new Response(null, { status: opts.optionsSuccessStatus, headers });
return new Response(null, { status: 204, headers });
}
// If we got here, it's a normal request
return res;
}
export function initCors(options?: CorsOptions) {
return async (req: Request, res: Response) => cors(req, res, options);
}
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.3.0"
"next": "^16.3.3"
},
"devDependencies": {
"@types/node": "^20",
-25
View File
@@ -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"]
-26
View File
@@ -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,8 +1,10 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getContentsMissingImages, resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content';
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -30,7 +32,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
@@ -71,6 +73,7 @@ export const EnvelopeDistributeDialog = ({
const { toast } = useToast();
const { t, i18n } = useLingui();
const navigate = useNavigate();
const analytics = useAnalytics();
const [isOpen, setIsOpen] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
@@ -160,6 +163,25 @@ export const EnvelopeDistributeDialog = ({
[envelope.fields],
);
/**
* Image related contents without an image render nothing once sent.
*/
const contentsMissingImages = useMemo(() => getContentsMissingImages(envelope.contents), [envelope.contents]);
/**
* An envelope can hold more contents than the organisation's plan allows,
* e.g. after the plan was lowered or a template was copied, so this is
* reported here rather than letting the send fail.
*/
const contentLimits = useMemo(
() =>
resolveEnvelopeContentLimits(
envelope.contents.map((content) => content.contentMeta.type),
organisation.organisationClaim,
),
[envelope.contents, organisation.organisationClaim],
);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -173,8 +195,26 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_REQUIRED_EMAIL';
}
if (contentsMissingImages.length > 0) {
return 'MISSING_CONTENT_IMAGES';
}
if (contentLimits.isContentLimitExceeded) {
return 'ENVELOPE_CONTENT_LIMIT_EXCEEDED';
}
if (contentLimits.isImageLimitExceeded) {
return 'ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED';
}
return null;
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
}, [
envelope.recipients,
recipientsMissingRequiredEmail,
recipientsMissingSignatureFields,
contentsMissingImages,
contentLimits,
]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -200,6 +240,12 @@ export const EnvelopeDistributeDialog = ({
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'distribute_document',
envelopeId: envelope.id,
});
const errorMessage = getDistributeErrorMessage(error.code);
toast({
@@ -512,6 +558,33 @@ export const EnvelopeDistributeDialog = ({
</ul>
</AlertDescription>
))
.with('MISSING_CONTENT_IMAGES', () => (
<AlertDescription>
<Plural
value={contentsMissingImages.length}
one="An image content has no image. Upload an image for it, or remove it."
other="# image contents have no image. Upload an image for each, or remove them."
/>
</AlertDescription>
))
.with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => (
<AlertDescription>
<Plural
value={contentLimits.contentLimit}
one="This envelope cannot have more than # content item. Remove some, or contact support if you need more."
other="This envelope cannot have more than # content items. Remove some, or contact support if you need more."
/>
</AlertDescription>
))
.with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => (
<AlertDescription>
<Plural
value={contentLimits.imageLimit}
one="This envelope cannot have more than # image content item. Remove some, or contact support if you need more."
other="This envelope cannot have more than # image content items. Remove some, or contact support if you need more."
/>
</AlertDescription>
))
.exhaustive()}
</Alert>
@@ -44,6 +44,7 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
defaultValues: {
includeRecipients: true,
includeFields: true,
includeContents: true,
},
});
@@ -67,13 +68,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
const { includeRecipients, includeFields, includeContents } = form.getValues();
try {
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
includeContents,
});
} catch {
toast({
@@ -159,6 +161,23 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</div>
)}
/>
<Controller
control={form.control}
name="includeContents"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeContents"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeContents">
<Trans>Include Contents</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
@@ -1,6 +1,6 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { trpc } from '@documenso/trpc/react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
description: t`The file is not a valid PDF or is password protected.`,
variant: 'destructive',
});
}
@@ -195,18 +195,6 @@ export const EnvelopeItemEditDialog = ({
}
}, [isOpen, form, envelopeItem.title]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Dialog {...props} open={isOpen} onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TEnvelope } from '@documenso/lib/types/envelope';
@@ -51,6 +52,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
const { toast } = useToast();
const { t, i18n } = useLingui();
const analytics = useAnalytics();
const [isOpen, setIsOpen] = useState(false);
@@ -95,6 +97,13 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'redistribute_document',
envelopeId: envelope.id,
});
const errorMessage = getDistributeErrorMessage(error.code);
toast({
@@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}
if (result.failedIds.length > 0) {
@@ -290,15 +290,16 @@ export const EnvelopesBulkDownloadDialog = ({
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
<Plural
value={MAX_BULK_DOWNLOAD_ENVELOPES}
one="You can download up to # document at a time. Deselect some documents to continue."
other="You can download up to # documents at a time. Deselect some documents to continue."
/>
</AlertDescription>
</Alert>
)}
<fieldset disabled={isDownloading} className="space-y-4">
<fieldset disabled={isDownloading} className="min-w-0 space-y-4">
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
<div className="divide-y divide-border rounded-lg border border-border">
{envelopes.map((envelope) => {
@@ -96,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}
await onSuccess?.(data.folderId);
@@ -1,4 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
@@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
@@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}
setOpen(value);
if (!value) {
setValidationError(null);
form.reset();
}
};
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
@@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};
const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);
try {
const csv = await values.file.text();
await uploadBulkSend({
const result = await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
if (!result.success) {
setValidationError(result.error);
return;
}
toast({
title: _(msg`Success`),
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
});
setOpen(false);
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
const error = AppError.parseError(err);
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
}
};
return (
<Dialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => onChange(null)}
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -218,6 +257,72 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
)}
/>
{validationError !== null && (
<Alert variant="destructive">
<AlertDescription className="max-h-32 overflow-y-auto">
{match(validationError)
.with({ type: 'PARSE_ERROR' }, () => (
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
))
.with({ type: 'EMPTY' }, () => (
<Trans>
The CSV does not contain any rows. Please add at least one row of recipient details.
</Trans>
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
<Plural value={rowCount} one="The CSV contains # row." other="The CSV contains # rows." />{' '}
<Plural
value={maxRows}
one="A maximum of # row is allowed per upload."
other="A maximum of # rows is allowed per upload."
/>
</Trans>
))
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
<>
<Trans>
The CSV is missing the following required columns. Please download the template CSV for the
correct format.
</Trans>
<ul className="mt-1 list-inside list-disc">
{missingColumns.map((column) => (
<li key={column} className="font-mono">
{column}
</li>
))}
</ul>
</>
))
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
<>
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
<ul className="mt-1 list-inside list-disc">
{rowErrors.map((rowError, index) => (
<li key={index}>
<Trans>
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
email or empty
</Trans>
</li>
))}
</ul>
</>
))
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
code === AppErrorCode.LIMIT_EXCEEDED ? (
<Trans>The CSV exceeds the maximum file size.</Trans>
) : (
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
),
)
.exhaustive()}
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="sendImmediately"
@@ -240,7 +345,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Trans>Cancel</Trans>
</Button>
@@ -6,8 +6,9 @@ import {
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import type { TUseEnvelopePayload } from '@documenso/trpc/server/envelope-router/use-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -23,6 +24,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -38,33 +40,70 @@ import { useNavigate } from 'react-router';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.array(
const DOCUMENT_NAME_SOURCE = {
TEMPLATE: 'template',
UPLOAD: 'upload',
CUSTOM: 'custom',
} as const;
const getUploadedDocumentTitle = (file: File) => {
return file.name.replace(/\.[^/.]+$/, '').trim();
};
/**
* Whether the file name can be used as a document title.
*/
const isUploadedFileNameUsable = (file?: File): file is File => {
if (!file) {
return false;
}
const title = getUploadedDocumentTitle(file);
return title.length > 0 && title.length <= DOCUMENT_TITLE_MAX_LENGTH;
};
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
includeContents: z.boolean().default(true),
useCustomDocument: z.boolean().default(false),
documentNameSource: z.enum([
DOCUMENT_NAME_SOURCE.TEMPLATE,
DOCUMENT_NAME_SOURCE.UPLOAD,
DOCUMENT_NAME_SOURCE.CUSTOM,
]),
customDocumentName: z
.string()
.trim()
.max(DOCUMENT_TITLE_MAX_LENGTH, { message: msg`Document name is too long`.id }),
customDocumentData: z
.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
)
.optional(),
recipients: z.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
),
})
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
message: msg`Document name is required`.id,
path: ['customDocumentName'],
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod;
@@ -77,7 +116,6 @@ export function TemplateUseDialog({
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
envelopeId,
templateId,
templateSigningOrder,
trigger,
}: TemplateUseDialogProps) {
@@ -87,6 +125,7 @@ export function TemplateUseDialog({
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [lastUploadedFile, setLastUploadedFile] = useState<File>();
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
@@ -105,7 +144,10 @@ export function TemplateUseDialog({
const generateDefaultFormValues = () => {
return {
distributeDocument: false,
includeContents: true,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
@@ -138,32 +180,66 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
const { mutateAsync: createDocumentFromTemplate } = trpc.envelope.use.useMutation();
/**
* Track the most recently uploaded file so its name can be used as the document name.
* Files with an unusable name are ignored, and the document name source is reset if
* no usable file remains.
*/
const updateLastUploadedFile = (file?: File) => {
const usableFile = isUploadedFileNameUsable(file) ? file : undefined;
setLastUploadedFile(usableFile);
if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) {
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
}
};
const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => {
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
return data.customDocumentName;
}
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) {
return getUploadedDocumentTitle(lastUploadedFile);
}
return undefined;
};
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
const customFilesToUpload = (data.customDocumentData || []).filter(
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
const documentTitle = getDocumentTitle(data);
const customFilesToUpload = (data.customDocumentData ?? []).filter(
(item): item is typeof item & { data: File } => item.data !== undefined,
);
const customDocumentData = await Promise.all(
customFilesToUpload.map(async (item) => {
const customDocumentData = await putPdfFile(item.data);
return {
documentDataId: customDocumentData.id,
envelopeItemId: item.envelopeItemId,
};
}),
);
const { envelopeId } = await createDocumentFromTemplate({
templateId,
// The files are sent alongside the payload and mapped to their envelope
// items by index, which is robust against duplicate file names.
const payload: TUseEnvelopePayload = {
envelopeId,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
});
includeContents: data.includeContents,
customDocumentData: customFilesToUpload.map((item, index) => ({
identifier: index,
envelopeItemId: item.envelopeItemId,
})),
...(documentTitle ? { override: { title: documentTitle } } : {}),
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const item of customFilesToUpload) {
formData.append('files', item.data);
}
const { id: createdEnvelopeId } = await createDocumentFromTemplate(formData);
toast({
title: _(msg`Document created`),
@@ -171,7 +247,7 @@ export function TemplateUseDialog({
duration: 5000,
});
let documentPath = `${documentRootPath}/${envelopeId}`;
let documentPath = `${documentRootPath}/${createdEnvelopeId}`;
if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) {
documentPath += '?action=view-signing-links';
@@ -195,9 +271,14 @@ export function TemplateUseDialog({
name: 'recipients',
});
const useCustomDocument = form.watch('useCustomDocument');
const documentNameSource = form.watch('documentNameSource');
const canUseUploadedDocumentName = Boolean(lastUploadedFile);
useEffect(() => {
if (open) {
form.reset(generateDefaultFormValues());
setLastUploadedFile(undefined);
}
}, [open, form]);
@@ -238,9 +319,9 @@ export function TemplateUseDialog({
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
<form className="min-w-0" onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full min-w-0 flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] w-full min-w-0 max-w-full space-y-4 overflow-y-auto overflow-x-hidden p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
@@ -388,6 +469,46 @@ export function TemplateUseDialog({
</div>
)}
<FormField
control={form.control}
name="includeContents"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="includeContents"
className="h-5 w-5"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<label
className="ml-2 flex items-center text-muted-foreground text-sm"
htmlFor="includeContents"
>
<Trans>Include contents</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Copy the text, shapes, images and other contents from the template onto the new
document.
</Trans>
</p>
<p>
<Trans>Uncheck this to create the document without them.</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="useCustomDocument"
@@ -401,7 +522,17 @@ export function TemplateUseDialog({
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
const customDocumentData = form.getValues('customDocumentData');
form.setValue(
'customDocumentData',
customDocumentData?.map((item) => ({
...item,
data: undefined,
})),
);
form.clearErrors('customDocumentData');
updateLastUploadedFile(undefined);
}
}}
/>
@@ -428,7 +559,7 @@ export function TemplateUseDialog({
)}
/>
{form.watch('useCustomDocument') && (
{useCustomDocument && (
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
@@ -443,7 +574,7 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
className="flex w-full min-w-0 items-center gap-4 overflow-hidden rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
@@ -451,13 +582,15 @@ export function TemplateUseDialog({
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
<div className="min-w-0 flex-1 overflow-hidden">
<h4 className="truncate font-medium text-foreground text-sm">
{field.value ? getUploadedDocumentTitle(field.value) : item.title}
</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
{field.value ? (
<div>
<span>
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
</div>
</span>
) : (
<Trans>Default file</Trans>
)}
@@ -475,6 +608,18 @@ export function TemplateUseDialog({
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
if (field.value === lastUploadedFile) {
// Fall back to any other uploaded file so the option stays available.
const remainingUploadedFile = form
.getValues('customDocumentData')
?.find(
(item) =>
item.data !== field.value && isUploadedFileNameUsable(item.data),
)?.data;
updateLastUploadedFile(remainingUploadedFile);
}
}}
>
<X className="mr-2 h-4 w-4" />
@@ -517,7 +662,7 @@ export function TemplateUseDialog({
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
form.setError(`customDocumentData.${i}.data`, {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
@@ -526,7 +671,7 @@ export function TemplateUseDialog({
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
form.setError(`customDocumentData.${i}.data`, {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
@@ -537,6 +682,8 @@ export function TemplateUseDialog({
}
field.onChange(file);
form.clearErrors(`customDocumentData.${i}.data`);
updateLastUploadedFile(file);
}}
/>
</div>
@@ -550,6 +697,112 @@ export function TemplateUseDialog({
)}
</div>
)}
<FormField
control={form.control}
name="documentNameSource"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document name</Trans>
</FormLabel>
<FormControl>
<RadioGroup
aria-label={_(msg`Document name`)}
value={field.value}
onValueChange={field.onChange}
className="space-y-2"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
<label className="text-sm" htmlFor="document-name-source-template">
<Trans>Use template name</Trans>
</label>
</div>
<div className="flex items-start gap-2">
<RadioGroupItem
id="document-name-source-upload"
value={DOCUMENT_NAME_SOURCE.UPLOAD}
disabled={!canUseUploadedDocumentName}
className="mt-0.5"
/>
<div className="min-w-0">
<div className="flex items-center gap-1">
<label
className={cn('text-sm', {
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
})}
htmlFor="document-name-source-upload"
>
<Trans>Use uploaded file name</Trans>
</label>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={_(msg`About uploaded file naming`)}
className="text-muted-foreground"
>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-xs">
<Trans>
The document name will use the most recently uploaded file name without its
extension.
</Trans>
</TooltipContent>
</Tooltip>
</div>
{lastUploadedFile && (
<p
className="max-w-sm truncate text-muted-foreground text-xs"
title={lastUploadedFile.name}
>
{lastUploadedFile.name}
</p>
)}
{!canUseUploadedDocumentName && (
<p className="text-muted-foreground text-xs">
<Trans>Upload a custom document to use its file name.</Trans>
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
<label className="text-sm" htmlFor="document-name-source-custom">
<Trans>Enter custom document name</Trans>
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
<FormField
control={form.control}
name="customDocumentName"
render={({ field }) => (
<FormItem className="ml-6">
<FormControl>
<Input
{...field}
aria-label={_(msg`Custom document name`)}
placeholder={_(msg`Enter a document name`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<DialogFooter className="mt-4">
@@ -18,6 +18,8 @@ import { useCallback, useRef } from 'react';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { useCspNonce } from '~/utils/nonce';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps {
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const cspNonce = useCspNonce();
const $sensorApi = useRef<SensorAPI | null>(null);
@@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
/>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -1,4 +1,5 @@
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { formatFileSize } from '@documenso/lib/universal/unit-convertions';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -98,16 +99,6 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
form.unregister('documentData');
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
@@ -77,6 +78,7 @@ export const EmbedDirectTemplateClientPage = ({
}: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [searchParams] = useSearchParams();
@@ -264,6 +266,13 @@ export const EmbedDirectTemplateClientPage = ({
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
analytics.captureException(err, {
source: 'embed',
location: 'direct_template',
recipientId: recipient.id,
envelopeId,
});
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
@@ -308,6 +317,14 @@ export const EmbedDirectTemplateClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
envelopeId,
});
setHasFinishedInit(true);
}
@@ -2,6 +2,7 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { Trans } from '@lingui/react/macro';
import type { Signature } from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
export type EmbedDocumentCompletedPageProps = {
name?: string;
@@ -10,12 +11,8 @@ export type EmbedDocumentCompletedPageProps = {
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="font-semibold text-2xl text-foreground">
<Trans>Document Completed!</Trans>
</h3>
<div className="mt-8 w-full max-w-md">
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center overflow-hidden p-6">
<div className="embed--DocumentCompletedCard w-full max-w-sm md:max-w-md">
<SigningCard3D
className="mx-auto w-full"
name={name || 'Documenso'}
@@ -24,10 +21,19 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
/>
</div>
<p className="mt-8 max-w-[50ch] text-center text-muted-foreground text-sm">
<Trans>
The document is now completed, please follow any instructions provided within the parent application.
</Trans>
<h2 className="embed--DocumentCompletedTitle mt-8 max-w-[35ch] text-center font-semibold text-2xl text-foreground leading-normal md:text-3xl">
<Trans>Document Completed</Trans>
</h2>
<div className="embed--DocumentCompletedStatus mt-4 flex items-center text-center text-documenso-700">
<CheckCircle2Icon className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>No further action is required</Trans>
</span>
</div>
<p className="embed--DocumentCompletedDescription mt-2.5 max-w-[50ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Please follow any instructions provided within the parent application.</Trans>
</p>
</div>
);
@@ -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 { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
@@ -54,6 +55,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
completedFields: DocumentField[];
metadata?: DocumentMeta | null;
isCompleted?: boolean;
isRejected?: boolean;
hidePoweredBy?: boolean;
allowWhitelabelling?: boolean;
allRecipients?: RecipientWithFields[];
@@ -69,19 +71,23 @@ export const EmbedSignDocumentV1ClientPage = ({
completedFields,
metadata,
isCompleted,
isRejected,
hidePoweredBy = false,
allowWhitelabelling = false,
allRecipients = [],
}: EmbedSignDocumentV1ClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
isRejected ?? recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
@@ -154,6 +160,14 @@ export const EmbedSignDocumentV1ClientPage = ({
setHasCompletedDocument(true);
} catch (err) {
analytics.captureException(err, {
source: 'embed',
location: 'complete_document',
recipientId: recipient.id,
documentId,
envelopeId,
});
if (window.parent) {
window.parent.postMessage(
{
@@ -236,6 +250,15 @@ export const EmbedSignDocumentV1ClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
documentId,
envelopeId,
});
setHasFinishedInit(true);
}
@@ -244,6 +267,44 @@ export const EmbedSignDocumentV1ClientPage = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!window.parent) {
return;
}
if (hasRejectedDocument) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token,
documentId,
recipientId: recipient.id,
},
},
'*',
);
return;
}
if (hasCompletedDocument) {
window.parent.postMessage(
{
action: 'document-completed',
data: {
token,
documentId,
recipientId: recipient.id,
},
},
'*',
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
window.parent.postMessage(
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
@@ -25,6 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
allowWhitelabelling = false,
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const analytics = useAnalytics();
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
useRequiredEnvelopeSigningContext();
@@ -38,12 +40,18 @@ export const EmbedSignDocumentV2ClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT && !!email);
// The signing provider's envelope data isn't refreshed on revalidation.
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(isRejected);
const onDocumentCompleted = (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
}) => {
setHasCompletedDocument(true);
if (window.parent) {
window.parent.postMessage(
{
@@ -110,6 +118,8 @@ export const EmbedSignDocumentV2ClientPage = ({
recipientId: number;
reason?: string;
}) => {
setHasRejectedDocument(true);
if (window.parent) {
window.parent.postMessage(
{
@@ -170,6 +180,14 @@ export const EmbedSignDocumentV2ClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
envelopeId: envelope.id,
});
setHasFinishedInit(true);
}
@@ -209,23 +227,26 @@ export const EmbedSignDocumentV2ClientPage = ({
}
}, [isRejected, envelope.id, recipient.id, recipient.token]);
if (isRejected) {
if (hasRejectedDocument) {
return <EmbedDocumentRejected />;
}
if (isCompleted) {
if (hasCompletedDocument) {
const completedSignature =
recipient.fields.find((field) => field.signature)?.signature ?? recipientSignature ?? null;
return (
<EmbedDocumentCompleted
name={fullName}
signature={
recipientSignature
completedSignature
? {
id: 1,
fieldId: 1,
recipientId: recipient.id,
created: new Date(),
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
typedSignature: recipientSignature.typedSignature,
signatureImageAsBase64: completedSignature.signatureImageAsBase64,
typedSignature: completedSignature.typedSignature,
}
: undefined
}
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -57,6 +58,7 @@ export const MultiSignDocumentSigningView = ({
}: MultiSignDocumentSigningViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
@@ -101,6 +103,13 @@ export const MultiSignDocumentSigningView = ({
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'sign_field',
recipientId,
documentId: document?.id,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
@@ -120,6 +129,13 @@ export const MultiSignDocumentSigningView = ({
}
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'remove_field',
recipientId,
documentId: document?.id,
});
}
};
@@ -140,6 +156,13 @@ export const MultiSignDocumentSigningView = ({
recipientId,
});
} catch (err) {
analytics.captureException(err, {
source: 'embed',
location: 'complete_document',
recipientId,
documentId: document?.id,
});
onDocumentError?.();
const error = AppError.parseError(err);
@@ -0,0 +1,158 @@
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { Trans } from '@lingui/react/macro';
import type React from 'react';
import { useState } from 'react';
import { type FieldValues, type Path, useFormContext } from 'react-hook-form';
import { z } from 'zod';
/**
* Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`.
*/
export const ZTwoFactorCodeFieldSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TTwoFactorCodeFieldSchema = z.infer<typeof ZTwoFactorCodeFieldSchema>;
export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode;
type TwoFactorMethod = 'totp' | 'backup';
export type TwoFactorCodeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isSubmitting?: boolean;
submitLabel: React.ReactNode;
/**
* Called when the user submits the code. Typically the parent form's submit handler.
*/
onSubmit: () => void;
};
/**
* Collects a TOTP or backup code on top of an existing form, mirroring the
* sign in and disable 2FA dialogs.
*
* Must be rendered inside a `<Form>` whose values include `totpCode` and `backupCode`.
*/
export const TwoFactorCodeDialog = <T extends FieldValues & TTwoFactorCodeFieldSchema>({
open,
onOpenChange,
isSubmitting,
submitLabel,
onSubmit,
}: TwoFactorCodeDialogProps) => {
const form = useFormContext<T>();
const [method, setMethod] = useState<TwoFactorMethod>('totp');
const totpCodeName = 'totpCode' as Path<T>;
const backupCodeName = 'backupCode' as Path<T>;
const onToggleMethod = () => {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod((current) => (current === 'totp' ? 'backup' : 'totp'));
};
const handleOpenChange = (value: boolean) => {
if (isSubmitting) {
return;
}
if (!value) {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod('totp');
}
onOpenChange(value);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Two-Factor Authentication</Trans>
</DialogTitle>
<DialogDescription>
{method === 'totp' ? (
<Trans>Enter the code from your authenticator app to continue.</Trans>
) : (
<Trans>Enter one of your backup codes to continue.</Trans>
)}
</DialogDescription>
</DialogHeader>
<fieldset disabled={isSubmitting}>
{method === 'totp' && (
<FormField
control={form.control}
name={totpCodeName}
render={({ field }) => (
<FormItem>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6} autoFocus>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'backup' && (
<FormField
control={form.control}
name={backupCodeName}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" autoComplete="off" autoFocus {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={onToggleMethod}>
{method === 'totp' ? <Trans>Use Backup Code</Trans> : <Trans>Use Authenticator</Trans>}
</Button>
<Button type="button" loading={isSubmitting} onClick={onSubmit}>
{submitLabel}
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,175 @@
import {
CONTENT_MAX_STROKE_WIDTH,
CONTENT_MIN_STROKE_WIDTH,
DEFAULT_CONTENT_TEXT_COLOR,
} from '@documenso/lib/types/envelope-content-meta';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Slider } from '@documenso/ui/primitives/slider';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Control } from 'react-hook-form';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// biome-ignore lint/suspicious/noExplicitAny: See above
type FormControlType = Control<any>;
type GenericContentFieldProps = {
formControl: FormControlType;
className?: string;
};
export const EditorContentColorField = ({
formControl,
className,
name = 'color',
label,
defaultColor = DEFAULT_CONTENT_TEXT_COLOR,
}: GenericContentFieldProps & {
name?: string;
label?: React.ReactNode;
defaultColor?: string;
}) => {
return (
<FormField
control={formControl}
name={name}
render={({ field }) => (
<FormItem className={className}>
<FormLabel>{label ?? <Trans>Color</Trans>}</FormLabel>
<FormControl>
<div className="flex items-center gap-x-3">
<ColorPicker
data-testid={`content-form-${name}`}
value={field.value ?? ''}
defaultValue={defaultColor}
onChange={(color) => field.onChange(color)}
/>
<span className="font-mono text-muted-foreground text-xs uppercase">{field.value ?? defaultColor}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorContentStrokeWidthField = ({ formControl, className }: GenericContentFieldProps) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="strokeWidth"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Thickness</Trans>
</FormLabel>
<FormControl>
<Input
data-testid="content-form-strokeWidth"
type="number"
min={CONTENT_MIN_STROKE_WIDTH}
max={CONTENT_MAX_STROKE_WIDTH}
step={0.5}
className="bg-background"
placeholder={t`Thickness`}
{...field}
onChange={(e) => {
field.onChange(Number(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorContentStrokeStyleField = ({ formControl, className }: GenericContentFieldProps) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="strokeStyle"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Style</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger data-testid="content-form-strokeStyle">
<SelectValue placeholder={t`Select style`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">
<Trans>Solid</Trans>
</SelectItem>
<SelectItem value="dashed">
<Trans>Dashed</Trans>
</SelectItem>
<SelectItem value="dotted">
<Trans>Dotted</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
/**
* An opacity slider. The form value is a fraction (0-1) while the slider and
* label display a percentage.
*/
export const EditorContentOpacityField = ({
formControl,
className,
name = 'fillOpacity',
label,
}: GenericContentFieldProps & {
name?: string;
label?: React.ReactNode;
}) => {
return (
<FormField
control={formControl}
name={name}
render={({ field }) => {
const percentage = Math.round((field.value ?? 1) * 100);
return (
<FormItem className={className}>
<FormLabel className="flex items-center justify-between">
{label ?? <Trans>Opacity</Trans>}
<span className="font-mono text-muted-foreground tabular-nums">{percentage}%</span>
</FormLabel>
<FormControl>
<Slider
data-testid={`content-form-${name}`}
className="py-2"
min={0}
max={100}
step={1}
value={[percentage]}
onValueChange={([value]) => field.onChange(value / 100)}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
};
@@ -0,0 +1,31 @@
import { DEFAULT_CONTENT_HIGHLIGHT_COLOR } from '@documenso/lib/types/envelope-content-meta';
import {
type TContentHighlightFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EditorContentColorField, EditorContentOpacityField } from './editor-content-generic-field-forms';
/**
* Settings form for highlight contents, bound to the selected content's
* settings form. Only the presentational settings are editable here;
* geometry (page, position and size) is managed on the canvas.
*/
export const EditorContentHighlightForm = () => {
const { form, isReady } = useContentSettingsForm<TContentHighlightFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<EditorContentColorField formControl={form.control} defaultColor={DEFAULT_CONTENT_HIGHLIGHT_COLOR} />
<EditorContentOpacityField formControl={form.control} name="fillOpacity" />
</fieldset>
</div>
);
};
@@ -0,0 +1,136 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
APP_CONTENT_IMAGE_MIME_TYPES,
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
} from '@documenso/lib/constants/envelope-content';
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FileImageIcon, ImageUpIcon, RefreshCwIcon, TrashIcon } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
type EditorContentImageSettingsProps = {
formId: string;
dataContentId?: string | null;
};
/**
* Settings for image contents: the image itself.
*
* Uploads go through the shared upload dialog. This panel only offers the
* ways to start one (drop, click, replace) and to remove the current image.
* Geometry (page, position, size and rotation) is managed on the canvas.
*/
export const EditorContentImageSettings = ({ formId, dataContentId }: EditorContentImageSettingsProps) => {
const { t } = useLingui();
const { editorContents } = useCurrentEnvelopeEditor();
const { contentImages } = useCurrentEnvelopeRender();
const image = dataContentId ? contentImages.images.get(dataContentId) : undefined;
const details = dataContentId ? contentImages.details.get(dataContentId) : undefined;
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: Object.fromEntries(APP_CONTENT_IMAGE_MIME_TYPES.map((mimeType) => [mimeType, []])),
multiple: false,
maxSize: megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT),
// A dropped file goes straight to the dialog; a click opens the picker
// through it instead of the dropzone's own input.
noClick: true,
onDrop: (acceptedFiles) => {
const [file] = acceptedFiles;
if (file) {
void ContentImageUploadDialog.call({ formId, file });
}
},
// A file which is too large or not a supported image goes to the dialog
// as well, which shows why it cannot be used. When several files were
// dropped, the first is used.
onDropRejected: (fileRejections) => {
const [rejection] = fileRejections;
if (rejection) {
void ContentImageUploadDialog.call({ formId, file: rejection.file });
}
},
});
if (dataContentId) {
return (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-border p-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted/40">
<FileImageIcon className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-xs">
<Trans>Image</Trans>
</p>
<p className="truncate text-muted-foreground text-xs">
{[details ? formatFileSize(details.fileSize) : null, image ? `${image.width} * ${image.height}` : null]
.filter((part) => part !== null)
.join(' · ')}
</p>
</div>
<div className="flex shrink-0 flex-row gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
title={t`Replace image`}
onClick={() => void ContentImageUploadDialog.call({ formId })}
>
<RefreshCwIcon className="h-4 w-4" strokeWidth={1.5} />
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
title={t`Remove image`}
onClick={() => editorContents.updateContentByFormId(formId, { dataContentId: null, data: undefined })}
>
<TrashIcon className="h-4 w-4" strokeWidth={1.5} />
</Button>
</div>
</div>
);
}
return (
<div
{...getRootProps({
role: 'button',
tabIndex: 0,
onClick: () => void ContentImageUploadDialog.call({ formId }),
onKeyDown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void ContentImageUploadDialog.call({ formId });
}
},
})}
className={cn(
'mt-2 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-border border-dashed px-4 py-6 text-center transition-colors hover:border-primary/60',
isDragActive && 'border-primary bg-primary/5',
)}
>
<input {...getInputProps()} />
<ImageUpIcon className="h-6 w-6 text-muted-foreground" strokeWidth={1.5} />
<p className="text-foreground text-sm">
<Trans>Click to upload or drag and drop</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>PNG, JPEG or WebP up to {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB</Trans>
</p>
</div>
);
};
@@ -0,0 +1,43 @@
import { DEFAULT_CONTENT_STROKE_COLOR } from '@documenso/lib/types/envelope-content-meta';
import {
type TContentLineFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import {
EditorContentColorField,
EditorContentStrokeStyleField,
EditorContentStrokeWidthField,
} from './editor-content-generic-field-forms';
/**
* Settings form for line contents, bound to the selected content's settings
* form. Only the presentational settings are editable here; geometry (page
* and endpoints) is managed on the canvas.
*/
export const EditorContentLineForm = () => {
const { form, isReady } = useContentSettingsForm<TContentLineFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorContentStrokeWidthField className="w-full" formControl={form.control} />
<EditorContentStrokeStyleField className="w-full" formControl={form.control} />
</div>
<EditorContentColorField
formControl={form.control}
name="strokeColor"
defaultColor={DEFAULT_CONTENT_STROKE_COLOR}
/>
</fieldset>
</div>
);
};
@@ -0,0 +1,105 @@
import {
DEFAULT_CONTENT_STROKE_COLOR,
EnvelopeContentShapeType,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import { FormControl, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Trans } from '@lingui/react/macro';
import { useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import {
type TContentShapeFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import {
EditorContentColorField,
EditorContentOpacityField,
EditorContentStrokeStyleField,
EditorContentStrokeWidthField,
} from './editor-content-generic-field-forms';
/**
* The fill color used when the fill is first enabled.
*
* Todo: Contents
*/
export const DEFAULT_ENABLED_FILL_COLOR = '#e5e7eb';
/**
* Settings form for shape contents, bound to the selected content's settings
* form, with the fields for the content's shape. Only the presentational
* settings are editable here; geometry (page, position and size) is managed
* on the canvas.
*/
export const EditorContentShapeForm = () => {
const { form, content, isReady } = useContentSettingsForm<TContentShapeFormSchema>();
const fillColor = useWatch({ control: form.control, name: 'fillColor' });
const hasFill = Boolean(fillColor);
const onFillToggle = (enabled: boolean) => {
// The fill is represented purely by the fill color, so toggling sets it
// or clears it to `null`.
form.setValue('fillColor', enabled ? DEFAULT_ENABLED_FILL_COLOR : null, {
shouldDirty: true,
shouldValidate: true,
});
};
// Mount the inputs only once the form holds this content's values.
if (!isReady || content?.contentMeta.type !== EnvelopeContentType.SHAPE) {
return null;
}
return (
<div>
{match(content.contentMeta.shape)
.with(EnvelopeContentShapeType.RECTANGLE, () => (
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorContentStrokeWidthField className="w-full" formControl={form.control} />
<EditorContentStrokeStyleField className="w-full" formControl={form.control} />
</div>
<EditorContentColorField
formControl={form.control}
name="strokeColor"
label={<Trans>Border Color</Trans>}
defaultColor={DEFAULT_CONTENT_STROKE_COLOR}
/>
<FormItem className="mt-1 flex flex-row items-center justify-between">
<FormLabel>
<Trans>Fill</Trans>
</FormLabel>
<FormControl>
<Switch data-testid="content-form-fill" checked={hasFill} onCheckedChange={onFillToggle} />
</FormControl>
</FormItem>
{hasFill && (
<>
<EditorContentColorField
formControl={form.control}
name="fillColor"
label={<Trans>Fill Color</Trans>}
defaultColor={DEFAULT_ENABLED_FILL_COLOR}
/>
<EditorContentOpacityField
formControl={form.control}
name="fillOpacity"
label={<Trans>Fill Opacity</Trans>}
/>
</>
)}
</fieldset>
))
.exhaustive()}
</div>
);
};
@@ -0,0 +1,78 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Trans, useLingui } from '@lingui/react/macro';
import {
type TContentTextFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EditorContentColorField } from './editor-content-generic-field-forms';
import {
EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
/**
* Settings form for text contents, bound to the selected content's settings
* form. Only the presentational settings are editable here; geometry (page,
* position and size) is managed on the canvas.
*/
export const EditorContentTextForm = () => {
const { t } = useLingui();
const { form, isReady } = useContentSettingsForm<TContentTextFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text</Trans>
</FormLabel>
<FormControl>
<Textarea
data-testid="content-form-text"
className="h-auto"
placeholder={t`Add text to the document`}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorContentColorField className="w-full" formControl={form.control} />
</div>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
</fieldset>
</div>
);
};
@@ -15,7 +15,7 @@ import { type Control, useFormContext } from 'react-hook-form';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: See above
type FormControlType = Control<any>;
export const EditorGenericFontSizeField = ({
@@ -0,0 +1,53 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CheckIcon } from 'lucide-react';
import { match } from 'ts-pattern';
/**
* Compact "send me a setup link" button that reports via toast, for settings
* cards where the surrounding layout provides the explanation.
*/
export const PasswordSetupRequestButton = () => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({
onSuccess: () => {
toast({
title: _(msg`Check your email`),
description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`),
duration: 5000,
});
},
onError: (errorCode) => {
toast({
title: _(msg`An error occurred`),
description: match(errorCode)
.with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`))
.otherwise(() => _(msg`We were unable to send the email. Please try again later.`)),
variant: 'destructive',
});
},
});
if (isSuccess) {
return (
<Button variant="outline" className="flex-shrink-0 bg-background" disabled>
<CheckIcon className="mr-2 h-4 w-4" />
<Trans>Link sent</Trans>
</Button>
);
}
return (
<Button variant="outline" className="flex-shrink-0 bg-background" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
);
};
@@ -0,0 +1,61 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
export type PasswordSetupRequestProps = {
className?: string;
};
/**
* Inline "send me a setup link" control with its own sent/error states, for
* contexts like dialogs where a toast would be missed.
*/
export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => {
const { _ } = useLingui();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest();
if (isSuccess) {
return (
<Alert className={className} variant="neutral">
<AlertTitle>
<Trans>Check your email</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue.
</Trans>
</AlertDescription>
</Alert>
);
}
return (
<div className={className}>
{errorCode && (
<Alert className="mb-4" variant="destructive">
<AlertTitle>
<Trans>An error occurred</Trans>
</AlertTitle>
<AlertDescription>
{match(errorCode)
.with('SIGNIN_DISABLED', () =>
_(msg`Password sign in is disabled for this instance. Please contact support.`),
)
.otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))}
</AlertDescription>
</Alert>
)}
<Button type="button" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
</div>
);
};
+59 -18
View File
@@ -1,6 +1,6 @@
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -11,20 +11,21 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import type { z } from 'zod';
export const ZPasswordFormSchema = z
.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog';
export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
}).refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
@@ -33,29 +34,51 @@ export type PasswordFormProps = {
user: SessionUser;
};
export const PasswordForm = ({ className }: PasswordFormProps) => {
export const PasswordForm = ({ className, user }: PasswordFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false);
const form = useForm<TPasswordFormSchema>({
values: {
currentPassword: '',
password: '',
repeatedPassword: '',
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
const onFormSubmit = async (values: TPasswordFormSchema) => {
const { currentPassword, password, totpCode, backupCode } = values;
// Collect the 2FA code in a dialog once the password fields are valid.
if (user.twoFactorEnabled && !hasTwoFactorCode(values)) {
if (isTwoFactorDialogOpen) {
const message = _(msg`A code is required`);
form.setError('totpCode', { message });
form.setError('backupCode', { message });
}
setIsTwoFactorDialogOpen(true);
return;
}
try {
await authClient.emailPassword.updatePassword({
currentPassword,
password,
totpCode: totpCode || undefined,
backupCode: backupCode || undefined,
});
form.reset();
setIsTwoFactorDialogOpen(false);
toast({
title: _(msg`Password updated`),
@@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('NO_PASSWORD', () => msg`User has no password.`)
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
.with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`)
.with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`)
.with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`)
.with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`)
.with(
AppErrorCode.INCORRECT_TWO_FACTOR_CODE,
AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS,
() => msg`The two factor code you provided is invalid. Please try again.`,
)
.otherwise(
() => msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
);
@@ -83,7 +111,12 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
return (
<Form {...form}>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
{/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */}
<form
method="post"
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
@@ -140,6 +173,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
</Button>
</div>
</form>
<TwoFactorCodeDialog<TPasswordFormSchema>
open={isTwoFactorDialogOpen}
onOpenChange={setIsTwoFactorDialogOpen}
isSubmitting={isSubmitting}
submitLabel={<Trans>Update password</Trans>}
onSubmit={form.handleSubmit(onFormSubmit)}
/>
</Form>
);
};
@@ -54,6 +54,8 @@ export const SubscriptionClaimForm = ({
memberCount: subscriptionClaim.memberCount,
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
envelopeContentCount: subscriptionClaim.envelopeContentCount,
envelopeContentImageCount: subscriptionClaim.envelopeContentImageCount,
flags: subscriptionClaim.flags,
documentRateLimits: subscriptionClaim.documentRateLimits,
documentQuota: subscriptionClaim.documentQuota,
@@ -185,6 +187,54 @@ export const SubscriptionClaimForm = ({
)}
/>
<FormField
control={form.control}
name="envelopeContentCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envelopeContentImageCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Image Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of image contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
@@ -0,0 +1,371 @@
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import { SearchIcon, UsersIcon } from 'lucide-react';
import type { MouseEvent, ReactNode } from 'react';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { formatRelativeDate } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */
export type AnalyticsActivityRow = {
key: string | number;
avatar: {
imageId: string | null;
fallback: string;
};
title: string;
subtitle?: string | null;
sent: number;
completed: number;
pending: number;
/** 0-100, null when nothing was sent. */
completionRate: number | null;
lastActiveAt: Date | null;
/** When set the whole row navigates here and the title becomes a link. */
href?: string;
};
export type AnalyticsActivityTableCardProps = {
query: AnalyticsQueryResult<unknown>;
/** Rows derived from `query.data`; empty while loading. */
rows: AnalyticsActivityRow[];
/** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */
rangeKey: string;
title: ReactNode;
description: ReactNode;
/** Header of the first column, e.g. "Member". */
columnLabel: ReactNode;
/** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */
renderSummary: (count: number, activeCount: number) => ReactNode;
/** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */
renderShowing: (visibleCount: number, totalCount: number) => ReactNode;
emptyLabel: ReactNode;
emptyIcon?: LucideIcon;
/** Placeholder for the search input, e.g. "Search members". */
searchPlaceholder: string;
/** Rendered when the search matches nothing, e.g. "No members match your search". */
noSearchResultsLabel: ReactNode;
/** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */
testIdPrefix: string;
className?: string;
};
export const AnalyticsActivityTableCard = ({
query,
rows,
rangeKey,
title,
description,
columnLabel,
renderSummary,
renderShowing,
emptyLabel,
emptyIcon: EmptyIcon = UsersIcon,
searchPlaceholder,
noSearchResultsLabel,
testIdPrefix,
className,
}: AnalyticsActivityTableCardProps) => {
const { i18n } = useLingui();
// Tracks which window "Show all" was pressed for, so it resets whenever the window changes.
const [expandedRangeKey, setExpandedRangeKey] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const isExpanded = expandedRangeKey === rangeKey;
const { data, isLoading, isError, refetch } = query;
const activeCount = rows.filter((row) => row.sent > 0).length;
const normalisedSearchTerm = searchTerm.trim().toLowerCase();
const isSearching = normalisedSearchTerm.length > 0;
// Search always shows every match; the preview limit only applies to the unfiltered list.
const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows;
const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT);
const hasHiddenRows = filteredRows.length > visibleRows.length;
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
return (
<Card className={className} data-testid={testId('activity')}>
<CardHeader className="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col space-y-1.5">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
{data !== undefined && rows.length > 0 && (
<p className="shrink-0 text-muted-foreground text-sm tabular-nums" data-testid={testId('summary')}>
{renderSummary(rows.length, activeCount)}
</p>
)}
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || data === undefined ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 4 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-10" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="h-4 w-28" />
<Skeleton className="hidden h-4 w-20 md:block" />
</li>
))}
</ul>
) : rows.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<EmptyIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">{emptyLabel}</p>
</div>
) : (
<div className="flex flex-col gap-y-3">
<div className="relative sm:max-w-xs">
<SearchIcon
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
className="pl-9"
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
data-testid={testId('search')}
/>
</div>
{filteredRows.length === 0 ? (
<p className="py-8 text-center text-muted-foreground text-sm" data-testid={testId('no-results')}>
{noSearchResultsLabel}
</p>
) : (
<>
{/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */}
<div className="-mx-6">
<Table className="[&_td]:px-2 md:[&_td]:px-4 [&_th]:px-2 md:[&_th]:px-4">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={FIRST_CELL_CLASS}>{columnLabel}</TableHead>
<TableHead className="text-right">
<Trans>Sent</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Completed</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Pending</Trans>
</TableHead>
<TableHead className={cn('text-right', LAST_CELL_ON_MOBILE_CLASS)}>
<Trans>Completion rate</Trans>
</TableHead>
<TableHead className={cn('hidden text-right md:table-cell', LAST_CELL_CLASS)}>
<Trans>Last active</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<ActivityRow key={row.key} row={row} locale={i18n.locale} testIdPrefix={testIdPrefix} />
))}
</TableBody>
</Table>
</div>
{hasHiddenRows && (
<div className="flex items-center justify-between gap-x-4 border-border border-t pt-3">
<p className="text-muted-foreground text-sm" data-testid={testId('showing')}>
{renderShowing(visibleRows.length, filteredRows.length)}
</p>
<Button
variant="ghost"
size="sm"
className="-mr-2"
onClick={() => setExpandedRangeKey(rangeKey)}
data-testid={testId('show-all')}
>
<Trans>Show all</Trans>
</Button>
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
};
type ActivityRowProps = {
row: AnalyticsActivityRow;
locale: string;
testIdPrefix: string;
};
const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => {
const { _ } = useLingui();
const navigate = useNavigate();
const isActive = row.sent > 0;
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
// Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout.
const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground');
const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums';
// role="img" so the aria-label is valid (a bare span has no role that supports it).
const notAvailable = (
<span role="img" aria-label={_(msg`Not available`)}>
—
</span>
);
/**
* The title link is the accessible target; clicking anywhere else on the row
* navigates too. Modifier clicks and clicks on the link itself are left to the
* browser so open-in-new-tab keeps working, and drag-selecting text does not
* navigate.
*/
const handleRowClick = (event: MouseEvent<HTMLTableRowElement>) => {
if (!row.href || event.defaultPrevented || event.button !== 0) {
return;
}
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
if (event.target instanceof Element && event.target.closest('a')) {
return;
}
if (window.getSelection()?.toString()) {
return;
}
void navigate(row.href);
};
return (
<TableRow
className={cn(row.href && 'cursor-pointer')}
onClick={handleRowClick}
data-testid={testId('row')}
data-active={isActive ? 'true' : 'false'}
>
{/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */}
<TableCell truncate={false} className={cn('w-full max-w-0', FIRST_CELL_CLASS)}>
<div className="flex min-w-0 items-center gap-x-3">
<Avatar className="h-9 w-9 shrink-0">
{row.avatar.imageId && <AvatarImage src={formatAvatarUrl(row.avatar.imageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{row.avatar.fallback}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
{row.href ? (
<Link
to={row.href}
className={cn(
'truncate font-medium text-sm hover:underline',
isActive ? 'text-foreground' : 'text-foreground/80',
)}
>
{row.title}
</Link>
) : (
<span className={cn('truncate font-medium text-sm', isActive ? 'text-foreground' : 'text-foreground/80')}>
{row.title}
</span>
)}
{row.subtitle && <span className="truncate text-muted-foreground text-xs">{row.subtitle}</span>}
</div>
</div>
</TableCell>
<TableCell className={secondaryNumberClass} data-testid={testId('sent')}>
{row.sent.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', primaryNumberClass)} data-testid={testId('completed')}>
{row.completed.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', secondaryNumberClass)} data-testid={testId('pending')}>
{row.pending.toLocaleString(locale)}
</TableCell>
<TableCell className={cn(primaryNumberClass, LAST_CELL_ON_MOBILE_CLASS)} data-testid={testId('completion-rate')}>
{row.completionRate === null ? (
notAvailable
) : (
<div className="flex items-center justify-end gap-x-2">
<div className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:block" aria-hidden="true">
<div className="h-full rounded-full bg-primary" style={{ width: `${row.completionRate}%` }} />
</div>
<span className="w-9 text-right">{Math.round(row.completionRate)}%</span>
</div>
)}
</TableCell>
<TableCell
className={cn('hidden md:table-cell', secondaryNumberClass, LAST_CELL_CLASS)}
data-testid={testId('last-active')}
>
{row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)}
</TableCell>
</TableRow>
);
};
const ROW_PREVIEW_LIMIT = 8;
const matchesSearch = (row: AnalyticsActivityRow, term: string) => {
return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term);
};
/**
* The table is pulled out to the card edge (-mx-6), so the outer cells get the
* card's px-6 gutter to line up with the header. "Last active" is hidden below
* md, so "Completion rate" takes the right gutter there.
*/
const FIRST_CELL_CLASS = '!pl-6';
const LAST_CELL_CLASS = '!pr-6';
const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4';
@@ -0,0 +1,203 @@
import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { BarChart3Icon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics';
import { getAnalyticsDateRangeDays } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsDocumentsOverTimeCardProps = {
range: AnalyticsRangeValue;
query: AnalyticsQueryResult<TGetTeamAnalyticsDocumentsOverTimeResponse>;
className?: string;
};
type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket'];
export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
// The backend decides the bucket, and it must match the points being rendered so
// the tick and tooltip formatting line up. Before data arrives it is guessed from
// the requested range.
const bucket: Bucket = data ? data.range.bucket : guessBucket(range);
const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0;
return (
<Card className={cn('flex flex-col', className)} data-testid="analytics-documents-over-time">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Documents created</Trans>
</CardTitle>
<CardDescription>{bucket === 'month' ? <Trans>Monthly</Trans> : <Trans>Daily</Trans>}</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap" data-testid="analytics-documents-over-time-total">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
{/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */}
<CardContent className="flex flex-1 flex-col justify-center">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<Skeleton className="w-full" style={{ height: CHART_HEIGHT }} />
) : data.total === 0 ? (
<div
className="flex flex-col items-center justify-center gap-y-3 text-center"
style={{ height: CHART_HEIGHT }}
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<BarChart3Icon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="text-muted-foreground text-sm">
<Trans>No documents created in this period</Trans>
</p>
</div>
) : (
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
<BarChart data={data.points} margin={{ top: 8, right: 0, bottom: 0, left: 0 }} barCategoryGap="20%">
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
interval={tickInterval}
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={(value: string) => formatTickLabel(value, bucket, i18n.locale)}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={36}
tickCount={4}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
/>
<Tooltip
content={<DocumentsOverTimeTooltip bucket={bucket} locale={i18n.locale} />}
cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={28}
background={{ fill: 'hsl(var(--muted) / 0.5)', radius: 4 }}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
};
type DocumentsOverTimeTooltipProps = {
active?: boolean;
payload?: Array<{ payload: { date: string; count: number } }>;
bucket: Bucket;
locale: string;
};
const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => {
const point = payload?.[0]?.payload;
if (!active || !point) {
return null;
}
const count = Number(point.count ?? 0);
return (
<div className="rounded-md border border-border bg-popover px-3 py-2 text-popover-foreground text-sm shadow-md">
<p className="text-muted-foreground text-xs">{formatTooltipLabel(point.date, bucket, locale)}</p>
<p className="mt-0.5 font-medium tabular-nums">
<Plural value={count} one="# document" other="# documents" />
</p>
</div>
);
};
const CHART_HEIGHT = 240;
const TARGET_DAILY_TICK_COUNT = 6;
/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */
const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92;
const guessBucket = (range: AnalyticsRangeValue): Bucket => {
if (range.range === '12m') {
return 'month';
}
if (range.range === 'custom') {
return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day';
}
return 'day';
};
/**
* Month buckets label every month (12 fit at the lg width) and let recharts drop
* overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels.
*/
const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => {
if (bucket === 'month') {
return 'preserveStartEnd';
}
if (pointCount <= TARGET_DAILY_TICK_COUNT) {
return 0;
}
return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1);
};
const formatTickLabel = (date: string, bucket: Bucket, locale: string) => {
const parsed = DateTime.fromISO(date).setLocale(locale);
if (bucket === 'month') {
return parsed.toLocaleString({ month: 'short' });
}
return parsed.toLocaleString({ month: 'short', day: 'numeric' });
};
const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => {
const parsed = DateTime.fromISO(date).setLocale(locale);
if (bucket === 'month') {
return parsed.toLocaleString({ month: 'long', year: 'numeric' });
}
return parsed.toLocaleString(DateTime.DATE_FULL);
};
@@ -0,0 +1,17 @@
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
/**
* Shown while the analytics route's `clientLoader` resolves the browser timezone
* during hydration.
*/
export const AnalyticsHydrateFallback = () => {
return (
<div role="status" aria-live="polite" data-testid="analytics-loading">
<SpinnerBox />
<span className="sr-only">
<Trans>Loading analytics</Trans>
</span>
</div>
);
};
@@ -0,0 +1,45 @@
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics';
export type AnalyticsNoActivityAlertProps = {
range: TTeamAnalyticsRange;
/** Invoked when the user asks to widen the range to the last 12 months. */
onShowLastYear: () => void;
};
export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => {
const { _ } = useLingui();
const canWidenRange = range !== '12m';
return (
<Alert variant="neutral" padding="tight" className="mt-6" data-testid="analytics-no-activity">
<AlertDescription className="flex min-h-9 flex-wrap items-center justify-between gap-x-4 gap-y-2">
<span className="flex items-center gap-x-2">
<InfoIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>
{_(ANALYTICS_NO_ACTIVITY_LABELS[range])}
{canWidenRange && (
<>
{' '}
<Trans>Try a longer range.</Trans>
</>
)}
</span>
</span>
{canWidenRange && (
<Button variant="ghost" size="sm" className="-mr-2" onClick={onShowLastYear}>
<Trans>Show last 12 months</Trans>
</Button>
)}
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,214 @@
import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { AnalyticsStatCard } from './analytics-stat-card';
/** The part of the overview response shared by the team and organisation procedures. */
export type AnalyticsOverviewData = Pick<TGetTeamAnalyticsOverviewResponse, 'sent' | 'completionRate'>;
/**
* The third card counts the scope's "entities" (team members, organisation teams)
* and how many of them were active in the period.
*/
export type AnalyticsOverviewEntityCard<TData> = {
icon: LucideIcon;
title: ReactNode;
/** Applied to the value element, e.g. `analytics-members`. */
testId: string;
select: (data: TData) => { active: number; total: number };
};
export type AnalyticsOverviewCardsProps<TData extends AnalyticsOverviewData> = {
query: AnalyticsQueryResult<TData>;
entity: AnalyticsOverviewEntityCard<TData>;
};
export const AnalyticsOverviewCards = <TData extends AnalyticsOverviewData>({
query,
entity,
}: AnalyticsOverviewCardsProps<TData>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
const entityCounts = data ? entity.select(data) : null;
const formatNumber = (value: number) => value.toLocaleString(i18n.locale);
const sharedProps = {
isLoading: isLoading || !data,
isError,
onRetry: refetch,
};
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<AnalyticsStatCard
{...sharedProps}
icon={SendIcon}
title={<Trans>Documents sent</Trans>}
value={data ? formatNumber(data.sent.current) : null}
badge={data ? <SentDeltaBadge current={data.sent.current} previous={data.sent.previous} /> : null}
description={<Trans>vs. previous period</Trans>}
testId="analytics-sent"
/>
<AnalyticsStatCard
{...sharedProps}
icon={CircleCheckIcon}
title={<Trans>Completion rate</Trans>}
value={data ? formatRate(data.completionRate.rate) : null}
badge={
data ? (
<CompletionRateDeltaBadge rate={data.completionRate.rate} previousRate={data.completionRate.previousRate} />
) : null
}
description={<Trans>of sent documents completed</Trans>}
testId="analytics-completion-rate"
/>
<AnalyticsStatCard
{...sharedProps}
icon={entity.icon}
title={entity.title}
value={entityCounts ? `${formatNumber(entityCounts.active)}/${formatNumber(entityCounts.total)}` : null}
description={
entityCounts ? (
<Trans>
{formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '}
inactive
</Trans>
) : null
}
testId={entity.testId}
/>
</div>
);
};
type SentDeltaBadgeProps = {
current: number;
previous: number;
};
const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => {
if (previous === 0 && current === 0) {
return null;
}
if (previous === 0) {
return (
<DeltaBadge tone="new" testId="analytics-sent-delta">
<Trans>New</Trans>
</DeltaBadge>
);
}
const delta = Math.round(((current - previous) / previous) * 100);
// Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display.
const label =
delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`;
return (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-sent-delta">
{label}
</DeltaBadge>
);
};
type CompletionRateDeltaBadgeProps = {
rate: number | null;
previousRate: number | null;
};
const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => {
if (rate === null || previousRate === null) {
return null;
}
// Compare the rounded values so the delta always agrees with the displayed rate.
const delta = Math.round(rate) - Math.round(previousRate);
return (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-completion-rate-delta">
{formatSignedNumber(delta)}%
</DeltaBadge>
);
};
type DeltaTone = 'positive' | 'negative' | 'zero' | 'new';
type DeltaBadgeProps = {
tone: DeltaTone;
testId: string;
children: ReactNode;
};
const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => {
const DeltaIcon = DELTA_TONE_ICONS[tone];
return (
<span
className={cn(
'inline-flex items-center gap-x-0.5 rounded-full px-1.5 py-0.5 font-medium text-xs tabular-nums leading-none',
DELTA_TONE_CLASSES[tone],
)}
data-testid={testId}
>
{DeltaIcon && <DeltaIcon className="-ml-0.5 h-3 w-3" aria-hidden="true" />}
{children}
</span>
);
};
const DELTA_TONE_CLASSES: Record<DeltaTone, string> = {
positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
negative: 'bg-red-500/10 text-red-600 dark:text-red-400',
zero: 'bg-muted text-muted-foreground',
new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
};
const MAX_DISPLAYED_DELTA_PERCENT = 999;
const DELTA_TONE_ICONS: Record<DeltaTone, typeof ArrowUpRightIcon | null> = {
positive: ArrowUpRightIcon,
negative: ArrowDownRightIcon,
zero: null,
new: null,
};
const formatRate = (rate: number | null) => {
if (rate === null) {
return '—';
}
return `${Math.round(rate)}%`;
};
const formatSignedNumber = (value: number) => {
if (value > 0) {
return `+${value}`;
}
return String(value);
};
const getDeltaTone = (delta: number): DeltaTone => {
if (delta > 0) {
return 'positive';
}
if (delta < 0) {
return 'negative';
}
return 'zero';
};
@@ -0,0 +1,67 @@
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ReactNode } from 'react';
import type { AnalyticsRangeValue } from '~/utils/analytics';
import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics';
import { AnalyticsRangePicker } from './analytics-range-picker';
export type AnalyticsPageHeaderProps = {
avatarImageId: string | null;
/** The team or organisation name. */
name: string;
range: AnalyticsRangeValue;
onRangeChange: (range: AnalyticsRangeValue) => void;
/** Rendered before the range picker, e.g. a link to a related analytics page. */
actions?: ReactNode;
className?: string;
};
export const AnalyticsPageHeader = ({
avatarImageId,
name,
range,
onRangeChange,
actions,
className,
}: AnalyticsPageHeaderProps) => {
const { _, i18n } = useLingui();
const rangeLabel =
range.range === 'custom'
? formatAnalyticsDateRange(range.from, range.to, i18n.locale)
: _(ANALYTICS_RANGE_LABELS[range.range]);
return (
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div>
<h2 className="font-semibold text-4xl">
<Trans>Analytics</Trans>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Usage overview for {name} · {rangeLabel}
</Trans>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{actions}
<AnalyticsRangePicker value={range} onValueChange={onRangeChange} />
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type AnalyticsQueryErrorProps = {
onRetry: () => Promise<unknown>;
className?: string;
};
export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => {
const [isRetrying, setIsRetrying] = useState(false);
const handleRetry = async () => {
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
};
return (
<Alert variant="neutral" padding="tight" className={className} data-testid="analytics-error">
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
<span>
<Trans>This data could not be loaded.</Trans>
</span>
<Button variant="outline" size="sm" onClick={() => void handleRetry()} loading={isRetrying}>
<Trans>Retry</Trans>
</Button>
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,264 @@
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Button } from '@documenso/ui/primitives/button';
import type { CalendarProps } from '@documenso/ui/primitives/calendar';
import { Calendar } from '@documenso/ui/primitives/calendar';
import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { useRef, useState } from 'react';
import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics';
import {
ANALYTICS_PRESET_RANGES,
ANALYTICS_RANGE_LABELS,
formatAnalyticsDate,
formatAnalyticsDateRange,
getAnalyticsDateRangeDays,
} from '~/utils/analytics';
export type AnalyticsRangePickerProps = {
value: AnalyticsRangeValue;
onValueChange: (value: AnalyticsRangeValue) => void;
};
/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */
type DraftRange = {
from: Date | undefined;
to: Date | undefined;
};
/** A single react-day-picker matcher, e.g. `{ after: Date }`. */
type DayMatcher = Exclude<CalendarProps['disabled'], undefined | unknown[]>;
/**
* A preset select with a "Custom range…" item that opens a two month range
* calendar anchored to the select. The custom window is only committed when
* "Apply" is pressed.
*/
export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => {
const { _, i18n } = useLingui();
const { width } = useWindowSize();
const triggerRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [draft, setDraft] = useState<DraftRange | undefined>();
const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1;
const today = DateTime.local().startOf('day');
const openPicker = () => {
setDraft(
value.range === 'custom'
? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() }
: undefined,
);
setIsPickerOpen(true);
};
const closePicker = () => {
setIsPickerOpen(false);
setDraft(undefined);
};
const handleSelectValueChange = (nextValue: string) => {
if (nextValue === CUSTOM_RANGE_VALUE) {
openPicker();
return;
}
const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue);
if (!preset) {
return;
}
onValueChange({ range: preset });
};
/**
* Picking a day starts a new window unless one end is already pending, in which
* case it completes it. This replaces react-day-picker's default, which extends
* a completed window instead of starting over.
*/
const handleDaySelect = (_nextRange: unknown, day: Date) => {
if (draft?.from && !draft.to) {
setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day });
return;
}
setDraft({ from: day, to: undefined });
};
const handleApply = () => {
if (!draft?.from || !draft.to) {
return;
}
onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) });
closePicker();
};
// Only the last year (plus a day) up to today is selectable.
const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }];
// Open on the month of the pending window (or today), keeping the current month
// as the right-most one so no fully disabled future month is shown.
const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month');
const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 });
const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate();
const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null;
const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null;
const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0;
const customLabel =
value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined;
return (
<Popover
open={isPickerOpen}
onOpenChange={(open) => {
if (!open) {
closePicker();
}
}}
>
{/*
* The select never holds "custom" as its value so choosing "Custom range…" always
* fires a change, letting an active custom window be adjusted. The trigger shows
* the formatted window through the placeholder instead.
*/}
<Select value={value.range === 'custom' ? '' : value.range} onValueChange={handleSelectValueChange}>
<PopoverAnchor asChild>
<SelectTrigger
ref={triggerRef}
className="w-full sm:w-auto sm:min-w-44"
aria-label={_(msg`Date range`)}
data-testid="analytics-range"
>
<SelectValue placeholder={customLabel} />
</SelectTrigger>
</PopoverAnchor>
<SelectContent position="popper">
{ANALYTICS_PRESET_OPTIONS.map(({ value: optionValue, label }) => (
<SelectItem key={optionValue} value={optionValue}>
{_(label)}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={CUSTOM_RANGE_VALUE} data-testid="analytics-range-custom">
<Trans>Custom range…</Trans>
</SelectItem>
</SelectContent>
</Select>
<PopoverContent
ref={contentRef}
align="end"
className="w-auto p-0"
// The select refocuses its trigger (asynchronously) as it closes, which
// would otherwise dismiss the popover that has just opened and strand
// keyboard focus outside it. Pointer interaction with the trigger still
// dismisses the popover so the select can be reopened.
onFocusOutside={(event) => {
if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) {
return;
}
event.preventDefault();
const content = contentRef.current;
const firstTabbable = content?.querySelector<HTMLElement>(TABBABLE_SELECTOR);
(firstTabbable ?? content)?.focus();
}}
// There is no popover trigger element, so hand focus back to the select.
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.focus();
}}
>
<div data-testid="analytics-range-calendar">
<Calendar
mode="range"
selected={draft}
onSelect={handleDaySelect}
numberOfMonths={numberOfMonths}
// Adjacent months would otherwise show the same days twice.
showOutsideDays={false}
defaultMonth={defaultMonth}
fromDate={earliestDay.toJSDate()}
toDate={today.toJSDate()}
disabled={disabledDays}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2">
<p className="text-muted-foreground text-sm" aria-live="polite">
{draftFrom && draftTo ? (
<>
{formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '}
<Plural value={draftDays} one="# day" other="# days" />
</>
) : draftFrom ? (
<Trans>Pick an end date</Trans>
) : (
<Trans>Pick a start date</Trans>
)}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={closePicker}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
size="sm"
onClick={handleApply}
disabled={!draftFrom || !draftTo}
data-testid="analytics-range-apply"
>
<Trans>Apply</Trans>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
const CUSTOM_RANGE_VALUE = 'custom';
/** Tailwind `sm` breakpoint; two months are shown from here up. */
const SM_BREAKPOINT = 640;
/** First element the popover should focus: the calendar's month navigation, then the days. */
const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]';
const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({
value,
label: ANALYTICS_RANGE_LABELS[value],
}));
@@ -0,0 +1,63 @@
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsStatCardProps = {
icon: LucideIcon;
title: ReactNode;
value: ReactNode;
description: ReactNode;
badge?: ReactNode;
isLoading: boolean;
isError: boolean;
onRetry: () => Promise<unknown>;
testId: string;
};
export const AnalyticsStatCard = ({
icon: Icon,
title,
value,
description,
badge,
isLoading,
isError,
onRetry,
testId,
}: AnalyticsStatCardProps) => {
return (
<Card>
<CardContent className="flex flex-col p-5">
<div className="flex items-center justify-between gap-x-3">
<h3 className="font-medium text-muted-foreground text-sm">{title}</h3>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</div>
{isError ? (
<AnalyticsQueryError onRetry={onRetry} className="mt-3" />
) : isLoading ? (
<div className="mt-3 flex flex-col gap-y-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-3.5 w-32" />
</div>
) : (
<>
<div className="mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
<p className="font-semibold text-3xl text-foreground tabular-nums tracking-tight" data-testid={testId}>
{value}
</p>
{badge}
</div>
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
</>
)}
</CardContent>
</Card>
);
};
@@ -0,0 +1,198 @@
import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsStatusBreakdownCardProps = {
query: AnalyticsQueryResult<TGetTeamAnalyticsStatusBreakdownResponse>;
className?: string;
};
export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => {
const { _, i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : [];
return (
<Card className={cn('flex flex-col', className)} data-testid="analytics-status-breakdown">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Status breakdown</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created in this period</Trans>
</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
<CardContent className="flex flex-1 flex-col">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<div className="flex flex-col gap-y-4">
<Skeleton className="h-2.5 w-full rounded-full" />
<div className="flex flex-col gap-y-2">
{STATUS_ROWS.slice(0, 3).map((row) => (
<Skeleton key={row.key} className="h-5 w-full" />
))}
</div>
</div>
) : data.total === 0 ? (
<div className="flex flex-1 flex-col gap-y-4">
<StatusBar segments={[]} label={_(msg`No documents in this period`)} />
<p className="flex flex-1 items-center justify-center text-center text-muted-foreground text-sm">
<Trans>No documents in this period</Trans>
</p>
</div>
) : (
<div className="flex flex-col gap-y-2">
<StatusBar segments={rows} label={_(msg`Document status distribution`)} />
<ul className="flex flex-col divide-y divide-border">
{rows.map((row) => (
<li key={row.key} className="flex items-center justify-between gap-x-3 py-2.5 text-sm">
<div className="flex min-w-0 items-center gap-x-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
aria-hidden="true"
/>
<span className="truncate text-foreground">{_(row.label)}</span>
</div>
<div className="flex shrink-0 items-baseline gap-x-2 tabular-nums">
<span className="font-medium text-foreground" data-testid={`analytics-status-${row.key}`}>
{row.count.toLocaleString(i18n.locale)}
</span>
<span className="w-10 text-right text-muted-foreground">{row.percent}%</span>
</div>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
};
type StatusBarProps = {
segments: Array<{ key: string; percent: number; color: string }>;
label: string;
};
/**
* Stacked horizontal bar. Segment widths come from the largest-remainder
* percentages so they always add up to the full width; an empty list renders
* the muted track on its own.
*/
const StatusBar = ({ segments, label }: StatusBarProps) => {
return (
<div className="flex h-2.5 w-full gap-px overflow-hidden rounded-full bg-muted" role="img" aria-label={label}>
{segments.map((segment) => (
<div
key={segment.key}
className="h-full"
style={{ width: `${segment.percent}%`, backgroundColor: segment.color }}
/>
))}
</div>
);
};
type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled';
type StatusRow = {
key: StatusKey;
label: MessageDescriptor;
color: string;
};
/**
* Single source of truth for status colours so the bar and the legend cannot drift.
*/
const STATUS_ROWS: StatusRow[] = [
{ key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' },
{ key: 'pending', label: msg`Pending`, color: '#f59e0b' },
{ key: 'rejected', label: msg`Rejected`, color: '#ef4444' },
{ key: 'cancelled', label: msg`Cancelled`, color: '#f97316' },
{ key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' },
];
/**
* Assign integer percentages to the non-zero rows using largest-remainder
* allocation so the values always sum to exactly 100, with every non-zero row
* shown as at least 1%.
*/
const allocatePercentages = <T extends { count: number }>(rows: T[]): Array<T & { percent: number }> => {
const visibleRows = rows.filter((row) => row.count > 0);
const total = visibleRows.reduce((sum, row) => sum + row.count, 0);
if (total === 0) {
return [];
}
const allocations = visibleRows.map((row, index) => {
const exact = (row.count / total) * 100;
const floored = Math.floor(exact);
return { index, percent: floored, remainder: exact - floored };
});
let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0);
const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index);
for (const allocation of byRemainder) {
if (remaining <= 0) {
break;
}
allocation.percent += 1;
remaining -= 1;
}
// Every non-zero row must display at least 1%; take the difference from the largest rows.
const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index);
for (const allocation of allocations) {
if (allocation.percent > 0) {
continue;
}
allocation.percent = 1;
const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1);
if (donor) {
donor.percent -= 1;
}
}
return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent }));
};
@@ -0,0 +1,145 @@
import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { FileTextIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { formatRelativeDate } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
/** The template shape shared by the team and organisation procedures. */
export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number];
export type AnalyticsTemplateUsageCardProps<TTemplate extends AnalyticsTemplate> = {
query: AnalyticsQueryResult<{ templates: TTemplate[] }>;
/** Where the template title links to. Return null to render a plain title. */
getTemplateHref: (template: TTemplate) => string | null;
/** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */
renderTemplateMeta?: (template: TTemplate) => ReactNode;
/** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */
templatesHref?: string;
className?: string;
};
export const AnalyticsTemplateUsageCard = <TTemplate extends AnalyticsTemplate>({
query,
getTemplateHref,
renderTemplateMeta,
templatesHref,
className,
}: AnalyticsTemplateUsageCardProps<TTemplate>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
return (
<Card className={className} data-testid="analytics-template-usage">
<CardHeader>
<CardTitle>
<Trans>Template usage</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created from templates</Trans>
</CardDescription>
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 3 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-16" />
</li>
))}
</ul>
) : data.templates.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<FileTextIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">
<Trans>No documents were created from templates in this period</Trans>
</p>
{templatesHref && (
<Button variant="outline" size="sm" asChild>
<Link to={templatesHref}>
<Trans>View templates</Trans>
</Link>
</Button>
)}
</div>
) : (
<ol className="flex flex-col divide-y divide-border">
{data.templates.map((template, index) => {
const href = template.title === null ? null : getTemplateHref(template);
const meta = renderTemplateMeta?.(template);
return (
<li
key={template.id}
className="flex items-center gap-x-3 py-3 first:pt-0 last:pb-0"
data-testid="analytics-template-row"
>
<span className="w-5 shrink-0 text-muted-foreground text-xs tabular-nums" aria-hidden="true">
{index + 1}
</span>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
<FileTextIcon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
{template.title === null ? (
<span className="truncate text-muted-foreground text-sm">
<Trans>Unavailable template</Trans>
</span>
) : href !== null ? (
<Link to={href} className="truncate font-medium text-foreground text-sm hover:underline">
{template.title}
</Link>
) : (
<span className="truncate font-medium text-foreground text-sm">{template.title}</span>
)}
{(meta || template.updatedAt !== null) && (
<span className="truncate text-muted-foreground text-xs">
{meta}
{meta && template.updatedAt !== null && ' · '}
{template.updatedAt !== null && (
<Trans>Updated {formatRelativeDate(template.updatedAt, i18n.locale)}</Trans>
)}
</span>
)}
</div>
<span className="shrink-0 rounded-md border bg-muted px-2 py-0.5 font-medium text-foreground text-xs tabular-nums">
<Plural value={template.count} one="# use" other="# uses" />
</span>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
};
@@ -17,7 +17,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { defaultFilter as commandScore } from 'cmdk';
import {
@@ -596,9 +596,13 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
<span className="ml-auto text-muted-foreground text-xs">
{hasValidSearch ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
isVisibleCountCapped ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural value={totalVisibleCount} one="# result" other="# results" />
)
) : (
<Trans>{totalVisibleCount} items</Trans>
<Plural value={totalVisibleCount} one="# item" other="# items" />
)}
</span>
</div>
@@ -1,6 +1,9 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -22,6 +25,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const { organisations } = useSession();
const currentTeam = useOptionalCurrentTeam();
const currentOrganisation = useOptionalCurrentOrganisation();
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
{
@@ -37,18 +41,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
};
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
const navigationTeam =
currentTeam ??
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
if (!navigationTeam) {
return [
{
href: '/inbox',
text: t`Inbox`,
},
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
@@ -56,6 +61,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
const teamUrl = navigationTeam.url;
return [
{
href: `/t/${teamUrl}/documents`,
@@ -69,12 +76,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: '/inbox',
text: t`Inbox`,
},
...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole)
? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
},
];
}, [currentTeam, organisations]);
}, [currentTeam, currentOrganisation, organisations, t]);
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -1,66 +0,0 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus } from '@prisma/client';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: TRecipientLite;
documentStatus: DocumentStatus;
};
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
return (
<div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken,
})}
role={signingToken ? 'button' : undefined}
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
onClick={onRecipientClick}
>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div
className="text-muted-foreground text-sm"
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</div>
);
}
@@ -1,7 +1,18 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
import { match } from 'ts-pattern';
export const DirectTemplateInvalidPageView = () => {
/**
* Why a direct link template cannot be used. Each reason would otherwise only
* surface once the signer submits, since the template renders normally.
*/
export type DirectTemplateInvalidReason = 'MISSING_SIGNATURE_FIELD' | 'MISSING_CONTENT_IMAGE';
export type DirectTemplateInvalidPageViewProps = {
reason: DirectTemplateInvalidReason;
};
export const DirectTemplateInvalidPageView = ({ reason }: DirectTemplateInvalidPageViewProps) => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
@@ -12,10 +23,20 @@ export const DirectTemplateInvalidPageView = () => {
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
{match(reason)
.with('MISSING_SIGNATURE_FIELD', () => (
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field
assigned. Please contact the sender to update the template.
</Trans>
))
.with('MISSING_CONTENT_IMAGE', () => (
<Trans>
This direct link template cannot be used because one or more image contents have no image attached.
Please contact the sender to update the template.
</Trans>
))
.exhaustive()}
</p>
</div>
</div>
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TTemplate } from '@documenso/lib/types/template';
@@ -41,6 +42,7 @@ export const DirectTemplatePageView = ({
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
@@ -124,6 +126,13 @@ export const DirectTemplatePageView = ({
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
analytics.captureException(err, {
source: 'signing',
location: 'direct_template',
recipientId: directTemplateRecipient.id,
envelopeId: template.envelopeId,
});
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
@@ -11,6 +11,7 @@ import {
import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
@@ -223,27 +224,10 @@ export const DirectTemplateSigningForm = ({
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
return getNextDictatableRecipient({
recipients: template.recipients,
currentRecipientId: directRecipient.id,
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return (
@@ -435,7 +419,7 @@ export const DirectTemplateSigningForm = ({
fields={localFields}
fieldsValidated={fieldsValidated}
recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
allowDictateNextSigner={Boolean(nextRecipient && template.templateMeta?.allowDictateNextSigner)}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
/>
</div>
@@ -1,5 +1,7 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -7,11 +9,13 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password';
export type DocumentSigningAuthPasswordProps = {
open: boolean;
@@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({
}: DocumentSigningAuthPasswordProps) => {
const { t } = useLingui();
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Fetched on demand since this is only needed once the user opts for password auth.
const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, {
enabled: !!user,
});
const form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
@@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
// If the query fails we fall through to the regular password form rather than blocking.
const isPasswordSetupRequired =
!!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD);
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
@@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
@@ -79,9 +89,22 @@ export const DocumentSigningAuthPassword = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (user && isAuthMethodsPending) {
return (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (isPasswordSetupRequired) {
return <DocumentSigningAuthSetPassword onOpenChange={onOpenChange} />;
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{/* method="post" so a pre-hydration native submit can't leak the password into the URL. */}
<form method="post" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{formErrorCode && (
@@ -0,0 +1,63 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { PasswordSetupRequest } from '~/components/forms/password-setup-request';
export type DocumentSigningAuthSetPasswordProps = {
onOpenChange: (value: boolean) => void;
};
/**
* Shown in place of the password reauth form when the signed in user has no
* password (e.g. they signed up via OAuth or a passkey).
*
* Password based action auth is meant to prove more than possession of a session,
* so rather than letting the session set a password inline we send the user the
* verified reset link and ask them to come back.
*/
export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => {
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
return (
<div className="space-y-4">
{isEmailPasswordSigninEnabled ? (
<>
<Alert variant="neutral">
<AlertTitle>
<Trans>No password set</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Signing this field requires a password, but your account does not have one. We can email you a link to
set one. Once done, sign in again and return to this document to continue.
</Trans>
</AlertDescription>
</Alert>
<PasswordSetupRequest />
</>
) : (
<Alert variant="warning">
<AlertTitle>
<Trans>Password authentication unavailable</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Your account does not have a password and password sign in is disabled for this instance. Please contact
the document sender to use a different authentication method.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
};
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -39,6 +40,7 @@ export const DocumentSigningCheckboxField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -126,6 +128,13 @@ export const DocumentSigningCheckboxField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -157,6 +166,13 @@ export const DocumentSigningCheckboxField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -216,6 +232,13 @@ export const DocumentSigningCheckboxField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating the signature.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -88,6 +89,7 @@ export const DocumentSigningCompleteDialog = ({
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const analytics = useAnalytics();
const { t, i18n } = useLingui();
const { toast } = useToast();
@@ -100,8 +102,10 @@ export const DocumentSigningCompleteDialog = ({
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner);
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
@@ -179,6 +183,11 @@ export const DocumentSigningCompleteDialog = ({
return;
}
analytics.captureException(error, {
source: 'signing',
location: 'complete_document',
});
// This dialog owns the completion error toast for every signing surface
// so the user gets a specific, actionable message. Callers should run
// their own side effects (e.g. embeds posting document-error) and
@@ -317,7 +326,7 @@ export const DocumentSigningCompleteDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
{canDictateNextSigner && (
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -39,6 +40,7 @@ export const DocumentSigningDateField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -85,6 +87,13 @@ export const DocumentSigningDateField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -113,6 +122,13 @@ export const DocumentSigningDateField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -35,6 +36,7 @@ export const DocumentSigningDropdownField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -86,6 +88,13 @@ export const DocumentSigningDropdownField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -120,6 +129,13 @@ export const DocumentSigningDropdownField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -33,6 +34,7 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { email: providedEmail } = useRequiredDocumentSigningContext();
@@ -78,6 +80,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -106,6 +115,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -39,7 +39,11 @@ export type DocumentSigningFormProps = {
}) => Promise<void>;
isSubmitting: boolean;
fieldsValidated: () => void;
nextRecipient?: RecipientWithFields;
/**
* The dictatable next recipient, decided server-side. Only their identity
* is needed — for the dictation flag and the prefilled inputs.
*/
nextRecipient?: Pick<Recipient, 'name' | 'email'>;
};
export const DocumentSigningForm = ({
@@ -84,6 +88,10 @@ export const DocumentSigningForm = ({
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const allowDictateNextSigner = Boolean(nextRecipient && document.documentMeta?.allowDictateNextSigner);
const defaultNextSigner = nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined;
const localFieldsValidated = () => {
setValidateUninsertedFields(true);
fieldsValidated();
@@ -151,10 +159,8 @@ export const DocumentSigningForm = ({
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</div>
</div>
@@ -223,8 +229,8 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</form>
) : (
@@ -291,10 +297,8 @@ export const DocumentSigningForm = ({
})
}
recipient={recipient}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</div>
</>
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -38,6 +39,7 @@ export const DocumentSigningInitialsField = ({
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { fullName } = useRequiredDocumentSigningContext();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -84,6 +86,13 @@ export const DocumentSigningInitialsField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -112,6 +121,13 @@ export const DocumentSigningInitialsField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -39,6 +40,7 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
@@ -116,6 +118,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -144,6 +153,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,5 @@
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -47,6 +48,7 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -142,6 +144,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -193,6 +202,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
@@ -23,7 +22,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react';
@@ -61,6 +60,12 @@ export type DocumentSigningPageViewV1Props = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
/**
* The dictatable next recipient, computed server-side over the FULL
* recipient list — must not be re-derived from the role-scoped
* `allRecipients`.
*/
nextRecipient?: Pick<Recipient, 'name' | 'email'>;
branding: DocumentSigningBranding;
includeSenderDetails: boolean;
};
@@ -72,6 +77,7 @@ export const DocumentSigningPageViewV1 = ({
completedFields,
isRecipientsTurn,
allRecipients = [],
nextRecipient,
includeSenderDetails,
branding,
}: DocumentSigningPageViewV1Props) => {
@@ -83,8 +89,6 @@ export const DocumentSigningPageViewV1 = ({
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false);
@@ -118,12 +122,6 @@ export const DocumentSigningPageViewV1 = ({
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
@@ -142,34 +140,6 @@ export const DocumentSigningPageViewV1 = ({
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
const sortedRecipients = [...allRecipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
@@ -235,7 +235,10 @@ export const DocumentSigningPageViewV2 = () => {
</div>
</div>
<div className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto" ref={scrollableContainerRef}>
<div
className="embed--DocumentContainer min-w-0 flex-1 overflow-x-auto overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -267,6 +270,9 @@ export const DocumentSigningPageViewV2 = () => {
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
toolbar={['zoom']}
// Todo: Content - Decide how to manage zooming on mobile.
toolbarClassName="hidden lg:flex"
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -31,6 +32,7 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -91,6 +93,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -120,6 +129,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the selection.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -41,6 +42,7 @@ export function DocumentSigningRejectDialog({
}: DocumentSigningRejectDialogProps) {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@@ -76,6 +78,12 @@ export function DocumentSigningRejectDialog({
window.location.href = `/sign/${token}/rejected`;
}
} catch (err) {
analytics.captureException(err, {
source: 'signing',
location: 'reject_document',
documentId,
});
toast({
title: t`Error`,
description: t`An error occurred while rejecting the document. Please try again.`,
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -47,6 +48,7 @@ export const DocumentSigningSignatureField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient } = useDocumentSigningRecipientContext();
@@ -157,6 +159,13 @@ export const DocumentSigningSignatureField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
@@ -183,6 +192,13 @@ export const DocumentSigningSignatureField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
@@ -1,4 +1,5 @@
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -50,6 +51,7 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -170,6 +172,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -200,6 +209,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -6,6 +6,8 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups';
import { isRecipientBefore } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
@@ -236,12 +238,16 @@ export const EnvelopeSigningProvider = ({
}, [envelopeData.recipient.fields]);
/**
* Assistant recipients are those that have a signing order after the assistant.
* Assistant recipients are those positioned strictly after the assistant —
* never their own group peers.
*/
const assistantRecipients =
recipient.role === RecipientRole.ASSISTANT
? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0))
: [];
const assistantRecipients = useMemo(() => {
if (recipient.role !== RecipientRole.ASSISTANT) {
return [];
}
return envelope.recipients.filter((r) => isRecipientBefore(recipient, r));
}, [envelope.recipients, recipient]);
/**
* Assistant fields are those fulfill all of the following:
@@ -249,12 +255,11 @@ export const EnvelopeSigningProvider = ({
* - After the assistant signing order
* - Are not signature fields
*/
const assistantFields =
recipient.role === RecipientRole.ASSISTANT
? assistantRecipients
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
: [];
const assistantFields = useMemo(() => {
return assistantRecipients
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE));
}, [assistantRecipients]);
/**
* The recipient that the assistant has currently selected to sign on behalf of.
@@ -269,7 +274,7 @@ export const EnvelopeSigningProvider = ({
const selectedAssistantRecipientFields = useMemo(() => {
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
}, [recipientFields, selectedAssistantRecipient]);
}, [assistantFields, selectedAssistantRecipient]);
/**
* Fields that have been completed by other recipients.
@@ -290,32 +295,14 @@ export const EnvelopeSigningProvider = ({
.filter((field) => field.inserted);
const nextRecipient = useMemo(() => {
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
return null;
}
const sortedRecipients = [...envelope.recipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
return getNextDictatableRecipient({
recipients: envelope.recipients,
currentRecipientId: recipient.id,
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (
@@ -28,6 +28,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type DocumentEditFormProps = {
@@ -42,6 +43,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const cspNonce = useCspNonce();
const navigate = useNavigate();
@@ -473,6 +475,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddFieldsFormPartial
@@ -2,7 +2,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useQueryState } from 'nuqs';
import { useQueryStates } from 'nuqs';
import { useEffect, useState } from 'react';
import { documentsSearchParams } from '~/utils/documents-search-params';
@@ -10,16 +10,26 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
const { _ } = useLingui();
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [{ query }, setSearchParams] = useQueryStates(
{
query: documentsSearchParams.query,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
void setQuery(debouncedSearchTerm || null);
// Reset pagination so a new search never lands on an empty page.
void setSearchParams({
query: debouncedSearchTerm || null,
page: null,
});
}
}, [debouncedSearchTerm, query, setQuery]);
}, [debouncedSearchTerm, query, setSearchParams]);
return (
<Input
@@ -1,5 +1,4 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -38,7 +37,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
const team = useCurrentTeam();
const navigate = useNavigate();
const analytics = useAnalytics();
const organisation = useCurrentOrganisation();
const userTimezone =
@@ -103,12 +101,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
// Handle legacy template creation.
@@ -0,0 +1,131 @@
import type Konva from 'konva';
import type { LucideIcon } from 'lucide-react';
/**
* The gap between a selection and its floating action bar.
*/
const ACTION_BAR_OFFSET_PX = 5;
/**
* Resolve the absolute CSS position for an action bar hanging below the
* bottom center of a set of nodes.
*
* Uses the union of the nodes' own client rects rather than the transformer's,
* since the transformer rect includes its handles (e.g. the rotate anchor and
* its stem above the selection) which would otherwise push the bar away from
* the selection.
*
* Client rects are in scaled stage coordinates, matching the CSS pixel space
* of the konva container.
*/
const getNodesActionBarPosition = (nodes: Konva.Node[]) => {
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const node of nodes) {
const rect = node.getClientRect({ skipStroke: true, skipShadow: true });
minX = Math.min(minX, rect.x);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
if (!Number.isFinite(minX) || !Number.isFinite(maxY)) {
return null;
}
return {
top: `${maxY + ACTION_BAR_OFFSET_PX}px`,
left: `${minX + (maxX - minX) / 2}px`,
};
};
type EnvelopeCanvasActionBarProps = {
/**
* The Konva nodes the bar hangs below.
*/
nodes: Konva.Node[];
/**
* Hide the bar, e.g. while the nodes are being transformed.
*/
hidden?: boolean;
children: React.ReactNode;
};
/**
* A floating container positioned below the bottom center of a set of canvas
* nodes, for action bars and menus attached to a selection.
*
* Must be rendered within the page's relatively positioned container.
*/
export const EnvelopeCanvasActionBar = ({ nodes, hidden = false, children }: EnvelopeCanvasActionBarProps) => {
const position = nodes.length > 0 ? getNodesActionBarPosition(nodes) : null;
if (!position || hidden) {
return null;
}
return (
<div
data-testid="envelope-canvas-action-bar"
className="flex flex-col items-center"
style={{
position: 'absolute',
top: position.top,
left: position.left,
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
>
{children}
</div>
);
};
/**
* The floating pill which groups action buttons, themed like the viewer
* toolbar so the two float over the page consistently.
*
* The ring and shadow are stronger than the toolbar's since the bar sits
* directly on the page, which is usually white, rather than on the viewer
* background.
*/
export const EnvelopeCanvasActionButtonGroup = ({ children }: { children: React.ReactNode }) => {
return (
<div className="flex w-fit items-center gap-x-0.5 rounded-lg bg-popover p-1 text-popover-foreground shadow-black/25 shadow-lg ring-1 ring-black/15 dark:shadow-black/60 dark:ring-white/20">
{children}
</div>
);
};
/**
* A thin vertical separator between groups of actions within the pill.
*/
export const EnvelopeCanvasActionDivider = () => {
return <div className="mx-0.5 h-4 w-px bg-border" />;
};
type EnvelopeCanvasActionButtonProps = {
title: string;
icon: LucideIcon;
onClick: () => void;
};
export const EnvelopeCanvasActionButton = ({ title, icon: Icon, onClick }: EnvelopeCanvasActionButtonProps) => {
return (
<button
type="button"
title={title}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
onClick={onClick}
onTouchEnd={onClick}
>
<Icon className="h-3.5 w-3.5" />
</button>
);
};
@@ -0,0 +1,423 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import {
CONTENT_MAX_FONT_SIZE,
CONTENT_MAX_STROKE_WIDTH,
CONTENT_MIN_FONT_SIZE,
CONTENT_MIN_STROKE_WIDTH,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_CONTENT_HIGHLIGHT_COLOR,
DEFAULT_CONTENT_STROKE_COLOR,
DEFAULT_CONTENT_STROKE_WIDTH,
DEFAULT_CONTENT_TEXT_ALIGN,
DEFAULT_CONTENT_TEXT_COLOR,
DEFAULT_CONTENT_VERTICAL_ALIGN,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { FormField } from '@documenso/ui/primitives/form/form';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Slider } from '@documenso/ui/primitives/slider';
import { Trans, useLingui } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import {
ALargeSmallIcon,
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
AlignVerticalJustifyCenterIcon,
AlignVerticalJustifyEndIcon,
AlignVerticalJustifyStartIcon,
ImageUpIcon,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { DEFAULT_ENABLED_FILL_COLOR } from '~/components/forms/editor/editor-content-shape-form';
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
import {
type TContentHighlightFormSchema,
type TContentLineFormSchema,
type TContentShapeFormSchema,
type TContentTextFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EnvelopeCanvasActionButton, EnvelopeCanvasActionDivider } from './envelope-canvas-action-bar';
type EnvelopeCanvasContentActionsProps = {
/**
* The single selected content.
*/
content: TLocalContent;
};
/**
* The type specific quick actions for a single selected content, shown at the
* start of the content action bar: the frequently adjusted styles (colors,
* stroke width) and the image upload for image contents.
*
* The style actions are fields of the selected content's settings form, the
* same form the sidebar edits, so the two never disagree. The full set of
* settings remains in the sidebar. Renders nothing for types without quick
* actions.
*/
export const EnvelopeCanvasContentActions = ({ content }: EnvelopeCanvasContentActionsProps) => {
const { t } = useLingui();
const { content: formContent, isReady } = useContentSettingsForm();
// The canvas selection and the settings form follow each other, but guard
// against a render in between so a stale selection never edits another
// content's form, and wait for the form to hold this content's values.
if (formContent?.formId !== content.formId || !isReady) {
return null;
}
const actions = match(content.contentMeta)
.with({ type: EnvelopeContentType.TEXT }, () => <TextContentActions />)
.with({ type: EnvelopeContentType.LINE }, () => <LineContentActions />)
.with({ type: EnvelopeContentType.SHAPE }, () => <ShapeContentActions />)
.with({ type: EnvelopeContentType.HIGHLIGHT }, () => <HighlightContentActions />)
.with({ type: EnvelopeContentType.IMAGE }, () => (
<EnvelopeCanvasActionButton
title={content.dataContentId ? t`Replace image` : t`Upload image`}
icon={ImageUpIcon}
onClick={() => void ContentImageUploadDialog.call({ formId: content.formId })}
/>
))
.exhaustive();
return (
<>
{actions}
<EnvelopeCanvasActionDivider />
</>
);
};
const TextContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentTextFormSchema>();
return (
<>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<ColorAction
title={t`Text color`}
value={field.value ?? DEFAULT_CONTENT_TEXT_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="fontSize"
render={({ field }) => (
<FontSizeAction value={field.value ?? DEFAULT_CONTENT_FONT_SIZE} onChange={field.onChange} />
)}
/>
<FormField
control={form.control}
name="textAlign"
render={({ field }) => (
<ChoiceAction
title={t`Text align`}
value={field.value ?? DEFAULT_CONTENT_TEXT_ALIGN}
options={[
{ value: 'left', icon: AlignLeftIcon, label: t`Left` },
{ value: 'center', icon: AlignCenterIcon, label: t`Center` },
{ value: 'right', icon: AlignRightIcon, label: t`Right` },
]}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="verticalAlign"
render={({ field }) => (
<ChoiceAction
title={t`Vertical align`}
value={field.value ?? DEFAULT_CONTENT_VERTICAL_ALIGN}
options={[
{ value: 'top', icon: AlignVerticalJustifyStartIcon, label: t`Top` },
{ value: 'middle', icon: AlignVerticalJustifyCenterIcon, label: t`Middle` },
{ value: 'bottom', icon: AlignVerticalJustifyEndIcon, label: t`Bottom` },
]}
onChange={field.onChange}
/>
)}
/>
</>
);
};
const LineContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentLineFormSchema>();
return (
<>
<FormField
control={form.control}
name="strokeColor"
render={({ field }) => (
<ColorAction
title={t`Line color`}
value={field.value ?? DEFAULT_CONTENT_STROKE_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="strokeWidth"
render={({ field }) => (
<StrokeWidthAction value={field.value ?? DEFAULT_CONTENT_STROKE_WIDTH} onChange={field.onChange} />
)}
/>
</>
);
};
const ShapeContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentShapeFormSchema>();
return (
<>
<FormField
control={form.control}
name="strokeColor"
render={({ field }) => (
<ColorAction
title={t`Border color`}
value={field.value ?? DEFAULT_CONTENT_STROKE_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="fillColor"
render={({ field }) => (
<ColorAction
title={field.value ? t`Fill color` : t`Add fill`}
value={field.value}
defaultValue={DEFAULT_ENABLED_FILL_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="strokeWidth"
render={({ field }) => (
<StrokeWidthAction value={field.value ?? DEFAULT_CONTENT_STROKE_WIDTH} onChange={field.onChange} />
)}
/>
</>
);
};
const HighlightContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentHighlightFormSchema>();
return (
<FormField
control={form.control}
name="color"
render={({ field }) => (
<ColorAction
title={t`Highlight color`}
value={field.value ?? DEFAULT_CONTENT_HIGHLIGHT_COLOR}
onChange={field.onChange}
/>
)}
/>
);
};
type ColorActionProps = {
title: string;
/**
* The current color, or null when none is set (e.g. no fill).
*/
value: string | null;
/**
* The color the picker starts from when none is set.
*/
defaultValue?: string;
onChange: (color: string) => void;
};
/**
* A swatch button which opens a color picker. An unset color shows a struck
* through swatch.
*/
const ColorAction = ({ title, value, defaultValue, onChange }: ColorActionProps) => {
return (
<ColorPicker
value={value ?? ''}
defaultValue={defaultValue}
onChange={onChange}
trigger={
<button type="button" title={title} className="rounded-md p-1.5 transition-colors hover:bg-muted">
<span
className="relative block h-3.5 w-3.5 overflow-hidden rounded-full ring-1 ring-black/20 ring-inset dark:ring-white/30"
style={{ backgroundColor: value ?? 'transparent' }}
>
{!value && <span className="absolute -inset-x-1 top-1/2 h-px -translate-y-1/2 rotate-45 bg-destructive" />}
</span>
</button>
}
/>
);
};
type FontSizeActionProps = {
value: number;
onChange: (fontSize: number) => void;
};
/**
* A button showing the current font size which opens a slider.
*/
const FontSizeAction = ({ value, onChange }: FontSizeActionProps) => {
const { t } = useLingui();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={t`Font size`}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
<ALargeSmallIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<Trans>Font size</Trans>
</span>
<span className="tabular-nums">{value}</span>
</div>
<Slider
value={[value]}
min={CONTENT_MIN_FONT_SIZE}
max={CONTENT_MAX_FONT_SIZE}
step={1}
onValueChange={([fontSize]) => onChange(fontSize)}
/>
</PopoverContent>
</Popover>
);
};
type ChoiceActionProps<T extends string> = {
title: string;
value: T;
options: { value: T; icon: LucideIcon; label: string }[];
onChange: (value: T) => void;
};
/**
* A button showing the icon of the current choice which opens the other
* choices, e.g. text alignment.
*/
const ChoiceAction = <T extends string>({ title, value, options, onChange }: ChoiceActionProps<T>) => {
const current = options.find((option) => option.value === value) ?? options[0];
const CurrentIcon = current.icon;
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={title}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
<CurrentIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="flex w-auto gap-0.5 p-1" onOpenAutoFocus={(event) => event.preventDefault()}>
{options.map((option) => {
const OptionIcon = option.icon;
return (
<button
key={option.value}
type="button"
title={option.label}
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground aria-pressed:bg-muted aria-pressed:text-foreground"
>
<OptionIcon className="h-4 w-4" />
</button>
);
})}
</PopoverContent>
</Popover>
);
};
type StrokeWidthActionProps = {
value: number;
onChange: (width: number) => void;
};
/**
* A button showing the current stroke width which opens a slider.
*/
const StrokeWidthAction = ({ value, onChange }: StrokeWidthActionProps) => {
const { t } = useLingui();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={t`Thickness`}
className="flex h-[26px] w-[26px] items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
{/* A bar whose thickness follows the value, so the current width is still glanceable. */}
<span className="block w-3.5 rounded-full bg-current" style={{ height: Math.max(1.5, Math.min(value, 6)) }} />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<Trans>Thickness</Trans>
</span>
<span className="tabular-nums">{value}</span>
</div>
<Slider
value={[value]}
min={CONTENT_MIN_STROKE_WIDTH}
max={CONTENT_MAX_STROKE_WIDTH}
step={0.5}
onValueChange={([width]) => onChange(width)}
/>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,169 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { fieldButtonList } from '../envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from '../envelope-recipient-selector';
import { EnvelopeCanvasActionButton, EnvelopeCanvasActionButtonGroup } from './envelope-canvas-action-bar';
type EnvelopeCanvasFieldActionButtonsProps = {
selectedFieldFormIds: string[];
onDuplicate: () => void;
onDuplicateOnAllPages: () => void;
onDelete: () => void;
onChangeRecipient: (recipientId: number) => void;
onChangeFieldType: (type: FieldType) => void;
};
/**
* The floating actions for the selected fields.
*/
export const EnvelopeCanvasFieldActionButtons = ({
selectedFieldFormIds,
onDuplicate,
onDuplicateOnAllPages,
onDelete,
onChangeRecipient,
onChangeFieldType,
}: EnvelopeCanvasFieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
const selectedFields = useMemo(
() => editorFields.localFields.filter((field) => selectedFieldFormIds.includes(field.formId)),
[editorFields.localFields, selectedFieldFormIds],
);
/**
* Decide the preselected field type in the command input.
*
* If all fields share the same type, use that as the default selection.
* Otherwise show no preselection.
*/
const preselectedFieldType = useMemo(() => {
if (selectedFields.length === 0) {
return null;
}
const firstType = selectedFields[0].type;
const isTypesSame = selectedFields.every((field) => field.type === firstType);
return isTypesSame ? firstType : null;
}, [selectedFields]);
/**
* Decide the preselected recipient in the command input.
*
* If all fields belong to the same recipient then use that recipient as the default.
*
* Otherwise show the placeholder.
*/
const preselectedRecipient = useMemo(() => {
if (selectedFields.length === 0) {
return null;
}
const recipient = envelope.recipients.find((recipient) => recipient.id === selectedFields[0].recipientId);
if (!recipient) {
return null;
}
const isRecipientsSame = selectedFields.every((field) => field.recipientId === recipient.id);
return isRecipientsSame ? recipient : null;
}, [selectedFields, envelope.recipients]);
return (
<>
<EnvelopeCanvasActionButtonGroup>
<EnvelopeCanvasActionButton
title={t`Change Recipient`}
icon={UserCircleIcon}
onClick={() => setShowRecipientSelector(true)}
/>
<EnvelopeCanvasActionButton
title={t`Change Field Type`}
icon={ShapesIcon}
onClick={() => setShowFieldTypeSelector(true)}
/>
<EnvelopeCanvasActionButton title={t`Duplicate`} icon={CopyPlusIcon} onClick={onDuplicate} />
<EnvelopeCanvasActionButton
title={t`Duplicate on all pages`}
icon={SquareStackIcon}
onClick={onDuplicateOnAllPages}
/>
<EnvelopeCanvasActionButton title={t`Remove`} icon={TrashIcon} onClick={onDelete} />
</EnvelopeCanvasActionButtonGroup>
<CommandDialog position="start" open={showRecipientSelector} onOpenChange={setShowRecipientSelector}>
<EnvelopeRecipientSelectorCommand
placeholder={t`Select a recipient`}
selectedRecipient={preselectedRecipient}
onSelectedRecipientChange={(recipient) => {
editorFields.setSelectedRecipient(recipient.id);
onChangeRecipient(recipient.id);
setShowRecipientSelector(false);
}}
recipients={envelope.recipients}
fields={envelope.fields}
/>
</CommandDialog>
<CommandDialog position="start" open={showFieldTypeSelector} onOpenChange={setShowFieldTypeSelector}>
<Command defaultValue={preselectedFieldType ? t(FRIENDLY_FIELD_TYPE[preselectedFieldType]) : undefined}>
<CommandInput placeholder={t`Select a field type`} />
<CommandList>
<CommandEmpty>
<span className="inline-block px-4 text-muted-foreground">
{t`No field type matching this description was found.`}
</span>
</CommandEmpty>
<CommandGroup>
{fieldButtonList.map((field) => {
const FieldIcon = field.icon;
const label = t(FRIENDLY_FIELD_TYPE[field.type]);
return (
<CommandItem
key={field.type}
className="px-2"
onSelect={() => {
onChangeFieldType(field.type);
setShowFieldTypeSelector(false);
}}
>
<FieldIcon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
</>
);
};
@@ -0,0 +1,40 @@
import { useLingui } from '@lingui/react/macro';
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
type EnvelopeCanvasPendingContentMenuProps = {
/**
* The palette items on offer, e.g. without images once the organisation's
* image allowance is used up.
*/
items: ContentDragDropItem[];
onSelectItem: (item: ContentDragDropItem) => void;
};
/**
* The content type picker shown after drawing a marquee on an empty area of
* the page while editing contents, to create a content of that size.
*/
export const EnvelopeCanvasPendingContentMenu = ({ items, onSelectItem }: EnvelopeCanvasPendingContentMenuProps) => {
const { t } = useLingui();
return (
<div
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="flex w-max items-center gap-x-1 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{items.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onSelectItem(item)}
className="flex flex-shrink-0 items-center gap-x-1.5 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
<item.icon className="h-3.5 w-3.5" />
{t(item.name)}
</button>
))}
</div>
);
};
@@ -0,0 +1,34 @@
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { fieldButtonList } from '../envelope-editor-fields-drag-drop';
type EnvelopeCanvasPendingFieldMenuProps = {
onSelectType: (type: FieldType) => void;
};
/**
* The field type picker shown after drawing a marquee on an empty area of the
* page, to create a field of that size.
*/
export const EnvelopeCanvasPendingFieldMenu = ({ onSelectType }: EnvelopeCanvasPendingFieldMenuProps) => {
const { t } = useLingui();
return (
<div
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{fieldButtonList.map((field) => (
<button
key={field.type}
type="button"
onClick={() => onSelectType(field.type)}
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
{t(field.name)}
</button>
))}
</div>
);
};
@@ -0,0 +1,59 @@
import { CONTENT_GROUP_NODE_NAME } from '@documenso/lib/universal/content-renderer/content-renderer';
import type Konva from 'konva';
import type { RefObject } from 'react';
/**
* The shared canvas handles and page metrics passed to the envelope canvas
* hooks, sourced from `usePageRenderer` and the page render data.
*/
export type EnvelopeCanvas = {
stage: RefObject<Konva.Stage | null>;
pageLayer: RefObject<Konva.Layer | null>;
/**
* The scale of the stage relative to the unscaled page.
*/
scale: number;
pageNumber: number;
/**
* The raw page size in pixels.
*/
unscaledViewport: { width: number; height: number };
/**
* The page size in pixels as rendered on the stage.
*/
scaledViewport: { width: number; height: number };
};
export type EnvelopeCanvasSelectionKind = 'field' | 'content';
/**
* The current canvas selection. Fields and contents share one transformer, so
* only one kind can be selected at a time.
*/
export type EnvelopeCanvasSelection = {
kind: EnvelopeCanvasSelectionKind;
groups: Konva.Group[];
} | null;
/**
* The Konva group names for each selectable kind.
*/
export const ENVELOPE_CANVAS_GROUP_NAMES: Record<EnvelopeCanvasSelectionKind, string> = {
// Defined by the field renderer in `field-generic-items.ts`.
field: 'field-group',
content: CONTENT_GROUP_NODE_NAME,
};
/**
* A box in scaled stage coordinates, e.g. from a client rect.
*/
export type EnvelopeCanvasBox = {
x: number;
y: number;
width: number;
height: number;
};
@@ -0,0 +1,112 @@
import Konva from 'konva';
import type { EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
type ReconcileEnvelopeCanvasGroupsOptions<T> = {
layer: Konva.Layer;
/**
* The Konva group name of the items being reconciled.
*/
groupName: string;
items: T[];
getRenderId: (item: T) => string;
render: (item: T) => void;
};
/**
* Sync a layer's groups of a given name with a list of items.
*
* Groups whose ID no longer matches an item are destroyed, then every item is
* (re)rendered so existing groups pick up any changes.
*/
export const reconcileEnvelopeCanvasGroups = <T>({
layer,
groupName,
items,
getRenderId,
render,
}: ReconcileEnvelopeCanvasGroupsOptions<T>) => {
const renderIds = new Set(items.map(getRenderId));
layer.find('Group').forEach((group) => {
if (group.name() === groupName && !renderIds.has(group.id())) {
group.destroy();
}
});
for (const item of items) {
render(item);
}
};
/**
* Filter selected groups down to those which are still attached to the stage
* and still correspond to an item on the page, e.g. after items are deleted
* or the page is resynced.
*/
export const getLiveEnvelopeCanvasGroups = (groups: Konva.Group[], isOnPage: (renderId: string) => boolean) => {
return groups.filter((group) => Boolean(group.getStage()) && Boolean(group.getParent()) && isOnPage(group.id()));
};
type SyncEditorSelectionToCanvasOptions = {
layer: Konva.Layer;
kind: EnvelopeCanvasSelectionKind;
/**
* The form ID of the item selected within the editor, if any.
*/
editorFormId: string | null;
isOnPage: (renderId: string) => boolean;
/**
* The currently selected groups of this kind.
*/
selectedGroups: Konva.Group[];
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => void;
clear: () => void;
};
/**
* Sync the editor's single selected item onto the canvas selection.
*
* Creating an item marks it as selected within the editor, so this makes a
* newly placed item show its transformer immediately without a second click.
* It also clears the canvas selection when the editor selection is cleared,
* so a stale transformer can't linger.
*
* Must run after the groups have been rendered so the item's group exists.
*/
export const syncEditorSelectionToCanvas = ({
layer,
kind,
editorFormId,
isOnPage,
selectedGroups,
select,
clear,
}: SyncEditorSelectionToCanvasOptions) => {
const isSingleSelection = selectedGroups.length === 1;
if (editorFormId && isOnPage(editorFormId)) {
const isAlreadySelected = isSingleSelection && selectedGroups[0].id() === editorFormId;
if (isAlreadySelected) {
return;
}
const groupToSelect = layer.findOne(`#${editorFormId}`);
if (groupToSelect instanceof Konva.Group) {
select(kind, [groupToSelect]);
}
return;
}
if (editorFormId === null && isSingleSelection) {
clear();
}
};
@@ -0,0 +1,448 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelopeContentMeta } from '@documenso/lib/types/envelope-content-meta';
import {
CONTENT_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
EnvelopeContentShapeType,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import {
readContentGroupTransform,
resolveContentMetaFromTransform,
} from '@documenso/lib/universal/content-renderer/content-geometry';
import {
MIN_CONTENT_HEIGHT_PX,
MIN_CONTENT_WIDTH_PX,
} from '@documenso/lib/universal/content-renderer/content-renderer';
import { renderContent } from '@documenso/lib/universal/content-renderer/render-content';
import { getLocalContentOrderId, sortContentsForRender } from '@documenso/lib/utils/envelope-content';
import { getClientSideContentTranslations } from '@documenso/lib/utils/envelope-content-translations';
import type { PercentageBox } from '@documenso/lib/utils/geometry';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
import {
getLiveEnvelopeCanvasGroups,
reconcileEnvelopeCanvasGroups,
syncEditorSelectionToCanvas,
} from './reconcile-envelope-canvas-groups';
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const PENDING_CONTENT_NODE_NAME = 'pending-content-creation';
type UseEnvelopeCanvasContentsLayerOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
/**
* Whether contents can currently be edited on the canvas.
*/
isEditable: boolean;
/**
* Whether the envelope has used up the organisation's content allowance,
* in which case no further contents can be created from the canvas.
*/
isContentLimitReached: boolean;
applyPageItemsVisibility: () => void;
};
/**
* Renders and manages the contents of the current page on the canvas.
*
* Owns content rendering, z-ordering relative to fields, geometry write back,
* the marquee-to-create flow and the selection actions for contents.
*/
export const useEnvelopeCanvasContentsLayer = ({
canvas,
selection,
isEditable,
isContentLimitReached,
applyPageItemsVisibility,
}: UseEnvelopeCanvasContentsLayerOptions) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorContents, selectedEditorTab } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
const { stage, pageLayer, scale, pageNumber, unscaledViewport } = canvas;
const { contentGroups: selectedGroups } = selection;
/**
* The rectangle drawn via the marquee which is pending a content type choice.
*/
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_CONTENT_NODE_NAME });
/**
* The page's contents in stacking order, so rendering them in sequence
* puts the last one on top. Local contents are ordered by their form ID as
* the tiebreak, the same way persisted contents are ordered by their ID.
*/
const localPageContents = useMemo(
() =>
sortContentsForRender(
editorContents.localContents
.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
)
.map((content) => ({ ...content, id: getLocalContentOrderId(content) })),
(content) => content.contentMeta.zIndex,
),
[editorContents.localContents, pageNumber, currentEnvelopeItem?.id],
);
const isOnPage = (formId: string) => localPageContents.some((content) => content.formId === formId);
/**
* Write the new geometry of a content back into its content meta after a drag
* or resize/rotate gesture.
*/
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
const contentGroup = event.target as Konva.Group;
const contentFormId = contentGroup.id();
const content = editorContents.getContentByFormId(contentFormId);
if (!content) {
return;
}
editorContents.updateContentByFormId(contentFormId, {
contentMeta: resolveContentMetaFromTransform(
content.contentMeta,
readContentGroupTransform(contentGroup),
isDragEvent ? 'drag' : 'transform',
unscaledViewport.width,
unscaledViewport.height,
),
});
// Select the content if it is not already selected.
if (isDragEvent && !selection.isSelected(contentGroup)) {
selection.select('content', [contentGroup]);
}
pageLayer.current?.batchDraw();
};
const unsafeRenderContent = (content: TLocalContent) => {
if (!pageLayer.current) {
return;
}
const { contentGroup } = renderContent(
{
renderId: content.formId,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
},
{
pageLayer: pageLayer.current,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: 'edit',
editable: isEditable,
translations: getClientSideContentTranslations(i18n),
hoverOutlineColor: getRecipientColorStyles('green').baseRing,
images: contentImages.images,
},
);
// Contents are inert while the fields tab is active so they behave as part
// of the document. Their stacking relative to the fields is applied to
// all of them together afterwards, see `applyContentsStacking`.
contentGroup.listening(isEditable);
if (!isEditable) {
return;
}
contentGroup.off('click');
contentGroup.off('transformend');
contentGroup.off('dragend');
// A plain click selects just this content, shift + click toggles it
// in/out of the current selection.
contentGroup.on('click', (event) => {
if (event.evt.shiftKey) {
selection.toggle('content', contentGroup);
} else {
selection.select('content', [contentGroup]);
}
pageLayer.current?.batchDraw();
});
contentGroup.on('transformend', handleResizeOrMove);
contentGroup.on('dragend', handleResizeOrMove);
};
const renderContentOnLayer = (content: TLocalContent) => {
try {
unsafeRenderContent(content);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
/**
* Place the contents as a band relative to the fields, keeping their
* stacking order intact: above the muted fields while the contents tab is
* active so they are fully visible and clickable, beneath the fields
* otherwise as part of the document.
*
* Konva stacks by child order, so the band is moved one group at a time.
* Sending to the bottom iterates in reverse so the last content stays on
* top, as when sending to the top in order.
*/
const applyContentsStacking = () => {
const layer = pageLayer.current;
if (!layer) {
return;
}
const groups = localPageContents.flatMap((content) => {
const group = layer.findOne(`#${content.formId}`);
return group instanceof Konva.Group ? [group] : [];
});
if (selectedEditorTab === 'contents') {
for (const group of groups) {
group.moveToTop();
}
} else {
for (const group of [...groups].reverse()) {
group.moveToBottom();
}
}
};
const renderAll = () => {
for (const content of localPageContents) {
renderContentOnLayer(content);
}
applyContentsStacking();
};
/**
* Resolve a marquee selection box into a content selection, or into a
* pending content creation when nothing was selected and the box is large
* enough.
*/
const selectInBox = (box: EnvelopeCanvasBox) => {
const currentStage = stage.current;
if (!currentStage || !isEditable) {
return;
}
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.content, box);
selection.select('content', groupsInBox);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a content if no items are selected or the size is too small.
if (
groupsInBox.length === 0 &&
!isContentLimitReached &&
unscaledBoxWidth > MIN_CONTENT_WIDTH_PX &&
unscaledBoxHeight > MIN_CONTENT_HEIGHT_PX
) {
pending.setPendingFromBox(box);
}
};
/**
* Create a content of the given palette item from the pending creation
* rectangle.
*
* Unlike a palette drop, which places a content at its default size, the
* content is fitted to the box the author drew.
*/
const createFromPending = (item: ContentDragDropItem) => {
const box = pending.getPendingBox();
pending.clearPending();
if (!box || !currentEnvelopeItem) {
return;
}
editorContents.addContent({
envelopeItemId: currentEnvelopeItem.id,
contentMeta: buildContentMetaFromBox(item, pageNumber, box),
});
};
/**
* Render contents when they are added, removed or updated, and when an
* image arrives after the page was created (a failed image simply keeps
* its placeholder so the author can re-upload).
*/
useEffect(() => {
const layer = pageLayer.current;
if (!layer || !stage.current) {
return;
}
reconcileEnvelopeCanvasGroups({
layer,
groupName: ENVELOPE_CANVAS_GROUP_NAMES.content,
items: localPageContents,
getRenderId: (content) => content.formId,
render: renderContentOnLayer,
});
applyContentsStacking();
// Reconcile selection state with live content nodes after flush/sync updates.
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
if (liveSelectedGroups.length !== selectedGroups.length) {
selection.select('content', liveSelectedGroups);
}
// Only sync while editable, otherwise a transformer would be attached
// to an inert group.
if (isEditable) {
syncEditorSelectionToCanvas({
layer,
kind: 'content',
editorFormId: editorContents.selectedContent?.formId ?? null,
isOnPage,
selectedGroups,
select: selection.select,
clear: selection.clear,
});
}
applyPageItemsVisibility();
selection.refreshTransformer();
layer.batchDraw();
}, [
localPageContents,
selectedGroups,
selectedEditorTab,
isEditable,
editorContents.selectedContent?.formId,
contentImages.images,
]);
/**
* Selecting a single content brings it to the front of its page, so the
* content being worked on is never hidden behind another.
*/
useEffect(() => {
if (isEditable && selectedGroups.length === 1) {
editorContents.bringContentToFront(selectedGroups[0].id());
}
}, [isEditable, selectedGroups]);
/**
* Clear any active content selection and pending content creation when
* contents are no longer editable, e.g. when switching back to the fields
* tab or hiding contents via the viewer toolbar.
*/
useEffect(() => {
if (!isEditable && (selectedGroups.length > 0 || pending.pendingCreation)) {
pending.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}
}, [isEditable, selectedGroups, pending.pendingCreation]);
const getSelectedContents = () =>
selectedGroups
.map((group) => editorContents.getContentByFormId(group.id()))
.filter((content) => content !== undefined);
const deleteSelected = () => {
editorContents.removeContentsByFormId(selectedGroups.map((group) => group.id()));
selection.clear();
};
const duplicateSelected = () => {
for (const content of getSelectedContents()) {
editorContents.duplicateContent(content);
}
};
return {
localPageContents,
renderAll,
selectInBox,
pendingCreation: pending.pendingCreation,
createFromPending,
clearPending: pending.clearPending,
deleteSelected,
duplicateSelected,
};
};
/**
* Build the meta of a content created from a box drawn on the page, merging
* the box into the default values of the item.
*
* Box contents take the box as their bounds. Lines have no box of their own,
* so they run horizontally along its top edge, from the top left to the top
* right corner.
*/
const buildContentMetaFromBox = (item: ContentDragDropItem, page: number, box: PercentageBox): TEnvelopeContentMeta => {
// Shapes share a content type and are told apart by `shape`.
const defaultMeta =
item.type === EnvelopeContentType.SHAPE
? CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]
: CONTENT_META_DEFAULT_VALUES[item.type];
const meta = { ...structuredClone(defaultMeta), page };
if (meta.type === EnvelopeContentType.LINE) {
return {
...meta,
x1: box.positionX,
y1: box.positionY,
x2: box.positionX + box.width,
y2: box.positionY,
};
}
return {
...meta,
positionX: box.positionX,
positionY: box.positionY,
width: box.width,
height: box.height,
};
};
@@ -0,0 +1,472 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { EnvelopePageItemsVisibility } from '@documenso/lib/types/envelope-page-items-visibility';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { MIN_FIELD_HEIGHT_PX, MIN_FIELD_WIDTH_PX } from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
import {
getLiveEnvelopeCanvasGroups,
reconcileEnvelopeCanvasGroups,
syncEditorSelectionToCanvas,
} from './reconcile-envelope-canvas-groups';
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const PENDING_FIELD_NODE_NAME = 'pending-field-creation';
type UseEnvelopeCanvasFieldsLayerOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
fieldsVisibility: EnvelopePageItemsVisibility;
applyPageItemsVisibility: () => void;
};
/**
* Renders and manages the fields of the current page on the canvas.
*
* Owns field rendering, overlap highlighting, geometry write back, the
* marquee-to-create flow and the selection actions for fields.
*/
export const useEnvelopeCanvasFieldsLayer = ({
canvas,
selection,
fieldsVisibility,
applyPageItemsVisibility,
}: UseEnvelopeCanvasFieldsLayerOptions) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { stage, pageLayer, scale, pageNumber, unscaledViewport, scaledViewport } = canvas;
const { fieldGroups: selectedGroups, isTransforming } = selection;
/**
* The rectangle drawn via the marquee which is pending a field type choice.
*/
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_FIELD_NODE_NAME });
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
const isOnPage = (formId: string) => localPageFields.some((field) => field.formId === formId);
/**
* Debounce the fields used for overlap highlighting so we don't recompute on every
* small drag/resize tick. Overlaps only occur within the same page and envelope
* item, so computing from this page's fields alone is sufficient.
*/
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
const overlappingFieldFormIds = useMemo(() => {
const formIds = new Set<string>();
const pairs = getOverlappingFieldPairs(
debouncedPageFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
);
for (const pair of pairs) {
formIds.add(pair.fieldA.id);
formIds.add(pair.fieldB.id);
}
return formIds;
}, [debouncedPageFields]);
/**
* Write the new geometry of a field back after a drag or resize gesture.
*/
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const {
width: fieldPixelWidth,
height: fieldPixelHeight,
x: fieldX,
y: fieldY,
} = fieldGroup.getClientRect({
skipStroke: true,
skipShadow: true,
});
const pageHeight = scaledViewport.height;
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100;
const positionPercentY = (fieldY / pageHeight) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldPixelWidth / pageWidth) * 100;
const fieldPageHeight = (fieldPixelHeight / pageHeight) * 100;
const fieldUpdates: Partial<TLocalField> = {
positionX: positionPercentX,
positionY: positionPercentY,
};
// Do not update the width/height unless the field has actually been resized.
// This is because our calculations will shift the width/height slightly
// due to the way we convert between pixel and percentage.
if (!isDragEvent) {
fieldUpdates.width = fieldPageWidth;
fieldUpdates.height = fieldPageHeight;
}
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected.
if (isDragEvent && !selection.isSelected(fieldGroup)) {
selection.select('field', [fieldGroup]);
}
pageLayer.current?.batchDraw();
};
/**
* Draws (or removes) a dashed warning outline over a field that significantly
* overlaps another field. The highlight is a child of the field group so it moves
* and resizes with the field, and sits on top of the field's own rect (which is
* re-styled on every render and would otherwise clobber a direct stroke change).
*/
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
// Skip while a field is actively being dragged/resized. The highlight is driven
// by debounced field data, so it would lag behind and distort during the gesture.
// It is repainted once the gesture settles (the effect re-runs on isTransforming).
if (isTransforming || !isOverlapping) {
existingHighlight?.destroy();
return;
}
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const highlightAttrs = {
x: 0,
y: 0,
width: fieldRect.width(),
height: fieldRect.height(),
stroke: '#f59e0b',
strokeWidth: 2,
dash: [6, 4],
cornerRadius: 2,
strokeScaleEnabled: false,
listening: false,
} satisfies Partial<Konva.RectConfig>;
if (existingHighlight instanceof Konva.Rect) {
existingHighlight.setAttrs(highlightAttrs);
existingHighlight.moveToTop();
return;
}
const highlight = new Konva.Rect({
name: 'field-overlap-highlight',
...highlightAttrs,
});
fieldGroup.add(highlight);
highlight.moveToTop();
};
const unsafeRenderField = (field: TLocalField) => {
if (!pageLayer.current) {
return;
}
// Muted fields are rendered with the read-only styling and cannot be
// edited, mirroring how other recipients' fields look during signing.
const isMuted = fieldsVisibility === 'muted';
const recipient = envelope.recipients.find((r) => r.id === field.recipientId);
const isFieldEditable =
!isMuted && recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: isMuted ? 'readOnly' : getRecipientColorKey(field.recipientId),
editable: isFieldEditable,
mode: 'edit',
});
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
if (!isFieldEditable) {
return;
}
fieldGroup.off('click');
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// A plain click selects just this field, shift + click toggles it in/out
// of the current selection.
fieldGroup.on('click', (event) => {
pending.clearPending();
if (event.evt.shiftKey) {
selection.toggle('field', fieldGroup);
} else {
selection.select('field', [fieldGroup]);
}
pageLayer.current?.batchDraw();
});
fieldGroup.on('transformend', handleResizeOrMove);
fieldGroup.on('dragend', handleResizeOrMove);
};
const renderFieldOnLayer = (field: TLocalField) => {
try {
unsafeRenderField(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
/**
* Render every field on the page. Called when the page canvas is created.
*/
const renderAll = () => {
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
};
/**
* Resolve a marquee selection box into a field selection, or into a pending
* field creation when nothing was selected and the box is large enough.
*/
const selectInBox = (box: EnvelopeCanvasBox) => {
const currentStage = stage.current;
if (!currentStage) {
return;
}
// While fields are hidden they cannot be selected or created.
if (fieldsVisibility !== 'visible') {
return;
}
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.field, box);
selection.select('field', groupsInBox);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small.
if (
groupsInBox.length === 0 &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) {
pending.setPendingFromBox(box);
}
};
/**
* Create a field of the given type from the pending creation rectangle.
*/
const createFromPending = (type: FieldType) => {
const box = pending.getPendingBox();
pending.clearPending();
if (!box || !currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type,
positionX: box.positionX,
positionY: box.positionY,
width: box.width,
height: box.height,
recipientId: editorFields.selectedRecipient.id,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
});
};
/**
* Render fields when they are added, removed or updated.
*/
useEffect(() => {
const layer = pageLayer.current;
if (!layer || !stage.current) {
return;
}
reconcileEnvelopeCanvasGroups({
layer,
groupName: ENVELOPE_CANVAS_GROUP_NAMES.field,
items: localPageFields,
getRenderId: (field) => field.formId,
render: renderFieldOnLayer,
});
// Reconcile selection state with live field nodes after flush/sync updates.
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
if (liveSelectedGroups.length !== selectedGroups.length) {
selection.select('field', liveSelectedGroups);
}
syncEditorSelectionToCanvas({
layer,
kind: 'field',
editorFormId: editorFields.selectedField?.formId ?? null,
isOnPage,
selectedGroups,
select: selection.select,
clear: selection.clear,
});
applyPageItemsVisibility();
selection.refreshTransformer();
layer.batchDraw();
}, [
localPageFields,
selectedGroups,
overlappingFieldFormIds,
isTransforming,
editorFields.selectedField?.formId,
fieldsVisibility,
]);
/**
* Clear any active selection and pending field creation when fields are no
* longer visible, since the transformer and floating toolbars would
* otherwise remain anchored to hidden fields.
*/
useEffect(() => {
if (fieldsVisibility !== 'visible' && (selectedGroups.length > 0 || pending.pendingCreation)) {
pending.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}
}, [fieldsVisibility, selectedGroups, pending.pendingCreation]);
const getSelectedFields = () =>
selectedGroups.map((group) => editorFields.getFieldByFormId(group.id())).filter((field) => field !== undefined);
const deleteSelected = () => {
editorFields.removeFieldsByFormId(selectedGroups.map((group) => group.id()));
selection.clear();
};
const duplicateSelected = () => {
for (const field of getSelectedFields()) {
editorFields.duplicateField(field);
}
};
const duplicateSelectedOnAllPages = () => {
for (const field of getSelectedFields()) {
editorFields.duplicateFieldToAllPages(field);
}
selection.clear();
};
const changeSelectedRecipient = (recipientId: number) => {
for (const field of getSelectedFields()) {
if (field.recipientId !== recipientId) {
editorFields.updateFieldByFormId(field.formId, { recipientId, id: undefined });
}
}
};
const changeSelectedType = (type: FieldType) => {
for (const field of getSelectedFields()) {
if (field.type !== type) {
editorFields.updateFieldByFormId(field.formId, {
type,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
id: undefined,
});
}
}
};
return {
localPageFields,
renderAll,
selectInBox,
pendingCreation: pending.pendingCreation,
createFromPending,
clearPending: pending.clearPending,
deleteSelected,
duplicateSelected,
duplicateSelectedOnAllPages,
changeSelectedRecipient,
changeSelectedType,
};
};
@@ -0,0 +1,195 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import { resolveLineMetaFromPoints } from '@documenso/lib/universal/content-renderer/content-geometry';
import {
CONTENT_LINE_NODE_NAME,
calculateContentLineGeometry,
} from '@documenso/lib/universal/content-renderer/content-renderer';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import Konva from 'konva';
import { useEffect, useMemo, useState } from 'react';
import type { EnvelopeCanvas } from './envelope-canvas-types';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const LINE_ANCHOR_NODE_NAME = 'content-line-anchor';
/**
* The screen size radius of the line endpoint anchors.
*/
const LINE_ANCHOR_RADIUS = 5;
type UseEnvelopeCanvasLineAnchorsOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
isEditable: boolean;
localPageContents: TLocalContent[];
};
/**
* Draggable endpoint anchors for a single selected line content.
*
* Lines are move-only within the shared transformer, so the endpoints are
* edited through these custom anchors instead. Anchors are hidden while the
* line body itself is being dragged, and recreated afterwards.
*/
export const useEnvelopeCanvasLineAnchors = ({
canvas,
selection,
isEditable,
localPageContents,
}: UseEnvelopeCanvasLineAnchorsOptions) => {
const { editorContents } = useCurrentEnvelopeEditor();
const { pageLayer, scale, unscaledViewport, scaledViewport } = canvas;
const { contentGroups: selectedGroups, isTransforming } = selection;
/**
* Whether an anchor is being dragged.
*
* Tracked separately from the selection's `isTransforming` since that would
* destroy the anchors mid drag (this effect depends on it), whereas this only
* needs to hide the floating action bar.
*/
const [isDragging, setIsDragging] = useState(false);
/**
* The selected line content, if exactly one line content is selected.
*/
const selectedLineContent = useMemo((): TLocalContent | null => {
if (selectedGroups.length !== 1) {
return null;
}
const content = editorContents.getContentByFormId(selectedGroups[0].id());
return content?.contentMeta.type === EnvelopeContentType.LINE ? content : null;
// Depends on the contents themselves rather than the getter, which is
// stable, so the anchors follow the line as its coordinates change.
}, [selectedGroups, localPageContents, editorContents.getContentByFormId]);
useEffect(() => {
const layer = pageLayer.current;
if (!layer) {
return;
}
const destroyAnchors = () => {
for (const node of layer.find(`.${LINE_ANCHOR_NODE_NAME}`)) {
node.destroy();
}
};
destroyAnchors();
const contentMeta = selectedLineContent?.contentMeta;
if (!selectedLineContent || contentMeta?.type !== EnvelopeContentType.LINE || !isEditable || isTransforming) {
layer.batchDraw();
return;
}
const geometry = calculateContentLineGeometry(contentMeta, unscaledViewport.width, unscaledViewport.height);
const findLineNodes = () => {
const lineGroup = layer.findOne(`#${selectedLineContent.formId}`);
if (!(lineGroup instanceof Konva.Group)) {
return null;
}
const contentLine = lineGroup.findOne(`.${CONTENT_LINE_NODE_NAME}`);
if (!(contentLine instanceof Konva.Line)) {
return null;
}
return { lineGroup, contentLine };
};
const endpoints = [
{ pointIndex: 0, x: geometry.x + geometry.points[0], y: geometry.y + geometry.points[1] },
{ pointIndex: 2, x: geometry.x + geometry.points[2], y: geometry.y + geometry.points[3] },
];
for (const endpoint of endpoints) {
const anchor = new Konva.Circle({
name: LINE_ANCHOR_NODE_NAME,
x: endpoint.x,
y: endpoint.y,
// Compensate for the stage scale so the anchors keep a constant
// screen size, mirroring the transformer anchors.
radius: LINE_ANCHOR_RADIUS / scale,
fill: '#ffffff',
// Matches the transformer anchors, themed to the brand green.
stroke: getRecipientColorStyles('green').baseRing,
strokeWidth: 1.5,
strokeScaleEnabled: false,
draggable: true,
// Keep the anchor within the page bounds. Positions are in scaled
// stage coordinates.
dragBoundFunc: (pos) => ({
x: Math.max(0, Math.min(scaledViewport.width, pos.x)),
y: Math.max(0, Math.min(scaledViewport.height, pos.y)),
}),
});
anchor.on('dragstart', () => setIsDragging(true));
// Live preview while dragging the anchor.
anchor.on('dragmove', () => {
const lineNodes = findLineNodes();
if (!lineNodes) {
return;
}
const points = [...lineNodes.contentLine.points()];
points[endpoint.pointIndex] = anchor.x() - lineNodes.lineGroup.x();
points[endpoint.pointIndex + 1] = anchor.y() - lineNodes.lineGroup.y();
lineNodes.contentLine.points(points);
layer.batchDraw();
});
// Write the new endpoint positions back into the content meta. The
// reconcile effect re-renders the normalized geometry and recreates
// the anchors.
anchor.on('dragend', () => {
setIsDragging(false);
const lineNodes = findLineNodes();
if (!lineNodes) {
return;
}
editorContents.updateContentByFormId(selectedLineContent.formId, {
contentMeta: resolveLineMetaFromPoints(
contentMeta,
lineNodes.lineGroup.x(),
lineNodes.lineGroup.y(),
lineNodes.contentLine.points(),
unscaledViewport.width,
unscaledViewport.height,
),
});
});
layer.add(anchor);
anchor.moveToTop();
}
layer.batchDraw();
return () => {
destroyAnchors();
};
}, [selectedLineContent, isEditable, isTransforming, localPageContents, scale, scaledViewport]);
return { isDragging };
};
@@ -0,0 +1,201 @@
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import { KONVA_SELECTION_FILL_COLOR } from '@documenso/lib/universal/konva/constants';
import Konva from 'konva';
import { useCallback } from 'react';
import { clamp } from 'remeda';
import type { EnvelopeCanvasBox } from './envelope-canvas-types';
type UseEnvelopeCanvasMarqueeOptions = {
/**
* Called when a marquee drag ends, with the selection box in scaled stage
* coordinates. The caller decides what the box selects.
*/
onSelect: (box: EnvelopeCanvasBox) => void;
/**
* Called when an empty area of the stage is clicked without dragging.
*/
onEmptyClick: () => void;
};
/**
* Drag-to-select marquee on an empty area of the stage.
*
* The callbacks are read through refs, so the once-bound stage handlers always
* delegate to the latest render's logic.
*/
export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelopeCanvasMarqueeOptions) => {
const onSelectRef = useLatestRef(onSelect);
const onEmptyClickRef = useLatestRef(onEmptyClick);
/**
* Bind the marquee to a stage. Called once when the page canvas is created.
*/
const bind = useCallback((stage: Konva.Stage, layer: Konva.Layer) => {
const selectionRectangle = new Konva.Rect({
name: 'marquee-selection',
fill: KONVA_SELECTION_FILL_COLOR,
visible: false,
});
layer.add(selectionRectangle);
let x1 = 0;
let y1 = 0;
/**
* The pointer in layer coordinates, pinned to the page.
*
* Pointer positions are in scaled stage coordinates, while the rectangle
* lives on the scaled layer, so positions are divided by the stage scale.
*
* The pointer is clamped to the page so the rectangle stops at the page
* edge while the mouse itself is free to leave it, matching how a
* transformer resize behaves.
*/
const getPointer = () => {
const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) {
return null;
}
return {
x: clamp(pointerPosition.x / stage.scaleX(), { min: 0, max: stage.width() / stage.scaleX() }),
y: clamp(pointerPosition.y / stage.scaleY(), { min: 0, max: stage.height() / stage.scaleY() }),
};
};
// The stage only receives pointer events while the pointer is over its
// container, so a drag which leaves the page would freeze the rectangle
// and never finish. Like Konva's own transformer, the move and up events
// are tracked on the window for the duration of a drag instead.
const onWindowPointerMove = (evt: MouseEvent | TouchEvent) => {
// The stage is rebuilt on zoom, which throws away this rectangle.
if (!selectionRectangle.getStage()) {
stopTrackingWindow();
return;
}
// The stage cannot see the pointer once it is outside the container, so
// register its position from the window event.
stage.setPointersPositions(evt);
const pointer = getPointer();
if (!pointer) {
return;
}
selectionRectangle.moveToTop();
selectionRectangle.setAttrs({
x: Math.min(x1, pointer.x),
y: Math.min(y1, pointer.y),
width: Math.abs(pointer.x - x1),
height: Math.abs(pointer.y - y1),
});
};
/**
* Whether the pointer was actually dragged, as opposed to a plain click
* which leaves the rectangle without an area.
*/
const isMarqueeDrawn = () => {
return selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0;
};
const onWindowPointerUp = () => {
stopTrackingWindow();
if (!selectionRectangle.getStage()) {
return;
}
// Hide in a timeout so the click handler below can still detect that a
// marquee drag just finished.
setTimeout(() => {
selectionRectangle.visible(false);
});
// A plain click is not a selection, the click handler below deals with
// it. Selecting here would also match anything whose bounding box merely
// contains the point (e.g. a diagonal line), and since this runs after
// other window listeners it would override e.g. a content being placed.
if (!isMarqueeDrawn()) {
return;
}
onSelectRef.current(selectionRectangle.getClientRect());
};
const startTrackingWindow = () => {
window.addEventListener('mousemove', onWindowPointerMove);
window.addEventListener('touchmove', onWindowPointerMove);
window.addEventListener('mouseup', onWindowPointerUp);
window.addEventListener('touchend', onWindowPointerUp);
window.addEventListener('touchcancel', onWindowPointerUp);
};
const stopTrackingWindow = () => {
window.removeEventListener('mousemove', onWindowPointerMove);
window.removeEventListener('touchmove', onWindowPointerMove);
window.removeEventListener('mouseup', onWindowPointerUp);
window.removeEventListener('touchend', onWindowPointerUp);
window.removeEventListener('touchcancel', onWindowPointerUp);
};
stage.on('mousedown.marquee touchstart.marquee', (e) => {
// Do nothing if the pointer is down on a shape.
if (e.target !== stage) {
return;
}
const pointer = getPointer();
if (!pointer) {
return;
}
x1 = pointer.x;
y1 = pointer.y;
selectionRectangle.setAttrs({
x: x1,
y: y1,
width: 0,
height: 0,
visible: true,
});
startTrackingWindow();
});
stage.on('click.marquee tap.marquee', (e) => {
// A marquee drag just finished, the selection was handled on mouse up.
if (isMarqueeDrawn()) {
return;
}
if (e.target === stage) {
onEmptyClickRef.current();
}
});
}, []);
return { bind };
};
/**
* Find the groups of a given name which intersect a box, excluding groups
* which are not draggable (i.e. not currently editable).
*/
export const findEnvelopeCanvasGroupsInBox = (stage: Konva.Stage, groupName: string, box: EnvelopeCanvasBox) => {
return stage
.find(`.${groupName}`)
.filter(
(node): node is Konva.Group =>
node instanceof Konva.Group && node.draggable() && Konva.Util.haveIntersection(box, node.getClientRect()),
);
};

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