Compare commits

..
Author SHA1 Message Date
ephraimduncan 6f60250c48 docs: add data-readonly and correct field color example
The embedding CSS docs did not list the data-readonly field attribute. The
example for filled fields used var(--primary), which holds only HSL numbers and
is not a valid color. The example uses hsl(var(--primary)).
2026-09-29 18:09:31 +00:00
Raiyan Zaman b75a66d5c0 fix(i18n): add missing "zu" in German invite/reminder strings (#3331) 2026-09-29 21:25:30 +10:00
David Nguyen 573c928a0e feat: add recipient grouping (#3319) 2026-09-29 16:52:43 +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
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
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
238 changed files with 18762 additions and 6144 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.
@@ -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',
+29 -42
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
@@ -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" \
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" \
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" \
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" \
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
@@ -144,6 +149,7 @@ Fields expose data attributes for state-based styling:
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
| `[data-readonly]` | `true`, `false` | Whether the field is read-only |
### Example
@@ -155,7 +161,7 @@ Fields expose data attributes for state-based styling:
/* Style filled fields */
.field--FieldRootContainer[data-inserted='true'] {
background-color: var(--primary);
background-color: hsl(var(--primary));
opacity: 0.2;
}
@@ -186,15 +186,6 @@ Controls envelope configuration options. Set to `null` to hide envelope settings
| `allowConfigureEmailSender` | `boolean` | `true` | Allow configuring the email sender |
| `allowConfigureEmailReplyTo` | `boolean` | `true` | Allow configuring the email reply-to |
<Callout title="Allow Document Rejection">
The settings dialog also contains an "Allow Document Rejection" option that is always shown. It sets the document's
own rejection setting (`meta.allowDocumentRejection` in the API) and defaults to the organisation or team document
preference. This
is separate from the `allowDocumentRejection` option of the embedded signing views: that option only controls the
signing embed's UI, while this one is stored on the document and enforced by the server. A recipient can reject only
when both allow it.
</Callout>
### Actions
Controls available actions during editing:
@@ -47,20 +47,10 @@ Documenso expects the fragment to be **base64** of:
| `lockEmail` | `boolean` | Lock the email field (prevents editing). |
| `language` | `string` | Force the embed language (e.g. `en`). |
| `darkModeDisabled` | `boolean` | Disable dark mode behavior. |
| `allowDocumentRejection` | `boolean` | Show the reject option in the embed. Defaults to `false`. The document must also allow rejection, see below. |
| `allowDocumentRejection` | `boolean` | Allow or disallow document rejection. |
| `css` | `string` | Inject custom CSS into the embed. |
| `cssVars` | `object` | Override embed CSS variables (see the CSS Variables page). |
<Callout title="allowDocumentRejection and the document's own setting">
Two settings control whether a recipient can reject a document in an embed, and both must allow it:
- `allowDocumentRejection` above, which you pass per embed. It only controls your embed's UI and defaults to `false`.
- The document's own "Allow Document Rejection" setting. Its default comes from the organisation or team document
preferences, and it can be changed per document in the editor or through the API (`meta.allowDocumentRejection`).
When it is disabled, the reject option stays hidden even if you pass `allowDocumentRejection: true`, and the server
refuses rejection requests for that document.
</Callout>
#### Example
```ts
@@ -262,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>
@@ -297,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 },
});
@@ -374,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]}`,
}),
});
@@ -386,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,
};
}
@@ -453,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>
@@ -476,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)
@@ -540,7 +565,6 @@ async function bulkSendFromTemplate(
recipients: [
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
],
distributeDocument: true,
externalId: `bulk-${Date.now()}-${recipient.email}`,
}),
});
@@ -551,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,
};
}),
);
@@ -627,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
@@ -837,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,
@@ -897,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 {
@@ -911,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;
}
+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",
@@ -96,9 +96,6 @@ export const DocumentPreferencesResetDialog = ({
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Allow document rejection</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
@@ -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',
});
}
@@ -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) {
@@ -299,7 +299,7 @@ export const EnvelopesBulkDownloadDialog = ({
</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);
@@ -8,6 +8,7 @@ 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 { 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,27 +40,64 @@ 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(),
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>;
@@ -87,6 +126,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(
{
@@ -106,6 +146,8 @@ export function TemplateUseDialog({
return {
distributeDocument: false,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
@@ -140,11 +182,39 @@ export function TemplateUseDialog({
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
/**
* Track the most recently uploaded file so its name can be used as the document name.
* Files with an unusable name are ignored, and the document name source is reset if
* no usable file remains.
*/
const updateLastUploadedFile = (file?: File) => {
const usableFile = isUploadedFileNameUsable(file) ? file : undefined;
setLastUploadedFile(usableFile);
if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) {
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
}
};
const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => {
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
return data.customDocumentName;
}
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) {
return getUploadedDocumentTitle(lastUploadedFile);
}
return undefined;
};
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
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(
@@ -163,6 +233,7 @@ export function TemplateUseDialog({
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
...(documentTitle ? { override: { title: documentTitle } } : {}),
});
toast({
@@ -195,9 +266,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 +314,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 && (
@@ -401,7 +477,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 +514,7 @@ export function TemplateUseDialog({
)}
/>
{form.watch('useCustomDocument') && (
{useCustomDocument && (
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
@@ -443,7 +529,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 +537,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 +563,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 +617,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 +626,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 +637,8 @@ export function TemplateUseDialog({
}
field.onChange(file);
form.clearErrors(`customDocumentData.${i}.data`);
updateLastUploadedFile(file);
}}
/>
</div>
@@ -550,6 +652,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">
@@ -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>
);
@@ -55,6 +55,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
completedFields: DocumentField[];
metadata?: DocumentMeta | null;
isCompleted?: boolean;
isRejected?: boolean;
hidePoweredBy?: boolean;
allowWhitelabelling?: boolean;
allRecipients?: RecipientWithFields[];
@@ -70,6 +71,7 @@ export const EmbedSignDocumentV1ClientPage = ({
completedFields,
metadata,
isCompleted,
isRejected,
hidePoweredBy = false,
allowWhitelabelling = false,
allRecipients = [],
@@ -83,7 +85,9 @@ export const EmbedSignDocumentV1ClientPage = ({
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,
);
@@ -96,9 +100,6 @@ export const EmbedSignDocumentV1ClientPage = ({
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
// Both the embed host and the document settings must allow rejection.
const isDocumentRejectionAllowed = allowDocumentRejection && (metadata?.allowDocumentRejection ?? true);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
@@ -266,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(
@@ -306,7 +345,7 @@ export const EmbedSignDocumentV1ClientPage = ({
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
{isDocumentRejectionAllowed && (
{allowDocumentRejection && (
<DocumentSigningRejectDialog documentId={documentId} token={token} onRejected={onDocumentRejected} />
)}
</div>
@@ -505,7 +544,7 @@ export const EmbedSignDocumentV1ClientPage = ({
</Button>
) : (
<Button
className={isDocumentRejectionAllowed ? 'col-start-2' : 'col-span-2'}
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={isThrottled}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
@@ -40,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(
{
@@ -112,6 +118,8 @@ export const EmbedSignDocumentV2ClientPage = ({
recipientId: number;
reason?: string;
}) => {
setHasRejectedDocument(true);
if (window.parent) {
window.parent.postMessage(
{
@@ -219,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
}
@@ -228,7 +228,7 @@ export const MultiSignDocumentSigningView = ({
</Button>
</div>
{allowDocumentRejection && (document.documentMeta?.allowDocumentRejection ?? true) && (
{allowDocumentRejection && (
<div className="embed--Actions mt-8 mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog documentId={document.id} token={token} onRejected={onRejected} />
</div>
@@ -43,7 +43,6 @@ export type TDocumentPreferencesFormSchema = {
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
allowDocumentRejection: boolean | null;
aiFeaturesEnabled: boolean | null;
};
@@ -58,7 +57,6 @@ type SettingsSubset = Pick<
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'allowDocumentRejection'
| 'aiFeaturesEnabled'
>;
@@ -79,7 +77,6 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
allowDocumentRejection: settings.allowDocumentRejection,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
};
};
@@ -104,7 +101,6 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
allowDocumentRejection: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
});
@@ -490,60 +486,6 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
)}
/>
<FormField
control={form.control}
name="allowDocumentRejection"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Allow Document Rejection</Trans>}
testId="allow-document-rejection"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="allow-document-rejection-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether recipients can reject newly created documents from the signing page. Existing
documents keep their current setting. You can change it per document in the document settings.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
@@ -138,12 +138,6 @@ export const AdminGlobalSettingsSection = ({
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Allow document rejection</Trans>}>
<DetailsValue>
{booleanValue(settings.allowDocumentRejection, inheritedSettings?.allowDocumentRejection)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
@@ -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>
);
};
@@ -599,18 +599,10 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
isVisibleCountCapped ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural
value={totalVisibleCount}
one="# result"
other="# results"
/>
<Plural value={totalVisibleCount} one="# result" other="# results" />
)
) : (
<Plural
value={totalVisibleCount}
one="# item"
other="# items"
/>
<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>
);
}
@@ -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>
@@ -102,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 ?? '',
@@ -324,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
@@ -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>
</>
@@ -22,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';
@@ -60,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;
};
@@ -71,6 +77,7 @@ export const DocumentSigningPageViewV1 = ({
completedFields,
isRecipientsTurn,
allRecipients = [],
nextRecipient,
includeSenderDetails,
branding,
}: DocumentSigningPageViewV1Props) => {
@@ -133,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;
@@ -261,9 +240,7 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex items-center gap-x-4">
<DocumentSigningAttachmentsPopover envelopeId={document.envelopeId} token={recipient.token} />
{documentMeta.allowDocumentRejection && (
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
)}
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
</div>
</div>
@@ -63,10 +63,6 @@ export const DocumentSigningPageViewV2 = () => {
onDocumentRejected,
} = useEmbedSigningContext() || {};
// Both the document settings and the embed host (if any) must allow rejection.
const isDocumentRejectionAllowed =
envelope.type === EnvelopeType.DOCUMENT && envelope.documentMeta.allowDocumentRejection && allowDocumentRejection;
const { t } = useLingui();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
@@ -197,7 +193,7 @@ export const DocumentSigningPageViewV2 = () => {
}
/>
{isDocumentRejectionAllowed && (
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
@@ -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 (
@@ -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,42 +1,30 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import {
updateEditorSigners,
ZEditorRecipientsFormSchema,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import {
isAssistantLastSigner,
isCcRecipient,
normalizeRecipientSigningOrders,
canRecipientBeModified as utilCanRecipientBeModified,
} from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DragDropContext, Draggable, Droppable, type DropResult, type SensorAPI } from '@hello-pangea/dnd';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
@@ -45,7 +33,8 @@ import { isDeepEqual } from 'remeda';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { RecipientStepList } from './recipient-step-list';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
@@ -53,9 +42,7 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const cspNonce = useCspNonce();
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { sessionData } = useOptionalSession();
@@ -63,7 +50,6 @@ export const EnvelopeEditorRecipientForm = () => {
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
@@ -109,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients, fields } = envelope;
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const { recipients } = envelope;
const { form } = editorRecipients;
@@ -163,15 +134,16 @@ export const EnvelopeEditorRecipientForm = () => {
}, [watchedSigners]);
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
};
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
const { fields: signers, remove: removeSigner } = useFieldArray({
// Keep a mounted field array for `signers` so react-hook-form reconciles
// whole-array `setValue` calls atomically. Without it, reordering the array
// leaves stale partial entries in watched values (missing email/name/role),
// which breaks validation and the autosave sync.
useFieldArray({
control,
name: 'signers',
keyName: 'nativeId',
});
const emptySignerIndex = watchedSigners.findIndex(
@@ -185,39 +157,22 @@ export const EnvelopeEditorRecipientForm = () => {
const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName);
// Note: Watched signer entries can be transiently partial while react-hook-form
// re-registers reordered array fields, so guard optional access here.
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
(signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
);
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
);
const canRecipientBeModified = (recipientId?: number) => {
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (recipientId === undefined) {
return true;
}
const recipient = recipients.find((recipient) => recipient.id === recipientId);
if (!recipient) {
return false;
}
return utilCanRecipientBeModified(recipient, fields);
};
const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, updatedSigners);
if (shouldFocus) {
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
@@ -235,20 +190,17 @@ export const EnvelopeEditorRecipientForm = () => {
email: '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: activeRecipientCount + 1,
signingOrder: undefined,
});
};
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
const currentSigners = form.getValues('signers');
let nextSigningOrder =
currentSigners.length > 0 ? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1 : 1;
// If the only signer is the default empty signer lets just replace it with the detected recipients
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
form.setValue(
'signers',
updateEditorSigners(
form,
detectedRecipients.map((recipient, index) => ({
formId: nanoid(12),
name: recipient.name,
@@ -257,10 +209,6 @@ export const EnvelopeEditorRecipientForm = () => {
actionAuth: [],
signingOrder: index + 1,
})),
{
shouldValidate: true,
shouldDirty: true,
},
);
return;
@@ -281,16 +229,11 @@ export const EnvelopeEditorRecipientForm = () => {
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: nextSigningOrder,
signingOrder: undefined,
});
nextSigningOrder += 1;
}
form.setValue('signers', normalizeSigningOrders(currentSigners), {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
toast({
title: plural(detectedRecipients.length, {
@@ -304,32 +247,6 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
if (!canRecipientBeModified(signer.id)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) {
removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
}
};
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
@@ -350,7 +267,7 @@ export const EnvelopeEditorRecipientForm = () => {
email: currentEditorEmail ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: activeRecipientCount + 1,
signingOrder: undefined,
},
true,
);
@@ -359,142 +276,6 @@ export const EnvelopeEditorRecipientForm = () => {
}
};
const handleRecipientAutoCompleteSelect = (index: number, suggestion: RecipientAutoCompleteOption) => {
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
};
const onDragEnd = useCallback(
async (result: DropResult) => {
if (!result.destination) {
return;
}
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
// Find next valid position
let insertIndex = result.destination.index;
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) {
insertIndex++;
}
items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders(items);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
await form.trigger('signers');
},
[form, canRecipientBeModified, watchedSigners, toast],
);
const handleRoleChange = useCallback(
(index: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
// Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeSigningOrders(
currentSigners.map((signer, idx) => ({
...signer,
role: idx === index ? role : signer.role,
})),
);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
},
[form, toast, canRecipientBeModified],
);
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
const currentSigners = form.getValues('signers');
const signer = currentSigners[index];
if (isCcRecipient(signer)) {
return;
}
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
if (currentSigningOrderIndex === -1) {
return;
}
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
nonCcSigners.splice(newPosition, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
},
[form, canRecipientBeModified, toast],
);
const handleSigningOrderDisable = useCallback(() => {
setShowSigningOrderConfirmation(false);
@@ -506,10 +287,8 @@ export const EnvelopeEditorRecipientForm = () => {
})),
);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
@@ -537,14 +316,17 @@ export const EnvelopeEditorRecipientForm = () => {
const { data } = validatedFormValues;
// Weird edge case where the whole envelope is created via API
// with no signing order. If they come to this page it will show an error
// since they aren't equal and the recipient is no longer editable.
// Locked recipients hold persisted values the server refuses to rewrite,
// e.g. an envelope created via API with no signing order where a recipient
// has already signed. Restore their PERSISTED order so form normalization
// drift never submits a "changed" locked recipient the server rejects.
const envelopeRecipients = data.signers.map((recipient) => {
if (!canRecipientBeModified(recipient.id)) {
const persistedRecipient = recipients.find((envelopeRecipient) => envelopeRecipient.id === recipient.id);
return {
...recipient,
signingOrder: recipient.signingOrder,
signingOrder: persistedRecipient?.signingOrder ?? undefined,
};
}
return recipient;
@@ -570,7 +352,7 @@ export const EnvelopeEditorRecipientForm = () => {
signer.email !== recipient.email ||
signer.name !== recipient.name ||
signer.role !== recipient.role ||
signer.signingOrder !== recipient.signingOrder ||
(signer.signingOrder ?? null) !== (recipient.signingOrder ?? null) ||
!isDeepEqual(signerActionAuth, recipientActionAuth)
);
});
@@ -590,7 +372,7 @@ export const EnvelopeEditorRecipientForm = () => {
}, [formValues]);
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
return (
<Card backdropBlur={false} className="border">
@@ -646,7 +428,7 @@ export const EnvelopeEditorRecipientForm = () => {
type="button"
className="flex-1"
size="sm"
disabled={isSubmitting || signers.length >= remaining.recipients}
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
@@ -796,288 +578,7 @@ export const EnvelopeEditorRecipientForm = () => {
)}
</div>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
isCcRecipient(signer) ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2': snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4': showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
})}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={activeRecipientCount}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
<FormErrorMessage
className="mt-2"
@@ -16,7 +16,7 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { canAccessTeamDocument, DocumentSignatureType, extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
@@ -97,7 +97,7 @@ export const ZAddSettingsFormSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
.refine((value) => value === undefined || value === '' || isHttpUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
language: z
@@ -110,7 +110,6 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
allowDocumentRejection: z.boolean(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
}),
@@ -201,7 +200,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
allowDocumentRejection: envelope.documentMeta.allowDocumentRejection,
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
},
@@ -251,7 +249,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
message,
subject,
emailReplyTo,
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
} = data.meta;
@@ -281,7 +278,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
},
@@ -469,52 +465,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
/>
)}
<FormField
control={form.control}
name="meta.allowDocumentRejection"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allow Document Rejection</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Controls whether recipients can reject the document from the signing page.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
value={field.value ? 'true' : 'false'}
onValueChange={(value) => field.onChange(value === 'true')}
>
<SelectTrigger className="bg-background" data-testid="allow-document-rejection-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{settings.allowConfigureDateFormat && (
<FormField
control={form.control}
@@ -0,0 +1,235 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isCcRecipient } from '@documenso/lib/utils/recipients';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client';
import { GripVerticalIcon, TrashIcon } from 'lucide-react';
import { memo } from 'react';
import { useFormContext } from 'react-hook-form';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientRowProps = {
signerIndex: number;
signer: TEditorSigner;
isSequential: boolean;
isInputDisabled: boolean;
canBeModified: boolean;
isRemoveDisabled: boolean;
showAdvancedSettings: boolean;
dragHandleProps?: DraggableProvidedDragHandleProps | null;
recipientSuggestions: RecipientAutoCompleteOption[];
isLoadingSuggestions: boolean;
onRoleChange: (signerIndex: number, role: RecipientRole) => void;
onRemove: (signerIndex: number) => void;
onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void;
onSearchQueryChange: (query: string) => void;
};
const RecipientRowInner = ({
signerIndex,
signer,
isSequential,
isInputDisabled,
canBeModified,
isRemoveDisabled,
showAdvancedSettings,
dragHandleProps,
recipientSuggestions,
isLoadingSuggestions,
onRoleChange,
onRemove,
onAutoCompleteSelect,
onSearchQueryChange,
}: RecipientRowProps) => {
const { t } = useLingui();
const { envelope, editorConfig } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const form = useFormContext<TEditorRecipientsFormSchema>();
const { isSubmitting } = form.formState;
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified;
const rowErrors = form.formState.errors.signers?.[signerIndex];
return (
<fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
<div className="flex flex-row items-center gap-x-2">
{isSequential && !isCcRecipient(signer) && (
<span
{...(dragHandleProps ?? {})}
data-testid="recipient-row-drag-handle"
className={cn(
'mt-auto -ml-1.5 flex h-10 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{
'mb-6': rowErrors,
'cursor-default hover:bg-transparent': !dragHandleProps,
},
)}
>
<GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
'opacity-10': !dragHandleProps,
})}
/>
</span>
)}
<FormField
control={form.control}
name={`signers.${signerIndex}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6': rowErrors && !rowErrors.email,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="email"
aria-label={t`Email`}
placeholder={t`Email`}
value={field.value}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6': rowErrors && !rowErrors.name,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="text"
aria-label={t`Name`}
placeholder={t`Recipient ${signerIndex + 1}`}
{...field}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6': rowErrors && !rowErrors.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onRoleChange(signerIndex, value as RecipientRole);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': rowErrors,
})}
data-testid="remove-signer-button"
disabled={isFieldDisabled || isRemoveDisabled || isDirectRecipient}
onClick={() => onRemove(signerIndex)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${signerIndex}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6': rowErrors && !rowErrors.actionAuth,
'pl-6': isSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect {...field} onValueChange={field.onChange} disabled={isFieldDisabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</fieldset>
);
};
/**
* Memoized: rows contain heavy inputs (autocomplete, role select) and would
* otherwise re-render on every drag state change, making drags feel sluggish.
* All callback props are stable (useCallback in the list) and `signer` object
* identities only change when form values actually change.
*/
export const RecipientRow = memo(RecipientRowInner);
@@ -0,0 +1,261 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import type { RecipientStep } from '@documenso/lib/utils/recipient-groups';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd';
import { Draggable, Droppable } from '@hello-pangea/dnd';
import { Plural, Trans } from '@lingui/react/macro';
import { GripVerticalIcon, Users2Icon } from 'lucide-react';
import { RecipientRow, type RecipientRowProps } from './recipient-row';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type DraggingType = 'STEP' | 'RECIPIENT' | null;
/**
* Skips the drop animation. The post-drop state update re-sorts and renumbers
* the groups anyway, so gliding to the predicted slot first makes every drop
* feel like it settles twice — snapping hands control to the real re-render
* immediately instead.
*/
const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {
if (!snapshot.isDropAnimating) {
return provided.draggableProps.style;
}
return {
...provided.draggableProps.style,
transitionDuration: '0.001s',
};
};
export type RecipientStepCardSharedRowProps = Pick<
RecipientRowProps,
| 'showAdvancedSettings'
| 'recipientSuggestions'
| 'isLoadingSuggestions'
| 'onRoleChange'
| 'onRemove'
| 'onAutoCompleteSelect'
| 'onSearchQueryChange'
>;
export type RecipientStepCardProps = {
stepIndex: number;
step: RecipientStep<TEditorSigner>;
isLastStep: boolean;
draggableProvided: DraggableProvided;
draggableSnapshot: DraggableStateSnapshot;
draggingType: DraggingType;
/**
* Whether recipients may be combined into signing groups. False on CSC
* (AES/QES) instances, where every signing recipient must hold a distinct
* step. Constant for the session, so disabling the drop-zone with it does
* not violate the "never toggle `isDropDisabled` mid-drag" constraint.
*/
isGroupingEnabled: boolean;
isStepLocked: boolean;
isRemoveDisabled: boolean;
flatIndexByFormId: Map<string, number>;
canSignerBeModified: (signer: TEditorSigner) => boolean;
isSubmitting: boolean;
onUngroup: (stepIndex: number) => void;
rowProps: RecipientStepCardSharedRowProps;
};
/**
* The drop-zone strip rendered above each group card (and below the last one)
* that receives recipient-row drops. Invisible until a dragged row hovers it,
* then it shows a full-width green line marking the insertion point.
*
* Notes:
* - It lives INSIDE the step's Draggable so it shifts together with the card
* while groups are being reordered — a static strip between draggables
* would stay behind while the cards around it are displaced, making group
* drags look broken.
* - Its `droppableId` must stay STABLE while mounted (anchored to a formId,
* never a positional index): @hello-pangea/dnd does not support changing
* ids on mounted droppables/draggables, which silently breaks them.
* - `type="RECIPIENT"` already scopes it to recipient-row drags, and
* `isDropDisabled` must not be toggled based on the active drag, as
* @hello-pangea/dnd snapshots it at drag start (before state updates land).
* - It must keep a CONSTANT size: droppable geometry is captured when a drag
* starts, so resizing during the drag would leave the visible strip and the
* actual hit area in different places. Only colors may change mid-drag.
*/
const RecipientStepGap = ({ droppableId }: { droppableId: string }) => (
<Droppable droppableId={droppableId} type="RECIPIENT">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn('flex h-6 items-center', {
'gap-active': snapshot.isDraggingOver,
})}
>
<div
className={cn('h-[3px] w-full rounded-full bg-primary opacity-0 transition-opacity duration-100', {
'opacity-100': snapshot.isDraggingOver,
})}
/>
{provided.placeholder}
</div>
)}
</Droppable>
);
export const RecipientStepCard = ({
stepIndex,
step,
isLastStep,
draggableProvided,
draggableSnapshot,
draggingType,
isGroupingEnabled,
isStepLocked,
isRemoveDisabled,
flatIndexByFormId,
canSignerBeModified,
isSubmitting,
onUngroup,
rowProps,
}: RecipientStepCardProps) => {
const isGroup = step.members.length > 1;
const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor);
const stepLabel = step.order ?? stepIndex + 1;
// All droppable ids are anchored to the first member's formId (never a
// positional index) so they stay stable while cards are reordered —
// @hello-pangea/dnd does not support changing ids on mounted elements.
const stepAnchor = step.members[0].formId;
return (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
style={getDraggableStyle(draggableProvided, draggableSnapshot)}
className={cn({
'pointer-events-none': draggableSnapshot.isDragging,
})}
>
<RecipientStepGap droppableId={`gap-${stepAnchor}`} />
<Droppable droppableId={`step-members-${stepAnchor}`} type="RECIPIENT" isDropDisabled={!isGroupingEnabled}>
{(droppableProvided, droppableSnapshot) => {
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
const isHighlighted = isCombineTarget || isJoinTarget;
return (
<div
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
data-testid="recipient-step-card"
className={cn('relative rounded-lg border bg-background px-3 pt-2 pb-1 transition-shadow', {
'border-primary/60 bg-primary/5': isGroup,
'bg-widget-foreground shadow-lg': draggableSnapshot.isDragging,
'border-primary ring-1 ring-primary': isHighlighted,
})}
>
{isHighlighted && (
<Badge
variant="default"
size="small"
className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm"
>
<Users2Icon className="h-3 w-3" />
<Trans>Release to group</Trans>
</Badge>
)}
<div className="flex flex-row items-center gap-x-1">
<span
{...(draggableProvided.dragHandleProps ?? {})}
data-testid="step-drag-handle"
className={cn(
'-my-1 -ml-1.5 flex h-8 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{ 'pointer-events-none opacity-30': isStepLocked },
)}
>
<GripVerticalIcon className="h-4 w-4 opacity-60" />
</span>
<Badge variant={isGroup ? 'default' : 'neutral'} size="small">
<Trans>Group {stepLabel}</Trans>
</Badge>
{isGroup && (
<>
<span className="ml-1 flex items-center gap-x-1.5 text-green-700 text-xs dark:text-green-400">
<Users2Icon className="h-3.5 w-3.5" />
<Plural
value={step.members.length}
one="# recipient · any order"
other="# recipients · any order"
/>
</span>
<Button
type="button"
variant="link"
size="sm"
data-testid="ungroup-step-button"
className="ml-auto h-auto p-0 text-xs"
disabled={isStepLocked || isSubmitting}
onClick={() => onUngroup(stepIndex)}
>
<Trans>Ungroup</Trans>
</Button>
</>
)}
</div>
{step.members.map((member, memberIndex) => {
const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
const canBeModified = canSignerBeModified(member);
return (
<Draggable
key={member.formId}
draggableId={`recipient-${member.formId}`}
index={memberIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(memberProvided, memberSnapshot) => (
<div
ref={memberProvided.innerRef}
{...memberProvided.draggableProps}
style={getDraggableStyle(memberProvided, memberSnapshot)}
className={cn({
'rounded-md bg-widget-foreground shadow-lg': memberSnapshot.isDragging,
})}
>
<RecipientRow
signerIndex={signerIndex}
signer={member}
isSequential={true}
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
canBeModified={canBeModified}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={memberProvided.dragHandleProps}
{...rowProps}
/>
</div>
)}
</Draggable>
);
})}
{droppableProvided.placeholder}
</div>
);
}}
</Droppable>
{isLastStep && <RecipientStepGap droppableId="gap-end" />}
</div>
);
};
@@ -0,0 +1,398 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import {
type TEditorRecipientsFormSchema,
updateEditorSigners,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
extractRecipientToNewStep,
getLastLockedStepIndex,
groupRecipientsBySigningOrder,
isSigningOrderFrozen,
mergeSteps,
moveRecipientToStep,
normalizeGroupedSigningOrders,
reorderStep,
ungroupStep,
} from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { Badge } from '@documenso/ui/primitives/badge';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useCallback, useMemo, useState } from 'react';
import { useCspNonce } from '~/utils/nonce';
import { RecipientRow } from './recipient-row';
import { type DraggingType, RecipientStepCard } from './recipient-step-card';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientStepListProps = {
showAdvancedSettings: boolean;
};
export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => {
const { t } = useLingui();
const { toast } = useToast();
const cspNonce = useCspNonce();
const { envelope, editorRecipients, isEmbedded, isCscMode } = useCurrentEnvelopeEditor();
const { form } = editorRecipients;
// Signing groups are an SES feature: TSP (AES/QES) signatures must be
// strictly sequential, so on CSC instances the group affordances (card
// combine, row-to-card join) are disabled while step reordering and
// ungrouping of invalid API-created state stay available.
const isGroupingEnabled = !isCscMode;
const [draggingType, setDraggingType] = useState<DraggingType>(null);
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const watchedSigners = form.watch('signers');
const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
const { isSubmitting } = form.formState;
const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]);
// Signing is sequential, so anyone who has already acted is at or before the
// current step. Those steps hold persisted orders that cannot be rewritten,
// so ordering is locked up to and including the last of them; everything
// after can still be rearranged freely.
const lastLockedStepIndex = useMemo(
() => getLastLockedStepIndex(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)),
[steps, envelope],
);
const isOrderingFrozen = useMemo(
() => isSigningOrderFrozen(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)),
[steps, envelope],
);
const isRemoveDisabled = watchedSigners.length === 1;
const flatIndexByFormId = useMemo(
() => new Map(watchedSigners.map((signer, index) => [signer.formId, index])),
[watchedSigners],
);
const canSignerBeModified = useCallback(
(signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id),
[envelope],
);
const applySigners = useCallback(
(updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => {
const { warnWhenAssistantLast = true } = options;
updateEditorSigners(form, updatedSigners);
if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
void form.trigger('signers');
},
[form, t, toast],
);
const handleRoleChange = useCallback(
(signerIndex: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
currentSigners.map((signer, index) => ({
...signer,
role: index === signerIndex ? role : signer.role,
})),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleRemove = useCallback(
(signerIndex: number) => {
const signer = form.getValues('signers')[signerIndex];
if (!signer) {
return;
}
if (!canSignerBeModified(signer)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
form.getValues('signers').filter((s) => s.formId !== signer.formId),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: false });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleUngroup = useCallback(
(stepIndex: number) => {
applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified));
},
[form, canSignerBeModified, applySigners],
);
const handleAutoCompleteSelect = useCallback(
(signerIndex: number, suggestion: RecipientAutoCompleteOption) => {
form.setValue(`signers.${signerIndex}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
},
[form],
);
const onBeforeCapture = useCallback((before: BeforeCapture) => {
setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT');
}, []);
const onDragEnd = useCallback(
(result: DropResult) => {
setDraggingType(null);
const currentSigners = form.getValues('signers');
// Drag-and-drop ids are anchored to the first member's formId so they
// stay stable across reorders; resolve them back to step indexes here.
const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners);
const findStepIndexByAnchor = (anchorFormId: string) =>
currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId);
if (result.type === 'STEP') {
if (result.combine) {
// Unreachable while combining is disabled, but kept as a guard so a
// stray combine result can never form a group on a CSC envelope.
if (!isGroupingEnabled) {
return;
}
const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified));
return;
}
if (result.destination) {
applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified));
}
return;
}
if (result.type === 'RECIPIENT' && result.destination) {
const formId = result.draggableId.slice('recipient-'.length);
const { droppableId } = result.destination;
if (droppableId === 'gap-end') {
applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified));
return;
}
if (droppableId.startsWith('gap-')) {
const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length));
if (insertStepIndex === -1) {
return;
}
applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified));
return;
}
if (droppableId.startsWith('step-members-')) {
// Unreachable while the card drop-zones are disabled, but kept as a
// guard so a stray drop can never form a group on a CSC envelope.
if (!isGroupingEnabled) {
return;
}
const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified));
}
}
},
[form, canSignerBeModified, applySigners, isGroupingEnabled],
);
const sharedRowProps = {
showAdvancedSettings,
recipientSuggestions,
isLoadingSuggestions: isLoading,
onRoleChange: handleRoleChange,
onRemove: handleRemove,
onAutoCompleteSelect: handleAutoCompleteSelect,
onSearchQueryChange: setRecipientSearchQuery,
};
return (
<div>
{!showAdvancedSettings && !isSequential && (
<div className="mb-1 flex flex-row gap-x-2 text-sm">
<span className="w-full">
<Trans>Email</Trans>
</span>
<span className="w-full">
<Trans>Name</Trans>
</span>
<span className="w-[7.5rem] flex-shrink-0" />
</div>
)}
{!isSequential ? (
<div className="flex w-full flex-col">
{watchedSigners.map((signer, index) => (
<RecipientRow
key={signer.formId}
signerIndex={index}
signer={signer}
isSequential={false}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
))}
</div>
) : (
<>
<DragDropContext nonce={cspNonce} onBeforeCapture={onBeforeCapture} onDragEnd={onDragEnd}>
<Droppable droppableId="recipient-steps" type="STEP" isCombineEnabled={isGroupingEnabled}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col">
{steps.map((step, stepIndex) => {
const isStepLocked = isOrderingFrozen || stepIndex <= lastLockedStepIndex;
return (
<Draggable
key={`step-${step.members[0].formId}`}
draggableId={`step-${step.members[0].formId}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
isLastStep={stepIndex === steps.length - 1}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isGroupingEnabled={isGroupingEnabled}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{ccRecipients.length > 0 && (
<div className="my-1 rounded-lg border px-3 py-1.5">
<Badge variant="neutral" size="small">
<Trans>Receives Copy</Trans>
</Badge>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
@@ -87,11 +87,7 @@ export const EnvelopeSignerHeader = () => {
const MobileDropdownMenu = () => {
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
const { allowDocumentRejection = true } = useEmbedSigningContext() || {};
// Both the document settings and the embed host (if any) must allow rejection.
const isDocumentRejectionAllowed =
envelope.type === EnvelopeType.DOCUMENT && envelope.documentMeta.allowDocumentRejection && allowDocumentRejection;
const { allowDocumentRejection } = useEmbedSigningContext() || {};
return (
<DropdownMenu>
@@ -117,7 +113,7 @@ const MobileDropdownMenu = () => {
}
/>
{isDocumentRejectionAllowed && (
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
@@ -31,6 +31,8 @@ type FilterPillCommonProps = {
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
/** Whether the selection can be removed. Defaults to true. */
clearable?: boolean;
testId?: string;
};
@@ -61,7 +63,7 @@ export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
* selections followed by a "+N more" chip.
*/
export const FilterPill = (props: FilterPillProps) => {
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, clearable = true, testId } = props;
const [open, setOpen] = useState(false);
@@ -84,7 +86,7 @@ export const FilterPill = (props: FilterPillProps) => {
return;
}
props.onChange(nextValue === props.value ? null : nextValue);
props.onChange(nextValue === props.value && clearable ? null : nextValue);
setOpen(false);
};
@@ -168,7 +170,7 @@ export const FilterPill = (props: FilterPillProps) => {
))}
</CommandGroup>
{hasSelection && (
{hasSelection && clearable && (
<>
<CommandSeparator />
<CommandGroup>
@@ -7,9 +7,10 @@ export type CardMetricProps = {
value?: string | number;
className?: string;
children?: React.ReactNode;
testId?: string;
};
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => {
return (
<div
className={cn(
@@ -29,7 +30,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
</div>
{children || (
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
<p className="mt-auto font-semibold text-4xl text-foreground leading-8" data-testid={testId}>
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
@@ -6,9 +6,13 @@ import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
@@ -62,6 +66,13 @@ export const OrgMenuSwitcher = () => {
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
// Team analytics take precedence when in a team context, the team page links to organisation analytics.
const analyticsPath = canAccessTeamSettings
? formatAnalyticsPath(currentTeam.url)
: currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? formatOrganisationAnalyticsPath(currentOrganisation.url)
: null;
// Use hovered org for teams display if available,
// otherwise use current team's org if in a team,
// finally fallback to selected org
@@ -271,6 +282,14 @@ export const OrgMenuSwitcher = () => {
</Link>
</DropdownMenuItem>
{analyticsPath && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={analyticsPath}>
<Trans>Analytics</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
to={
@@ -5,8 +5,8 @@ import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -1,37 +0,0 @@
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft, Loader } from 'lucide-react';
import { Link } from 'react-router';
export default function DocumentEditSkeleton() {
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link to="/" className="flex grow-0 items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 grow-0 truncate font-semibold text-2xl md:text-3xl">
<Trans>Loading Document...</Trans>
</h1>
<div className="flex h-10 items-center">
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
</div>
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="col-span-12 rounded-xl border-2 border-border bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7 dark:bg-background">
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</div>
</div>
<div className="col-span-12 rounded-xl border-2 border-border bg-background before:rounded-xl lg:col-span-6 xl:col-span-5" />
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
const ZIndexes: { [key: string]: string } = {
@@ -14,9 +15,10 @@ export type StackAvatarProps = {
zIndex?: string;
fallbackText?: string;
type: RecipientStatusType;
className?: string;
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
let classes = '';
let zIndexClass = '';
const firstClass = first ? '' : '-ml-3';
@@ -46,7 +48,14 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
}
return (
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
<Avatar
className={cn(
zIndexClass,
firstClass,
'h-10 w-10 border-2 border-white border-solid dark:border-border',
className,
)}
>
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
</Avatar>
);
@@ -1,16 +1,33 @@
import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import {
getExtraRecipientsType,
getRecipientType,
RecipientStatusType,
} 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 { PopoverHover } from '@documenso/ui/primitives/popover';
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 type { DocumentStatus } from '@prisma/client';
import { useMemo } from 'react';
import { DocumentStatus } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import {
CheckIcon,
CircleCheckIcon,
CircleDashedIcon,
CircleXIcon,
ClockIcon,
CopyIcon,
MailOpenIcon,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
@@ -27,125 +44,238 @@ export const StackAvatarsWithTooltip = ({
}: StackAvatarsWithTooltipProps) => {
const { _ } = useLingui();
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
);
const sections = useMemo(() => {
const groups = groupRecipientsByStatus(recipients);
const openedRecipients = recipients.filter((recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED);
const completedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
);
const uncompletedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
);
const rejectedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
];
return RECIPIENT_STATUS_SECTIONS.map((section) => ({
...section,
recipients: groups[section.type],
})).filter((section) => section.recipients.length > 0);
}, [recipients]);
const canCopySigningLink = documentStatus === DocumentStatus.PENDING;
return (
<PopoverHover
trigger={children || <StackAvatars recipients={sortedRecipients} />}
trigger={children || <RecipientAvatarStack recipients={recipients} />}
contentProps={{
className: 'flex flex-col gap-y-5 py-2',
className:
'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm',
side: position,
// Keep clear of the sticky app header (h-16, z-[60]) which paints above popovers.
collisionPadding: { top: 72, bottom: 8, left: 8, right: 8 },
// Opened via hover, so don't steal focus from wherever the user was.
onOpenAutoFocus: (event) => event.preventDefault(),
}}
>
{completedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Completed</Trans>
</h1>
{completedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
{sections.map((section) => (
<div key={section.type} className="px-3 py-2">
<div className={cn('flex items-center gap-1.5 font-medium text-xs', section.className)}>
<section.icon className="h-3.5 w-3.5 shrink-0" strokeWidth={2} />
<span>{_(section.label)}</span>
<span>{section.recipients.length}</span>
</div>
<div className="mt-1">
{section.recipients.map((recipient) => (
<RecipientRow
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
recipient={recipient}
signingToken={canCopySigningLink && section.hasSigningLink ? recipient.token : null}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
))}
</div>
</div>
)}
{rejectedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Rejected</Trans>
</h1>
{rejectedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Waiting</Trans>
</h1>
{waitingRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Opened</Trans>
</h1>
{openedRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Uncompleted</Trans>
</h1>
{uncompletedRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
))}
</PopoverHover>
);
};
type RecipientRowProps = {
recipient: TRecipientLite;
signingToken: string | null;
};
const RecipientRow = ({ recipient, signingToken }: RecipientRowProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [isCopied, setIsCopied] = useState(false);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => clearTimeout(copiedTimeoutRef.current ?? undefined), []);
const onCopySigningLink = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
setIsCopied(true);
clearTimeout(copiedTimeoutRef.current ?? undefined);
copiedTimeoutRef.current = setTimeout(() => setIsCopied(false), COPIED_INDICATOR_DURATION_MS);
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
const content = (
<>
<StackAvatar
first={true}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
className="h-6 w-6 shrink-0 border-0 text-[10px]"
/>
<div className="min-w-0 flex-1 text-xs leading-snug">
<p className="truncate text-foreground">{recipient.email || recipient.name}</p>
<p className="truncate text-muted-foreground">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</>
);
if (!signingToken) {
return <div className={recipientRowClassName}>{content}</div>;
}
return (
<button
type="button"
className={cn(
recipientRowClassName,
'group w-[calc(100%+1rem)] cursor-pointer text-left transition-colors duration-300 hover:bg-muted',
)}
title={_(msg`Click to copy signing link for sending to recipient`)}
onClick={onCopySigningLink}
>
{content}
{isCopied ? (
<CheckIcon className="h-3 w-3 shrink-0 text-green-600 dark:text-green-400" />
) : (
<CopyIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
)}
</button>
);
};
const RecipientAvatarStack = ({ recipients }: { recipients: TRecipientLite[] }) => {
const sortedRecipients = useMemo(() => {
const byId = (a: TRecipientLite, b: TRecipientLite) => a.id - b.id;
const rejected = recipients.filter((r) => getRecipientType(r) === RecipientStatusType.REJECTED);
const others = recipients.filter((r) => getRecipientType(r) !== RecipientStatusType.REJECTED);
return [...rejected.sort(byId), ...others.sort(byId)];
}, [recipients]);
const visibleRecipients = sortedRecipients.slice(0, MAX_VISIBLE_AVATARS);
const hiddenRecipients = sortedRecipients.slice(MAX_VISIBLE_AVATARS);
return (
<>
{visibleRecipients.map((recipient, index) => {
const isOverflowSlot = index === MAX_VISIBLE_AVATARS - 1 && hiddenRecipients.length > 0;
const zIndex = String(50 - index * 10);
if (isOverflowSlot) {
return (
<StackAvatar
key="extra-recipients"
first={index === 0}
zIndex={zIndex}
type={getExtraRecipientsType(sortedRecipients.slice(index))}
fallbackText={`+${hiddenRecipients.length + 1}`}
/>
);
}
return (
<StackAvatar
key={recipient.id}
first={index === 0}
zIndex={zIndex}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
);
})}
</>
);
};
const groupRecipientsByStatus = (recipients: TRecipientLite[]) => {
const groups: Record<RecipientStatusType, TRecipientLite[]> = {
[RecipientStatusType.COMPLETED]: [],
[RecipientStatusType.REJECTED]: [],
[RecipientStatusType.WAITING]: [],
[RecipientStatusType.OPENED]: [],
[RecipientStatusType.UNSIGNED]: [],
};
for (const recipient of recipients) {
groups[getRecipientType(recipient)].push(recipient);
}
return groups;
};
const MAX_VISIBLE_AVATARS = 5;
const COPIED_INDICATOR_DURATION_MS = 2000;
const recipientRowClassName = '-mx-2 flex items-center gap-2 rounded-md px-2 py-1';
type RecipientStatusSection = {
type: RecipientStatusType;
label: MessageDescriptor;
icon: LucideIcon;
className: string;
/** Whether recipients in this section still need to sign, so a signing link can be copied. */
hasSigningLink: boolean;
};
const RECIPIENT_STATUS_SECTIONS: RecipientStatusSection[] = [
{
type: RecipientStatusType.COMPLETED,
label: msg`Completed`,
icon: CircleCheckIcon,
className: 'text-green-600 dark:text-green-400',
hasSigningLink: false,
},
{
type: RecipientStatusType.REJECTED,
label: msg`Rejected`,
icon: CircleXIcon,
className: 'text-red-600 dark:text-red-400',
hasSigningLink: false,
},
{
type: RecipientStatusType.WAITING,
label: msg`Waiting`,
icon: ClockIcon,
className: 'text-blue-600 dark:text-blue-400',
hasSigningLink: true,
},
{
type: RecipientStatusType.OPENED,
label: msg`Opened`,
icon: MailOpenIcon,
className: 'text-amber-600 dark:text-amber-400',
hasSigningLink: true,
},
{
type: RecipientStatusType.UNSIGNED,
label: msg`Uncompleted`,
icon: CircleDashedIcon,
className: 'text-muted-foreground',
hasSigningLink: true,
},
];
@@ -1,41 +0,0 @@
import { getExtraRecipientsType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { StackAvatar } from './stack-avatar';
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
const renderStackAvatars = (recipients: TRecipientLite[]) => {
const zIndex = 50;
const itemsToRender = recipients.slice(0, 5);
const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient, index: number) => {
const first = index === 0;
if (index === 4 && remainingItems > 0) {
return (
<StackAvatar
key="extra-recipient"
first={first}
zIndex={String(zIndex - index * 10)}
type={getExtraRecipientsType(recipients.slice(4))}
fallbackText={`+${remainingItems + 1}`}
/>
);
}
return (
<StackAvatar
key={recipient.id}
first={first}
zIndex={String(zIndex - index * 10)}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
);
});
};
return <>{renderStackAvatars(recipients)}</>;
}
@@ -0,0 +1,42 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useQueryStates } from 'nuqs';
import { useEffect, useState } from 'react';
import { templatesSearchParams } from '~/utils/templates-search-params';
export const TemplateSearch = () => {
const { _ } = useLingui();
const [{ query }, setSearchParams] = useQueryStates(
{
query: templatesSearchParams.query,
page: templatesSearchParams.page,
},
{ history: 'push' },
);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
void setSearchParams({
query: debouncedSearchTerm || null,
page: null,
});
}
}, [debouncedSearchTerm, query, setSearchParams]);
return (
<Input
type="search"
placeholder={_(msg`Search templates...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
data-testid="templates-search-input"
/>
);
};
@@ -1,75 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { File, User2 } from 'lucide-react';
export type UserProfileSkeletonProps = {
className?: string;
user: {
name: string;
url: string;
};
rows?: number;
};
export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div className={cn('flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background', className)}>
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-muted-foreground text-sm lowercase">
{baseUrl.host}/u/{user.url}
</div>
<div className="mt-4">
<div className="rounded-full bg-primary/10 p-1.5">
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
</div>
</div>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="max-w-[12rem] truncate font-semibold text-2xl">{user.name}</h2>
<VerifiedIcon className="h-8 w-8 text-primary" />
</div>
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
</div>
<div className="mt-8 w-full">
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
<Trans>Documents</Trans>
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div key={index} className="flex items-center justify-between gap-x-6 bg-background p-4">
<div className="flex items-center gap-x-2">
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
<div className="space-y-2">
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
<Trans>Sign</Trans>
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};
@@ -1,4 +1,4 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
@@ -14,13 +14,26 @@ import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
stats: TFindDocumentsInternalResponse['stats'];
/**
* Per-status document counts, shown next to each option. When omitted no
* counts are rendered.
*/
stats?: TFindDocumentsInternalResponse['stats'];
/**
* The statuses available for selection. Defaults to every status that
* makes sense for the documents page.
*/
statuses?: ExtendedDocumentStatus[];
};
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
export const DocumentsTableStatusFilter = ({
stats,
statuses = SELECTABLE_STATUSES,
}: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const organisation = useOptionalCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
@@ -32,14 +45,14 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
const selectableStatuses = useMemo(
() =>
SELECTABLE_STATUSES.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
statuses.filter((value) => {
if (organisation?.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
[organisation.type],
[organisation?.type, statuses],
);
const selectedStatus = useMemo(
@@ -65,20 +78,22 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: formatStatsCount(stats[value]),
trailing: stats ? formatStatsCount(stats[value]) : undefined,
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
</span>
))}
</span>
{stats && (
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
</span>
))}
</span>
)}
</>
);
};
@@ -16,22 +16,17 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useQueryStates } from 'nuqs';
import { useMemo, useTransition } from 'react';
import { useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = {
data?: TFindInboxResponse;
isLoading?: boolean;
isLoadingError?: boolean;
};
type DocumentsTableRow = TFindInboxResponse['data'][number];
export const InboxTable = () => {
@@ -40,17 +35,24 @@ export const InboxTable = () => {
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const [findInboxSearchParams] = useQueryStates(inboxSearchParams, {
history: 'push',
});
const status = resolveInboxStatus(findInboxSearchParams.status);
const query = findInboxSearchParams.query ?? '';
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
page: page || 1,
perPage: perPage || 10,
page: Math.max(findInboxSearchParams.page ?? 1, 1),
perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100),
query: query || undefined,
status,
});
const hasSearchQuery = query.trim().length > 0;
const columns = useMemo(() => {
return [
{
@@ -123,7 +125,20 @@ export const InboxTable = () => {
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
<Trans>Documents that require your attention will appear here</Trans>
{match({ hasSearchQuery, status })
.with({ hasSearchQuery: true }, () => <Trans>No documents match your search</Trans>)
.with({ status: DocumentStatusEnum.COMPLETED }, () => (
<Trans>Documents that you have completed will appear here</Trans>
))
.with({ status: DocumentStatusEnum.REJECTED }, () => (
<Trans>Documents that have been rejected will appear here</Trans>
))
.with({ status: DocumentStatusEnum.CANCELLED }, () => (
<Trans>Documents that have been cancelled will appear here</Trans>
))
.otherwise(() => (
<Trans>Documents that require your attention will appear here</Trans>
))}
</p>
</div>
}
@@ -170,7 +170,10 @@ export const TemplatesTableActionDropdown = ({
onOpenChange={setRenameDialogOpen}
envelopeType="template"
onSuccess={async () => {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}}
/>
</DropdownMenu>
@@ -0,0 +1,61 @@
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { trpc } from '@documenso/trpc/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { UserIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { templatesSearchParams } from '~/utils/templates-search-params';
type TemplatesTableOwnerFilterProps = {
teamId: number;
};
export const TemplatesTableOwnerFilter = ({ teamId }: TemplatesTableOwnerFilterProps) => {
const { _ } = useLingui();
const isMounted = useIsMounted();
const [{ ownerIds }, setSearchParams] = useQueryStates(
{
ownerIds: templatesSearchParams.ownerIds,
page: templatesSearchParams.page,
},
{ history: 'push' },
);
const selectedOwnerIds = (ownerIds ?? []).map((ownerId) => ownerId.toString());
const { data, isLoading } = trpc.team.member.getMany.useQuery({
teamId,
});
const options = (data ?? []).map((member) => ({
label: member.name ?? member.email,
value: member.userId.toString(),
}));
const onChange = (newOwnerIds: string[]) => {
void setSearchParams({
ownerIds: newOwnerIds.length > 0 ? newOwnerIds.map(Number) : null,
page: null,
});
};
return (
<FilterPill
multiple
icon={UserIcon}
label={<Trans>Owner</Trans>}
value={selectedOwnerIds}
onChange={onChange}
options={options}
enableSearch
searchPlaceholder={_(msg`Search members...`)}
loading={!isMounted || isLoading}
testId="templates-table-owner-filter"
/>
);
};
@@ -0,0 +1,42 @@
import { Trans } from '@lingui/react/macro';
import { Building2Icon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { TEMPLATES_VIEW_VALUES, templatesSearchParams } from '~/utils/templates-search-params';
const VIEW_OPTIONS = [
{ value: 'team', label: <Trans>Team</Trans> },
{ value: 'organisation', label: <Trans>Organisation</Trans> },
];
export const TemplatesTableViewFilter = () => {
const [{ view }, setSearchParams] = useQueryStates(
{
view: templatesSearchParams.view,
ownerIds: templatesSearchParams.ownerIds,
page: templatesSearchParams.page,
},
{ history: 'push' },
);
const onChange = (newView: string | null) => {
// The owner filter only applies to the team view, so drop it on any view change.
void setSearchParams({
view: TEMPLATES_VIEW_VALUES.find((value) => value === newView) ?? null,
ownerIds: null,
page: null,
});
};
return (
<FilterPill
icon={Building2Icon}
label={<Trans>View</Trans>}
value={view}
onChange={onChange}
options={VIEW_OPTIONS}
testId="templates-table-view-filter"
/>
);
};
+1 -1
View File
@@ -149,7 +149,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<style
nonce={nonce(cspNonce)}
dangerouslySetInnerHTML={{
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
__html: `*, *::before, *::after { animation: none !important; transition-duration: 0.001s !important; transition-delay: 0s !important; }`,
}}
/>
)}
@@ -2,8 +2,11 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { InboxIcon } from 'lucide-react';
import { DocumentSearch } from '~/components/general/document/document-search';
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { InboxTable } from '~/components/tables/inbox-table';
import { INBOX_SELECTABLE_STATUSES } from '~/utils/inbox-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@@ -26,6 +29,14 @@ export default function InboxPage() {
<OrganisationInvitations className="mt-4" />
</div>
<div className="mb-8 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<DocumentSearch />
</div>
<DocumentsTableStatusFilter statuses={INBOX_SELECTABLE_STATUSES} />
</div>
<InboxTable />
</div>
);
@@ -1,7 +1,11 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction, formatTeamUrl } from '@documenso/lib/utils/teams';
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
@@ -17,6 +21,7 @@ import {
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRight,
BarChart3Icon,
CalendarIcon,
MoreVerticalIcon,
PlusIcon,
@@ -114,11 +119,22 @@ export default function OrganisationSettingsTeamsPage() {
</p>
</div>
<Button asChild>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
<div className="flex items-center gap-2">
{canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && (
<Button variant="outline" asChild>
<Link to={formatOrganisationAnalyticsPath(organisation.url)}>
<BarChart3Icon className="mr-2 h-4 w-4" />
<Trans>Analytics</Trans>
</Link>
</Button>
)}
<Button asChild>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -0,0 +1,179 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { formatAnalyticsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { UsersIcon } from 'lucide-react';
import { redirect } from 'react-router';
import type { AnalyticsActivityRow } from '~/components/general/analytics/analytics-activity-table-card';
import { AnalyticsActivityTableCard } from '~/components/general/analytics/analytics-activity-table-card';
import { AnalyticsDocumentsOverTimeCard } from '~/components/general/analytics/analytics-documents-over-time-card';
import { AnalyticsHydrateFallback } from '~/components/general/analytics/analytics-hydrate-fallback';
import { AnalyticsNoActivityAlert } from '~/components/general/analytics/analytics-no-activity-alert';
import { AnalyticsOverviewCards } from '~/components/general/analytics/analytics-overview-cards';
import { AnalyticsPageHeader } from '~/components/general/analytics/analytics-page-header';
import { AnalyticsStatusBreakdownCard } from '~/components/general/analytics/analytics-status-breakdown-card';
import { AnalyticsTemplateUsageCard } from '~/components/general/analytics/analytics-template-usage-card';
import { resolveBrowserTimezone, useAnalyticsRange } from '~/utils/analytics';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/o.$orgUrl.analytics._index';
export function meta() {
return appMetaTags(msg`Analytics`);
}
/**
* Organisation analytics are restricted to organisation admins (not managers),
* matching the tRPC procedures.
*/
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const organisation = await prisma.organisation.findFirst({
where: {
...buildOrganisationWhereQuery({
organisationId: undefined,
userId: session.user.id,
roles: [OrganisationMemberRole.ADMIN],
}),
url: params.orgUrl,
},
select: { id: true },
});
if (!organisation) {
throw redirect(`/o/${params.orgUrl}`);
}
return {};
}
/**
* The timezone is read from the browser so the analytics queries only run on the
* client, after hydration, with the correct day boundaries.
*/
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
await serverLoader();
return { timezone: resolveBrowserTimezone() };
}
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <AnalyticsHydrateFallback />;
}
export default function OrganisationAnalyticsPage({ loaderData }: Route.ComponentProps) {
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
const { timezone } = loaderData;
const { value: range, rangeKey, setValue: setRange } = useAnalyticsRange();
// `from`/`to` are only present for custom ranges.
const queryInput = { organisationId: organisation.id, timezone, ...range };
const overviewQuery = trpc.organisation.analytics.getOverview.useQuery(queryInput);
const documentsOverTimeQuery = trpc.organisation.analytics.getDocumentsOverTime.useQuery(queryInput);
const statusBreakdownQuery = trpc.organisation.analytics.getStatusBreakdown.useQuery(queryInput);
const templateUsageQuery = trpc.organisation.analytics.getTemplateUsage.useQuery({
...queryInput,
limit: TEMPLATE_LIMIT,
});
const teamActivityQuery = trpc.organisation.analytics.getTeamActivity.useQuery(queryInput);
const hasNoActivity =
overviewQuery.isSuccess &&
documentsOverTimeQuery.isSuccess &&
overviewQuery.data.sent.current === 0 &&
overviewQuery.data.sent.previous === 0 &&
documentsOverTimeQuery.data.total === 0;
const teamRows: AnalyticsActivityRow[] = (teamActivityQuery.data?.teams ?? []).map((team) => ({
key: team.id,
avatar: { imageId: team.avatarImageId, fallback: team.name.slice(0, 1).toUpperCase() },
title: team.name,
subtitle: `/t/${team.url}`,
sent: team.sent,
completed: team.completed,
pending: team.pending,
completionRate: team.completionRate,
lastActiveAt: team.lastActiveAt,
href: formatAnalyticsPath(team.url),
}));
return (
<div>
<AnalyticsPageHeader
avatarImageId={organisation.avatarImageId}
name={organisation.name}
range={range}
onRangeChange={setRange}
/>
{hasNoActivity && (
<AnalyticsNoActivityAlert range={range.range} onShowLastYear={() => setRange({ range: '12m' })} />
)}
<div className="mt-6 flex flex-col gap-4">
<AnalyticsOverviewCards
query={overviewQuery}
entity={{
icon: UsersIcon,
title: <Trans>Teams</Trans>,
testId: 'analytics-teams',
select: (data) => data.teams,
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<AnalyticsDocumentsOverTimeCard range={range} query={documentsOverTimeQuery} className="lg:col-span-2" />
<AnalyticsStatusBreakdownCard query={statusBreakdownQuery} />
</div>
<AnalyticsTemplateUsageCard
query={templateUsageQuery}
getTemplateHref={(template) =>
template.team !== null && template.envelopeId !== null
? `${formatTemplatesPath(template.team.url)}/${template.envelopeId}`
: null
}
renderTemplateMeta={(template) => template.team?.name}
/>
<AnalyticsActivityTableCard
query={teamActivityQuery}
rows={teamRows}
rangeKey={rangeKey}
title={<Trans>Team activity</Trans>}
description={<Trans>Documents sent by each team in this period</Trans>}
columnLabel={<Trans>Team</Trans>}
renderSummary={(count, activeCount) => (
<>
<Plural value={count} one="# team" other="# teams" /> · <Trans>{activeCount} active this period</Trans>
</>
)}
renderShowing={(visibleCount, totalCount) => (
<Trans>
Showing {visibleCount} of {totalCount} teams
</Trans>
)}
emptyLabel={<Trans>No teams</Trans>}
searchPlaceholder={_(msg`Search teams`)}
noSearchResultsLabel={<Trans>No teams match your search</Trans>}
testIdPrefix="team"
/>
</div>
</div>
);
}
const TEMPLATE_LIMIT = 5;
@@ -39,7 +39,6 @@ export default function OrganisationSettingsDocumentPage() {
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
} = data;
@@ -47,7 +46,6 @@ export default function OrganisationSettingsDocumentPage() {
documentVisibility === null ||
documentLanguage === null ||
documentDateFormat === null ||
allowDocumentRejection === null ||
aiFeaturesEnabled === null
) {
throw new Error('Should not be possible.');
@@ -65,7 +63,6 @@ export default function OrganisationSettingsDocumentPage() {
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
},
});
@@ -0,0 +1,183 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction, formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { ArrowRightIcon, UsersIcon } from 'lucide-react';
import { Link, redirect } from 'react-router';
import type { AnalyticsActivityRow } from '~/components/general/analytics/analytics-activity-table-card';
import { AnalyticsActivityTableCard } from '~/components/general/analytics/analytics-activity-table-card';
import { AnalyticsDocumentsOverTimeCard } from '~/components/general/analytics/analytics-documents-over-time-card';
import { AnalyticsHydrateFallback } from '~/components/general/analytics/analytics-hydrate-fallback';
import { AnalyticsNoActivityAlert } from '~/components/general/analytics/analytics-no-activity-alert';
import { AnalyticsOverviewCards } from '~/components/general/analytics/analytics-overview-cards';
import { AnalyticsPageHeader } from '~/components/general/analytics/analytics-page-header';
import { AnalyticsStatusBreakdownCard } from '~/components/general/analytics/analytics-status-breakdown-card';
import { AnalyticsTemplateUsageCard } from '~/components/general/analytics/analytics-template-usage-card';
import { useCurrentTeam } from '~/providers/team';
import { resolveBrowserTimezone, useAnalyticsRange } from '~/utils/analytics';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics._index';
export function meta() {
return appMetaTags(msg`Analytics`);
}
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
// `getTeamByUrl` throws when the user isn't a member; treat that like any other
// denial so the documents route renders its "Team not found" state instead of a 500.
const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl }).catch(() => null);
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
throw redirect(formatDocumentsPath(params.teamUrl));
}
return {};
}
/**
* The timezone is read from the browser so the analytics queries only run on the
* client, after hydration, with the correct day boundaries.
*/
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
await serverLoader();
return { timezone: resolveBrowserTimezone() };
}
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <AnalyticsHydrateFallback />;
}
export default function TeamAnalyticsPage({ loaderData }: Route.ComponentProps) {
const team = useCurrentTeam();
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const { timezone } = loaderData;
const { value: range, rangeKey, setValue: setRange } = useAnalyticsRange();
// `from`/`to` are only present for custom ranges.
const queryInput = { teamId: team.id, timezone, ...range };
const overviewQuery = trpc.team.analytics.getOverview.useQuery(queryInput);
const documentsOverTimeQuery = trpc.team.analytics.getDocumentsOverTime.useQuery(queryInput);
const statusBreakdownQuery = trpc.team.analytics.getStatusBreakdown.useQuery(queryInput);
const templateUsageQuery = trpc.team.analytics.getTemplateUsage.useQuery({ ...queryInput, limit: TEMPLATE_LIMIT });
const memberActivityQuery = trpc.team.analytics.getMemberActivity.useQuery(queryInput);
const hasNoActivity =
overviewQuery.isSuccess &&
documentsOverTimeQuery.isSuccess &&
overviewQuery.data.sent.current === 0 &&
overviewQuery.data.sent.previous === 0 &&
documentsOverTimeQuery.data.total === 0;
const templatesPath = formatTemplatesPath(team.url);
const memberRows: AnalyticsActivityRow[] = (memberActivityQuery.data?.members ?? []).map((member) => ({
key: member.userId,
avatar: { imageId: member.avatarImageId, fallback: formatMemberInitials(member.name, member.email) },
title: member.name || member.email,
subtitle: member.name ? member.email : null,
sent: member.sent,
completed: member.completed,
pending: member.pending,
completionRate: member.completionRate,
lastActiveAt: member.lastActiveAt,
}));
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<AnalyticsPageHeader
className="mt-8"
avatarImageId={team.avatarImageId}
name={team.name}
range={range}
onRangeChange={setRange}
actions={
canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && (
<Button variant="ghost" size="sm" className="text-muted-foreground" asChild>
<Link to={formatOrganisationAnalyticsPath(organisation.url)}>
<Trans>View organisation analytics</Trans>
<ArrowRightIcon className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
)
}
/>
{hasNoActivity && (
<AnalyticsNoActivityAlert range={range.range} onShowLastYear={() => setRange({ range: '12m' })} />
)}
<div className="mt-6 flex flex-col gap-4">
<AnalyticsOverviewCards
query={overviewQuery}
entity={{
icon: UsersIcon,
title: <Trans>Members</Trans>,
testId: 'analytics-members',
select: (data) => data.members,
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<AnalyticsDocumentsOverTimeCard range={range} query={documentsOverTimeQuery} className="lg:col-span-2" />
<AnalyticsStatusBreakdownCard query={statusBreakdownQuery} />
</div>
<AnalyticsTemplateUsageCard
query={templateUsageQuery}
templatesHref={templatesPath}
getTemplateHref={(template) =>
template.envelopeId !== null ? `${templatesPath}/${template.envelopeId}` : null
}
/>
<AnalyticsActivityTableCard
query={memberActivityQuery}
rows={memberRows}
rangeKey={rangeKey}
title={<Trans>Member activity</Trans>}
description={<Trans>Documents sent by each member in this period</Trans>}
columnLabel={<Trans>Member</Trans>}
renderSummary={(count, activeCount) => (
<>
<Plural value={count} one="# member" other="# members" /> ·{' '}
<Trans>{activeCount} active this period</Trans>
</>
)}
renderShowing={(visibleCount, totalCount) => (
<Trans>
Showing {visibleCount} of {totalCount} members
</Trans>
)}
emptyLabel={<Trans>No members</Trans>}
searchPlaceholder={_(msg`Search members`)}
noSearchResultsLabel={<Trans>No members match your search</Trans>}
testIdPrefix="member"
/>
</div>
</div>
);
}
const TEMPLATE_LIMIT = 5;
const formatMemberInitials = (name: string | null, email: string) => {
const initials = name ? extractInitials(name) : '';
return initials || email.slice(0, 1).toUpperCase();
};
@@ -43,6 +43,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
if (document.internalVersion !== 1) {
throw redirect(`${documentRootPath}/${document.envelopeId}/edit`);
}
const documentVisibility = document.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document.recipients.find((recipient) => recipient.email === user.email);
@@ -33,7 +33,6 @@ export default function TeamsSettingsPage() {
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
} = data;
@@ -58,7 +57,6 @@ export default function TeamsSettingsPage() {
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}),
delegateDocumentOwnership,
allowDocumentRejection,
},
});
@@ -42,6 +42,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(templateRootPath);
}
if (template.internalVersion !== 1) {
throw redirect(`${templateRootPath}/${template.envelopeId}/edit`);
}
return superLoaderJson({
template: {
...template,
@@ -5,26 +5,28 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, OrganisationType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { Bird, XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { useParams } from 'react-router';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateSearch } from '~/components/general/template/template-search';
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { TemplatesTable } from '~/components/tables/templates-table';
import { TemplatesTableOwnerFilter } from '~/components/tables/templates-table-owner-filter';
import { TemplatesTableViewFilter } from '~/components/tables/templates-table-view-filter';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
import { templatesSearchParams } from '~/utils/templates-search-params';
export function meta() {
return appMetaTags(msg`Templates`);
@@ -39,15 +41,26 @@ export default function TemplatesPage() {
const organisation = useCurrentOrganisation();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const [findTemplateSearchParams, setFindTemplateSearchParams] = useQueryStates(templatesSearchParams, {
history: 'push',
});
const page = findTemplateSearchParams.page || undefined;
const perPage = findTemplateSearchParams.perPage || undefined;
const query = findTemplateSearchParams.query || undefined;
const ownerIds = findTemplateSearchParams.ownerIds ?? undefined;
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const isOrgView = findTemplateSearchParams.view === 'organisation';
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
const hasActiveFilters = Boolean(ownerIds?.length);
const isSearchingOrFiltering = hasActiveFilters || Boolean(query);
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
const onResetFilters = () => {
void setFindTemplateSearchParams({
ownerIds: null,
page: null,
});
};
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
@@ -64,11 +77,13 @@ export default function TemplatesPage() {
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
const teamTemplatesQuery = trpc.template.findTemplatesInternal.useQuery(
{
page,
perPage,
folderId,
query,
ownerIds,
},
{
enabled: !isOrgView,
@@ -79,6 +94,7 @@ export default function TemplatesPage() {
{
page,
perPage,
query,
},
{
enabled: isOrgView,
@@ -87,14 +103,6 @@ export default function TemplatesPage() {
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
const handleViewChange = (newView: string) => {
if (newView !== 'team' && newView !== 'organisation') {
return;
}
void setView(newView === 'team' ? null : newView);
};
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
@@ -112,31 +120,25 @@ export default function TemplatesPage() {
</h1>
</div>
{showOrgTab && (
<div className="mt-6">
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
<TabsList>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="team"
data-testid="template-tab-team"
>
<Trans>Team</Trans>
</TabsTrigger>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="organisation"
data-testid="template-tab-organisation"
>
<Trans>Organisation</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<TemplateSearch />
</div>
)}
{showOrgFilter && <TemplatesTableViewFilter />}
{!isOrgView && <TemplatesTableOwnerFilter teamId={team.id} />}
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
</div>
<div className="mt-8">
{activeQuery.data && activeQuery.data.count === 0 ? (
{activeQuery.data && activeQuery.data.count === 0 && !isSearchingOrFiltering ? (
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<Bird className="h-12 w-12" strokeWidth={1.5} />
@@ -93,22 +93,23 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
})
: [recipient];
if (
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
recipient.role !== RecipientRole.ASSISTANT
) {
const nextPendingRecipient = await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
});
// Dictation eligibility must be decided here, over the FULL recipient list
// — the same computation the completion route enforces. `allRecipients` is
// role-scoped (assistants only see strictly later steps, not their own
// group peers), so deriving it client-side from that list would offer
// dictation the server then silently ignores.
const nextPendingRecipient =
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL
? await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
})
: null;
if (nextPendingRecipient) {
allRecipients.push({
...nextPendingRecipient,
fields: [],
});
}
}
// Only the identity is needed client-side (dictation flag + prefill).
const nextRecipient = nextPendingRecipient
? { name: nextPendingRecipient.name, email: nextPendingRecipient.email }
: null;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
@@ -170,6 +171,7 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
recipient,
recipientWithFields,
allRecipients,
nextRecipient,
completedFields,
recipientSignature,
isRecipientsTurn,
@@ -414,6 +416,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
recipientSignature,
isRecipientsTurn,
allRecipients,
nextRecipient,
includeSenderDetails,
branding,
recipientWithFields,
@@ -486,6 +489,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
nextRecipient={nextRecipient ?? undefined}
includeSenderDetails={includeSenderDetails}
branding={branding}
/>
@@ -19,7 +19,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { data } from 'react-router';
import { match } from 'ts-pattern';
@@ -80,7 +80,11 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
);
}
if (isRecipientExpired(recipient)) {
const isCompleted = recipient.signingStatus === SigningStatus.SIGNED || isDocumentCompleted(document.status);
const isRejected = recipient.signingStatus === SigningStatus.REJECTED;
const hasRecipientActioned = isCompleted || isRejected;
if (!hasRecipientActioned && isRecipientExpired(recipient)) {
throw data(
{
type: 'embed-recipient-expired',
@@ -115,7 +119,7 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
);
}
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
const isRecipientsTurnToSign = hasRecipientActioned || (await getIsRecipientsTurnToSign({ token }));
if (!isRecipientsTurnToSign) {
throw data(
@@ -173,6 +177,8 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
recipient,
fields,
completedFields,
isCompleted,
isRejected,
hidePoweredBy,
allowEmbedSigningWhitelabel,
};
@@ -392,6 +398,8 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
recipient,
fields,
completedFields,
isCompleted,
isRejected,
hidePoweredBy,
allowEmbedSigningWhitelabel,
} = data;
@@ -415,7 +423,8 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
fields={fields}
completedFields={completedFields}
metadata={document.documentMeta}
isCompleted={isDocumentCompleted(document.status)}
isCompleted={isCompleted}
isRejected={isRejected}
hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel}
allRecipients={allRecipients}
+193
View File
@@ -0,0 +1,193 @@
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import {
ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK,
ZAnalyticsDateSchema,
ZTeamAnalyticsRangeSchema,
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { DateTime, IANAZone, Interval } from 'luxon';
import { createParser, parseAsStringEnum, useQueryStates } from 'nuqs';
import { useEffect, useMemo } from 'react';
/**
* The subset of a tRPC query result the analytics cards need. The queries live on
* the page (team or organisation) so the cards stay scope-agnostic.
*/
export type AnalyticsQueryResult<TData> = {
data: TData | undefined;
isLoading: boolean;
isError: boolean;
refetch: () => Promise<unknown>;
};
export type TAnalyticsPresetRange = Exclude<TTeamAnalyticsRange, 'custom'>;
/**
* A fully specified analytics range: either a preset, or a custom inclusive
* [from, to] window of yyyy-MM-dd calendar dates in the browser timezone.
*/
export type AnalyticsRangeValue =
| { range: TAnalyticsPresetRange; from?: undefined; to?: undefined }
| { range: 'custom'; from: string; to: string };
export const ANALYTICS_PRESET_RANGES: TAnalyticsPresetRange[] = ZTeamAnalyticsRangeSchema.options.filter(
(range): range is TAnalyticsPresetRange => range !== 'custom',
);
export const ANALYTICS_RANGE_LABELS: Record<TTeamAnalyticsRange, MessageDescriptor> = {
'7d': msg`Last 7 days`,
'30d': msg`Last 30 days`,
'90d': msg`Last 90 days`,
'12m': msg`Last 12 months`,
custom: msg`Custom range`,
};
export const ANALYTICS_NO_ACTIVITY_LABELS: Record<TTeamAnalyticsRange, MessageDescriptor> = {
'7d': msg`No activity in the last 7 days.`,
'30d': msg`No activity in the last 30 days.`,
'90d': msg`No activity in the last 90 days.`,
'12m': msg`No activity in the last 12 months.`,
custom: msg`No activity in the selected range.`,
};
const DEFAULT_ANALYTICS_RANGE: TAnalyticsPresetRange = '30d';
/**
* The browser's IANA timezone, falling back to UTC when it cannot be resolved or
* is not a zone luxon recognises. Client-only.
*/
export const resolveBrowserTimezone = () => {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return IANAZone.isValidZone(browserTimezone) ? browserTimezone : 'UTC';
};
export const formatRelativeDate = (date: Date, locale: string) => {
return DateTime.fromJSDate(date).setLocale(locale).toRelative() ?? '';
};
/** Parse a strict yyyy-MM-dd calendar date at local midnight, null when invalid. */
export const parseAnalyticsDate = (value: string): DateTime | null => {
if (!ZAnalyticsDateSchema.safeParse(value).success) {
return null;
}
const parsed = DateTime.fromISO(value);
// Round-trip guard so overflowing dates such as 2023-02-30 are rejected.
if (!parsed.isValid || parsed.toISODate() !== value) {
return null;
}
return parsed.startOf('day');
};
/** Format a local `Date` (e.g. one picked in the calendar) as yyyy-MM-dd. */
export const formatAnalyticsDate = (date: Date): string => {
return DateTime.fromJSDate(date).toISODate() ?? '';
};
/** Human readable inclusive span, e.g. "Feb 1 – 29, 2024". */
export const formatAnalyticsDateRange = (from: string, to: string, locale: string): string => {
const start = DateTime.fromISO(from);
const end = DateTime.fromISO(to);
if (!start.isValid || !end.isValid) {
return `${from} – ${to}`;
}
return Interval.fromDateTimes(start, end).toLocaleString(DateTime.DATE_MED, { locale });
};
/** Number of calendar days in the inclusive [from, to] window, 0 when invalid. */
export const getAnalyticsDateRangeDays = (from: string, to: string): number => {
const start = parseAnalyticsDate(from);
const end = parseAnalyticsDate(to);
if (!start || !end || start > end) {
return 0;
}
return Math.round(end.diff(start, 'days').days) + 1;
};
/**
* Validate a custom window client-side, mirroring the backend resolver: both
* dates present and valid, ordered, not in the future and within the maximum span.
*/
export const isValidAnalyticsCustomRange = (from: string | null, to: string | null): boolean => {
if (!from || !to) {
return false;
}
const start = parseAnalyticsDate(from);
const end = parseAnalyticsDate(to);
if (!start || !end || start > end) {
return false;
}
const today = DateTime.local().startOf('day');
if (end > today) {
return false;
}
return start >= today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
};
const parseAsAnalyticsDate = createParser<string>({
parse: (value) => (parseAnalyticsDate(value) ? value : null),
serialize: (value) => value,
});
export const analyticsRangeSearchParams = {
range: parseAsStringEnum(ZTeamAnalyticsRangeSchema.options).withDefault(DEFAULT_ANALYTICS_RANGE),
from: parseAsAnalyticsDate,
to: parseAsAnalyticsDate,
};
/**
* The analytics range held in the URL (`?range=`, plus `?from=&to=` for custom
* ranges). A custom range with missing or invalid bounds falls back to the default
* preset and the stray params are cleared from the URL.
*/
export const useAnalyticsRange = () => {
const [{ range, from, to }, setSearchParams] = useQueryStates(analyticsRangeSearchParams);
const value = useMemo((): AnalyticsRangeValue => {
if (range !== 'custom') {
return { range };
}
if (from && to && isValidAnalyticsCustomRange(from, to)) {
return { range, from, to };
}
return { range: DEFAULT_ANALYTICS_RANGE };
}, [range, from, to]);
const hasInvalidCustomRange = range === 'custom' && value.range !== 'custom';
useEffect(() => {
if (hasInvalidCustomRange) {
void setSearchParams({ range: null, from: null, to: null });
}
}, [hasInvalidCustomRange, setSearchParams]);
const setValue = (next: AnalyticsRangeValue) => {
if (next.range === 'custom') {
void setSearchParams({ range: next.range, from: next.from, to: next.to });
return;
}
void setSearchParams({ range: next.range, from: null, to: null });
};
/** Changes whenever the effective window changes, including a custom span being adjusted. */
const rangeKey = `${value.range}:${value.from ?? ''}:${value.to ?? ''}`;
return { value, rangeKey, setValue };
};
@@ -0,0 +1,33 @@
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { INBOX_STATUSES, type TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types';
import { documentsSearchParams } from './documents-search-params';
/**
* The statuses that can be selected from the inbox status filter.
*/
export const INBOX_SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [...INBOX_STATUSES];
/**
* Shared nuqs parsers for the inbox page URL state.
*
* Reuses the documents parsers so the shared filter components
* (`DocumentSearch`, `DocumentsTableStatusFilter`) read and write the same
* params on both pages.
*/
export const inboxSearchParams = {
status: documentsSearchParams.status,
page: documentsSearchParams.page,
perPage: documentsSearchParams.perPage,
query: documentsSearchParams.query,
};
/**
* Narrows the URL `status` param to a status supported by the inbox.
*
* Returns `undefined` when it is missing or not selectable, which shows every
* non-draft document.
*/
export const resolveInboxStatus = (status: ExtendedDocumentStatus | null): TInboxStatus | undefined => {
return INBOX_STATUSES.find((value) => value === status);
};
@@ -0,0 +1,17 @@
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
export const TEMPLATES_VIEW_VALUES = ['team', 'organisation'] as const;
/**
* Shared nuqs parsers for the templates page URL state.
*
* Used by the templates page and its filter components so every consumer
* parses and serialises the params identically.
*/
export const templatesSearchParams = {
view: parseAsStringLiteral(TEMPLATES_VIEW_VALUES),
ownerIds: parseAsArrayOf(parseAsInteger),
page: parseAsInteger,
perPage: parseAsInteger,
query: parseAsString,
};
+1 -1
View File
@@ -92,7 +92,7 @@ export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('INVALID_DOCUMENT_FILE', () => ({
title: msg`Error`,
description: msg`You cannot upload encrypted PDFs.`,
description: msg`The file is not a valid PDF or is password protected.`,
}))
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
title: msg`Error`,
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.18.0"
"version": "2.19.0"
}
+1 -38
View File
@@ -1,4 +1,3 @@
import { extractSessionCookieFromHeaders } from '@documenso/auth/server/lib/session/session-cookies';
import { extractRequestMetadata, type RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Context, Next } from 'hono';
@@ -14,49 +13,13 @@ export type AppContext = {
*/
export const appContext = async (c: Context, next: Next) => {
const request = c.req.raw;
const url = new URL(request.url);
const noSessionCookie = extractSessionCookieFromHeaders(request.headers) === null;
setAppContext(c, {
requestMetadata: extractRequestMetadata(request),
});
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return await next();
}
// Add context to any pages you want here.
return await next();
return next();
};
const setAppContext = (c: Context, context: AppContext) => {
c.set('context', context);
};
const isPageRequest = (request: Request) => {
const url = new URL(request.url);
if (request.method !== 'GET') {
return false;
}
// If it ends with .data it's the loader which we need to pass context for.
if (url.pathname.endsWith('.data')) {
return true;
}
if (request.headers.get('Accept')?.includes('text/html')) {
return true;
}
return false;
};
/**
* List of paths to reject
* - Urls that start with /api
* - Urls that start with _
*/
const blacklistedPathsRegex = /^\/api\/|^\/__/;
+1512 -3689
View File
File diff suppressed because it is too large Load Diff
+19 -4
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.18.0",
"version": "2.19.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -46,6 +46,21 @@
"npm": ">=11.17.0",
"node": ">=24.0.0"
},
"allowScripts": {
"@documenso/skia-canvas": true,
"@playwright/browser-chromium": true,
"@prisma/client": true,
"@prisma/engines": true,
"aws-crt": true,
"esbuild": true,
"fsevents": true,
"inngest-cli": true,
"prisma": true,
"@datadog/pprof": false,
"core-js": false,
"msgpackr-extract": false,
"protobufjs": false
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
@@ -67,7 +82,7 @@
"esbuild": "^0.28.1",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
"inngest-cli": "^1.44.0",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^9.0.0",
@@ -91,7 +106,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.2",
"@libpdf/core": "^0.5.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -130,7 +145,7 @@
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"sharp": "0.35.3",
"sharp": "0.35.4",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
+5 -4
View File
@@ -11,6 +11,7 @@ import {
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
import {
DocumentDataType,
@@ -145,7 +146,7 @@ export const ZCreateDocumentMutationSchema = z.object({
name: z.string().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
signingOrder: ZRecipientSigningOrderSchema.nullish(),
}),
),
meta: z
@@ -235,7 +236,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
name: z.string().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
signingOrder: ZRecipientSigningOrderSchema.nullish(),
}),
),
meta: z
@@ -315,7 +316,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
id: z.number(),
email: zEmail(),
name: z.string().optional(),
signingOrder: z.number().optional(),
signingOrder: ZRecipientSigningOrderSchema.optional(),
}),
)
.refine(
@@ -389,7 +390,7 @@ export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
signingOrder: ZRecipientSigningOrderSchema.nullish(),
authOptions: z
.object({
actionAuth: z
@@ -0,0 +1,110 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
/**
* Field insertion must respect the recipient's signing window: an expired
* recipient can no longer act on the envelope at all. The V1 endpoints assert
* this; the V2 `envelope.field.sign` route historically did not.
*/
const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number; value: string }) => {
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({
json: {
token: input.token,
fieldId: input.fieldId,
fieldValue: {
type: FieldType.TEXT,
value: input.value,
},
},
}),
});
};
const seedV2PendingDocumentWithTextField = async () => {
const { user, team } = await seedUser();
const { user: signer } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [signer],
fields: [FieldType.TEXT],
updateDocumentOptions: {
internalVersion: 2,
},
});
const recipient = recipients[0];
const textField = recipient.fields.find((field) => field.type === FieldType.TEXT);
if (!textField) {
throw new Error('Seeded text field not found');
}
return { recipient, textField };
};
test('[ENVELOPE_FIELD_SIGN]: rejects field insertion for an expired recipient', async ({ page }) => {
const { recipient, textField } = await seedV2PendingDocumentWithTextField();
await prisma.recipient.update({
where: { id: recipient.id },
data: {
// Expired one hour ago.
expiresAt: new Date(Date.now() - 60 * 60 * 1000),
},
});
// The seed pre-populates customText with a placeholder value.
const fieldBefore = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
const response = await callSignEnvelopeField(page, {
token: recipient.token,
fieldId: textField.id,
value: 'TEXT',
});
expect(response.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe(fieldBefore.customText);
});
test('[ENVELOPE_FIELD_SIGN]: accepts field insertion for a recipient within their signing window', async ({ page }) => {
// Positive control: proves the request format reaches the route, so the
// expired-recipient rejection above cannot pass vacuously.
const { recipient, textField } = await seedV2PendingDocumentWithTextField();
await prisma.recipient.update({
where: { id: recipient.id },
data: {
// Expires an hour from now.
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const response = await callSignEnvelopeField(page, {
token: recipient.token,
fieldId: textField.id,
value: 'TEXT',
});
expect(response.ok()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('TEXT');
});
@@ -0,0 +1,423 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import {
seedCancelledDocument,
seedCompletedDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, RecipientRole, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
type InboxFindInput = {
query?: string;
status?: string;
page?: number;
perPage?: number;
};
type InboxFindDocument = {
envelopeId: string;
title: string;
status: string;
recipients: Array<{ email: string; token: string }>;
};
/**
* Calls `document.inbox.find` directly, bypassing any UI level restrictions so
* we can assert the server rejects or ignores hostile input on its own.
*/
const trpcInboxFind = async (page: Page, input: InboxFindInput) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
const url = `${WEBAPP_BASE_URL}/api/trpc/document.inbox.find?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
data: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json as { data: InboxFindDocument[]; count: number })
: null,
};
};
const titlesOf = (data: { data: InboxFindDocument[] } | null) => (data?.data ?? []).map((doc) => doc.title);
// ─── Recipient scoping ───────────────────────────────────────────────────────
test.describe('Inbox Find - Recipient Scoping', () => {
test('should not return documents the user is not a recipient of, even when searched by title', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: victim } = await seedUser();
const { user: attacker } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Confidential Merger Agreement' },
});
await seedCompletedDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Confidential Severance Package' },
});
// Positive control: the actual recipient can find them.
await apiSignin({ page, email: victim.email });
const victimPending = await trpcInboxFind(page, { query: 'Confidential', status: 'PENDING' });
expect(titlesOf(victimPending.data)).toEqual(['Confidential Merger Agreement']);
const victimCompleted = await trpcInboxFind(page, { query: 'Confidential', status: 'COMPLETED' });
expect(titlesOf(victimCompleted.data)).toEqual(['Confidential Severance Package']);
await apiSignout({ page });
// The attacker knows the exact title but is not a recipient.
await apiSignin({ page, email: attacker.email });
for (const status of ['PENDING', 'COMPLETED', undefined]) {
const { res, data } = await trpcInboxFind(page, { query: 'Confidential', status });
expect(res.ok()).toBeTruthy();
expect(data?.count).toBe(0);
expect(titlesOf(data)).toEqual([]);
}
await apiSignout({ page });
});
test('should not return documents where the user is only a CC recipient', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'CC Only Contract' },
});
await prisma.recipient.updateMany({
where: { envelopeId: ccDocument.id, email: recipient.email },
data: { role: RecipientRole.CC },
});
// Positive control on the same account.
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Signer Contract' },
});
await apiSignin({ page, email: recipient.email });
const unfiltered = await trpcInboxFind(page, {});
expect(titlesOf(unfiltered.data)).toEqual(['Signer Contract']);
const searched = await trpcInboxFind(page, { query: 'CC Only' });
expect(titlesOf(searched.data)).toEqual([]);
await apiSignout({ page });
});
test('should not expose team documents to team members who are not recipients', async ({ page }) => {
const { team, owner } = await seedTeam();
const { user: outsideRecipient } = await seedUser();
// A team admin can see this document on the team documents page, but the
// inbox is strictly recipient scoped.
const teamAdmin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
await seedPendingDocument(owner, team.id, [outsideRecipient], {
createDocumentOptions: { title: 'Team Payroll Summary' },
});
await seedCompletedDocument(owner, team.id, [outsideRecipient], {
createDocumentOptions: { title: 'Team Board Minutes' },
});
await apiSignin({ page, email: teamAdmin.email });
const pending = await trpcInboxFind(page, { query: 'Team', status: 'PENDING' });
expect(titlesOf(pending.data)).toEqual([]);
const completed = await trpcInboxFind(page, { query: 'Team', status: 'COMPLETED' });
expect(titlesOf(completed.data)).toEqual([]);
await apiSignout({ page });
// The document owner is also not a recipient, so it should not be in their inbox either.
await apiSignin({ page, email: owner.email });
const ownerResult = await trpcInboxFind(page, { query: 'Team' });
expect(titlesOf(ownerResult.data)).toEqual([]);
await apiSignout({ page });
});
test('should mask signing tokens of other recipients', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const { user: otherRecipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], {
createDocumentOptions: { title: 'Shared Token Document' },
});
await apiSignin({ page, email: recipient.email });
const { data } = await trpcInboxFind(page, { query: 'Shared Token', status: 'PENDING' });
expect(data?.data).toHaveLength(1);
const document = data?.data[0];
const ownRecipient = document?.recipients.find((r) => r.email === recipient.email);
const foreignRecipient = document?.recipients.find((r) => r.email === otherRecipient.email);
expect(ownRecipient?.token).toBeTruthy();
expect(foreignRecipient?.token).toBe('');
await apiSignout({ page });
});
});
// ─── Search hardening ────────────────────────────────────────────────────────
test.describe('Inbox Find - Search Hardening', () => {
test('should keep wildcard searches scoped to the recipient inbox', async ({ page }) => {
// SQL LIKE wildcards ("%" and "_") are intentionally passed through so
// users can do advanced searches. That must only ever widen the title
// match, never the recipient scoping.
const { user: sender, team: senderTeam } = await seedUser();
const { user: victim } = await seedUser();
const { user: attacker } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Victim Alpha Report' },
});
await seedCompletedDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Victim Beta Report' },
});
// The attacker has one document of their own so we can prove wildcards
// return their inbox and nothing more.
await seedPendingDocument(sender, senderTeam.id, [attacker], {
createDocumentOptions: { title: 'Attacker Own Report' },
});
const wildcardQueries = ['%', '_', '%%%', '%Report%', 'Victim%', 'Victim _lpha%', '\\', '%victim%'];
// Positive control: wildcards work for the actual recipient.
await apiSignin({ page, email: victim.email });
const victimAll = await trpcInboxFind(page, { query: '%' });
expect(titlesOf(victimAll.data).sort()).toEqual(['Victim Alpha Report', 'Victim Beta Report']);
const victimPattern = await trpcInboxFind(page, { query: 'Victim _lpha%' });
expect(titlesOf(victimPattern.data)).toEqual(['Victim Alpha Report']);
await apiSignout({ page });
// The attacker gets exactly their own inbox for every wildcard, never the victim's.
await apiSignin({ page, email: attacker.email });
for (const query of wildcardQueries) {
const { res, data } = await trpcInboxFind(page, { query });
expect(res.ok(), `query "${query}"`).toBeTruthy();
const titles = titlesOf(data);
expect(titles, `query "${query}"`).not.toContain('Victim Alpha Report');
expect(titles, `query "${query}"`).not.toContain('Victim Beta Report');
expect(
titles.every((title) => title === 'Attacker Own Report'),
`query "${query}"`,
).toBe(true);
}
// Wildcards combined with the status filter still cannot escape the scope.
for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED']) {
const { data } = await trpcInboxFind(page, { query: '%', status });
expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Alpha Report');
expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Beta Report');
}
await apiSignout({ page });
});
test('should only match against the document title', async ({ page }) => {
// Explicit names so the negative queries below are deterministic.
const { user: sender, team: senderTeam } = await seedUser({ name: 'Sender Person' });
const { user: recipient } = await seedUser({ name: 'Recipient Person' });
await seedPendingDocument(sender, senderTeam.id, ['zebra-person@test.documenso.com', recipient], {
createDocumentOptions: {
title: 'Plain Title',
externalId: 'ext-hidden-identifier',
},
});
await apiSignin({ page, email: recipient.email });
// Positive control.
const byTitle = await trpcInboxFind(page, { query: 'Plain' });
expect(titlesOf(byTitle.data)).toEqual(['Plain Title']);
// External IDs, sender details and other recipients must not be probeable
// through the inbox search.
for (const query of ['ext-hidden', sender.email, 'Sender Person', 'zebra-person']) {
const { data } = await trpcInboxFind(page, { query });
expect(titlesOf(data), `query "${query}"`).toEqual([]);
}
await apiSignout({ page });
});
test('should not surface deleted, draft or template envelopes through search', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Hidden Deleted Document' },
});
await prisma.envelope.update({
where: { id: deletedDocument.id },
data: { deletedAt: new Date() },
});
await seedDraftDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Hidden Draft Document' },
});
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Hidden Template Envelope', type: EnvelopeType.TEMPLATE },
});
// Positive control.
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Hidden Visible Document' },
});
await apiSignin({ page, email: recipient.email });
const unfiltered = await trpcInboxFind(page, { query: 'Hidden' });
expect(titlesOf(unfiltered.data)).toEqual(['Hidden Visible Document']);
for (const query of ['Hidden Deleted', 'Hidden Draft', 'Hidden Template']) {
const { data } = await trpcInboxFind(page, { query });
expect(titlesOf(data)).toEqual([]);
}
await apiSignout({ page });
});
});
// ─── Status filter hardening ─────────────────────────────────────────────────
test.describe('Inbox Find - Status Filter Hardening', () => {
test('should reject draft and virtual status values', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
// A recipient on a draft must never be able to pull it out via the status filter.
await seedDraftDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Unsent Draft Document' },
});
await apiSignin({ page, email: recipient.email });
for (const status of ['DRAFT', 'EXPIRED', 'INBOX', 'ALL', 'pending', 'draft', '']) {
const { res, data } = await trpcInboxFind(page, { status });
expect(res.status(), `status "${status}" should be rejected`).toBe(400);
expect(data).toBeNull();
}
// Sanity check that the valid filters, and the unfiltered view, never include the draft.
for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) {
const { res, data } = await trpcInboxFind(page, { status });
expect(res.ok(), `status "${status}" should be accepted`).toBeTruthy();
expect(titlesOf(data)).toEqual([]);
}
await apiSignout({ page });
});
test('should scope each status filter to exactly that status', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Pending Document' },
});
await seedCompletedDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Completed Document' },
});
await seedCancelledDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Cancelled Document' },
});
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Rejected Document', status: DocumentStatus.REJECTED },
});
await apiSignin({ page, email: recipient.email });
const expectations = [
{ status: 'PENDING', expected: ['Scoped Pending Document'] },
{ status: 'COMPLETED', expected: ['Scoped Completed Document'] },
{ status: 'CANCELLED', expected: ['Scoped Cancelled Document'] },
{ status: 'REJECTED', expected: ['Scoped Rejected Document'] },
];
for (const { status, expected } of expectations) {
const { data } = await trpcInboxFind(page, { query: 'Scoped', status });
expect(titlesOf(data), `status "${status}"`).toEqual(expected);
}
await apiSignout({ page });
});
test('should reject pagination values outside of the allowed range', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email });
const tooManyPerPage = await trpcInboxFind(page, { perPage: 101 });
expect(tooManyPerPage.res.status()).toBe(400);
const zeroPerPage = await trpcInboxFind(page, { perPage: 0 });
expect(zeroPerPage.res.status()).toBe(400);
const zeroPage = await trpcInboxFind(page, { page: 0 });
expect(zeroPage.res.status()).toBe(400);
await apiSignout({ page });
});
});
// ─── Authentication ──────────────────────────────────────────────────────────
test.describe('Inbox Find - Authentication', () => {
test('should reject unauthenticated requests', async ({ page }) => {
const { res } = await trpcInboxFind(page, { query: 'anything', status: 'PENDING' });
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
});
@@ -0,0 +1,545 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { APIRequestContext, APIResponse } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const nanoid = customAlphabet('1234567890abcdef', 10);
test.describe.configure({
mode: 'parallel',
});
const TEAM_ANALYTICS_PROCEDURES = [
'team.analytics.getOverview',
'team.analytics.getDocumentsOverTime',
'team.analytics.getStatusBreakdown',
'team.analytics.getTemplateUsage',
'team.analytics.getMemberActivity',
] as const;
const ORGANISATION_ANALYTICS_PROCEDURES = [
'organisation.analytics.getOverview',
'organisation.analytics.getDocumentsOverTime',
'organisation.analytics.getStatusBreakdown',
'organisation.analytics.getTemplateUsage',
'organisation.analytics.getTeamActivity',
] as const;
const seedScenario = async () => {
const suffix = nanoid();
const { user: owner, organisation, team: siblingTeam } = await seedUser();
const targetTeamUrl = `analytics-target-${suffix}`;
await createTeam({
userId: owner.id,
teamName: `Analytics Target Team ${suffix}`,
teamUrl: targetTeamUrl,
organisationId: organisation.id,
// Keeps plain organisation members out of the target team.
inheritMembers: false,
});
const targetTeam = await prisma.team.findFirstOrThrow({ where: { url: targetTeamUrl } });
const targetManagerName = `Analytics Target Manager ${suffix}`;
const targetManager = await seedTeamMember({
teamId: targetTeam.id,
name: targetManagerName,
role: TeamMemberRole.MANAGER,
});
const targetMember = await seedTeamMember({ teamId: targetTeam.id, role: TeamMemberRole.MEMBER });
const siblingTeamAdmin = await seedTeamMember({ teamId: siblingTeam.id, role: TeamMemberRole.ADMIN });
const [organisationMember, organisationManager] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{ organisationRole: OrganisationMemberRole.MEMBER },
{ organisationRole: OrganisationMemberRole.MANAGER },
],
});
const { user: outsider, team: outsiderTeam, organisation: outsiderOrganisation } = await seedUser();
const { user: recipient } = await seedUser();
const template = await seedBlankTemplate(owner, targetTeam.id, {
createTemplateOptions: { title: `Analytics Target Template ${suffix}` },
});
const document = await seedBlankDocument(targetManager, targetTeam.id, {
createDocumentOptions: {
title: `Analytics Target Document ${suffix}`,
status: DocumentStatus.PENDING,
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
});
await prisma.documentAuditLog.create({
data: {
envelopeId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: new Date(),
data: {},
},
});
await prisma.recipient.create({
data: {
envelopeId: document.id,
email: recipient.email,
name: 'Analytics Recipient',
token: nanoid(),
},
});
const markers = {
teamName: targetTeam.name,
templateTitle: template.title,
managerName: targetManagerName,
managerEmail: targetManager.email,
};
return {
owner,
organisation,
targetTeam,
targetManager,
targetMember,
siblingTeamAdmin,
organisationMember,
organisationManager,
outsider,
outsiderTeam,
outsiderOrganisation,
recipient,
markers: Object.values(markers),
namedMarkers: markers,
};
};
type Scenario = Awaited<ReturnType<typeof seedScenario>>;
type DeniedCaller = {
name: string;
caller: (scenario: Scenario) => { email: string };
};
const TEAM_DENIED_CALLERS: DeniedCaller[] = [
{ name: 'a user from another organisation', caller: (s) => s.outsider },
{ name: 'a recipient of a team document who is not a member', caller: (s) => s.recipient },
{ name: 'an organisation member who is not in the team', caller: (s) => s.organisationMember },
{ name: 'an admin of a sibling team', caller: (s) => s.siblingTeamAdmin },
{ name: 'a team member below manager', caller: (s) => s.targetMember },
];
const ORGANISATION_DENIED_CALLERS: DeniedCaller[] = [
{ name: 'a user from another organisation', caller: (s) => s.outsider },
{ name: 'a recipient of an organisation document', caller: (s) => s.recipient },
{ name: 'an organisation member', caller: (s) => s.organisationMember },
{ name: 'an organisation manager', caller: (s) => s.organisationManager },
{ name: 'a team admin who is an organisation member', caller: (s) => s.siblingTeamAdmin },
{ name: 'a team manager who is an organisation member', caller: (s) => s.targetManager },
];
test.describe('Team Analytics API - Adversarial: Access', () => {
test('should reject unauthenticated requests on every procedure', async ({ request }) => {
const { targetTeam, markers } = await seedScenario();
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(request, procedure, { teamId: targetTeam.id });
expectDenied(res, markers);
}
});
for (const { name, caller } of TEAM_DENIED_CALLERS) {
test(`should reject ${name} on every procedure`, async ({ page }) => {
const scenario = await seedScenario();
await apiSignin({ page, email: caller(scenario).email });
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, { teamId: scenario.targetTeam.id });
expectDenied(res, scenario.markers);
}
});
}
test('should allow team admins and managers (control)', async ({ page }) => {
const { owner, targetManager, organisationManager, targetTeam, namedMarkers } = await seedScenario();
for (const caller of [owner, targetManager, organisationManager]) {
await apiSignin({ page, email: caller.email });
const bodies: string[] = [];
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, { teamId: targetTeam.id });
expectAllowed(res);
bodies.push(res.body);
}
const combined = bodies.join('\n');
expect(combined).toContain(namedMarkers.templateTitle);
expect(combined).toContain(namedMarkers.managerName);
expect(combined).toContain(namedMarkers.managerEmail);
}
});
});
test.describe('Team Analytics API - Adversarial: Tampering', () => {
test('should ignore identity fields injected into the input', async ({ page }) => {
const { owner, outsider, targetTeam, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, {
teamId: targetTeam.id,
userId: owner.id,
userEmail: owner.email,
});
expectDenied(res, markers);
}
});
test('should not grant access through the x-team-id header', async ({ page }) => {
const { outsider, outsiderTeam, targetTeam, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const targetHeader = await trpcQuery(
page.context().request,
procedure,
{ teamId: targetTeam.id },
{ 'x-team-id': targetTeam.id.toString() },
);
expectDenied(targetHeader, markers);
const ownHeader = await trpcQuery(
page.context().request,
procedure,
{ teamId: targetTeam.id },
{ 'x-team-id': outsiderTeam.id.toString() },
);
expectDenied(ownHeader, markers);
}
});
test('should reject target team calls batched with an allowed call', async ({ page }) => {
const { outsider, outsiderTeam, targetTeam, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const { response, body, results } = await trpcBatchQuery(page.context().request, [
{ procedure: 'team.analytics.getOverview', input: { teamId: outsiderTeam.id } },
...TEAM_ANALYTICS_PROCEDURES.map((procedure) => ({ procedure, input: { teamId: targetTeam.id } })),
]);
expect(response.status()).toBe(207);
expect(results).toHaveLength(TEAM_ANALYTICS_PROCEDURES.length + 1);
const [allowed, ...denied] = results;
expect(allowed.result).toBeDefined();
expect(allowed.error).toBeUndefined();
for (const item of denied) {
expect(item.result).toBeUndefined();
expect(item.error?.json.data.httpStatus).toBe(401);
expect(item.error?.json.data.code).toBe('UNAUTHORIZED');
}
expectNoMarkers(body, markers);
});
test('should reject API tokens, even one scoped to the target team', async ({ request }) => {
const { owner, targetTeam, markers } = await seedScenario();
const { token } = await createApiToken({
userId: owner.id,
teamId: targetTeam.id,
tokenName: 'analytics-adversarial',
expiresIn: null,
});
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
for (const authorization of [`Bearer ${token}`, token]) {
const res = await trpcQuery(request, procedure, { teamId: targetTeam.id }, { Authorization: authorization });
expectDenied(res, markers);
}
}
});
test('should not reveal whether a team exists', async ({ page }) => {
const { outsider, targetTeam } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const existing = await trpcQuery(page.context().request, procedure, { teamId: targetTeam.id });
const missing = await trpcQuery(page.context().request, procedure, { teamId: 2_147_483_647 });
expect(existing.response.status()).toBe(401);
expect(missing.response.status()).toBe(401);
expect(errorMessageOf(missing)).toBe(errorMessageOf(existing));
}
});
});
test.describe('Organisation Analytics API - Adversarial: Access', () => {
test('should reject unauthenticated requests on every procedure', async ({ request }) => {
const { organisation, markers } = await seedScenario();
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(request, procedure, { organisationId: organisation.id });
expectDenied(res, markers);
}
});
for (const { name, caller } of ORGANISATION_DENIED_CALLERS) {
test(`should reject ${name} on every procedure`, async ({ page }) => {
const scenario = await seedScenario();
await apiSignin({ page, email: caller(scenario).email });
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, {
organisationId: scenario.organisation.id,
});
expectDenied(res, scenario.markers);
}
});
}
test('should allow organisation admins (control)', async ({ page }) => {
const { owner, organisation, namedMarkers } = await seedScenario();
await apiSignin({ page, email: owner.email });
const bodies: string[] = [];
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, { organisationId: organisation.id });
expectAllowed(res);
bodies.push(res.body);
}
const combined = bodies.join('\n');
expect(combined).toContain(namedMarkers.teamName);
expect(combined).toContain(namedMarkers.templateTitle);
});
});
test.describe('Organisation Analytics API - Adversarial: Tampering', () => {
test('should ignore identity fields injected into the input', async ({ page }) => {
const { owner, outsider, organisation, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, {
organisationId: organisation.id,
userId: owner.id,
userEmail: owner.email,
});
expectDenied(res, markers);
}
});
test('should not grant access through the x-team-id header', async ({ page }) => {
const { organisationMember, organisation, targetTeam, markers } = await seedScenario();
await apiSignin({ page, email: organisationMember.email });
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(
page.context().request,
procedure,
{ organisationId: organisation.id },
{ 'x-team-id': targetTeam.id.toString() },
);
expectDenied(res, markers);
}
});
test('should reject target organisation calls batched with an allowed call', async ({ page }) => {
const { outsider, outsiderOrganisation, organisation, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const { response, body, results } = await trpcBatchQuery(page.context().request, [
{ procedure: 'organisation.analytics.getOverview', input: { organisationId: outsiderOrganisation.id } },
...ORGANISATION_ANALYTICS_PROCEDURES.map((procedure) => ({
procedure,
input: { organisationId: organisation.id },
})),
]);
expect(response.status()).toBe(207);
expect(results).toHaveLength(ORGANISATION_ANALYTICS_PROCEDURES.length + 1);
const [allowed, ...denied] = results;
expect(allowed.result).toBeDefined();
expect(allowed.error).toBeUndefined();
for (const item of denied) {
expect(item.result).toBeUndefined();
expect(item.error?.json.data.httpStatus).toBe(401);
expect(item.error?.json.data.code).toBe('UNAUTHORIZED');
}
expectNoMarkers(body, markers);
});
test('should reject API tokens, even one belonging to an organisation admin', async ({ request }) => {
const { owner, organisation, targetTeam, markers } = await seedScenario();
const { token } = await createApiToken({
userId: owner.id,
teamId: targetTeam.id,
tokenName: 'analytics-adversarial',
expiresIn: null,
});
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
for (const authorization of [`Bearer ${token}`, token]) {
const res = await trpcQuery(
request,
procedure,
{ organisationId: organisation.id },
{ Authorization: authorization },
);
expectDenied(res, markers);
}
}
});
test('should not reveal whether an organisation exists', async ({ page }) => {
const { outsider, organisation } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const existing = await trpcQuery(page.context().request, procedure, { organisationId: organisation.id });
const missing = await trpcQuery(page.context().request, procedure, {
organisationId: `org_does_not_exist_${nanoid()}`,
});
expect(existing.response.status()).toBe(401);
expect(missing.response.status()).toBe(401);
expect(errorMessageOf(missing)).toBe(errorMessageOf(existing));
}
});
});
type TrpcResponseItem = {
result?: { data: { json: unknown } };
error?: { json: { message: string; data: { code: string; httpStatus: number } } };
};
type TrpcResult = {
response: APIResponse;
body: string;
json: TrpcResponseItem | null;
};
const DEFAULT_ANALYTICS_INPUT = { range: '30d', timezone: 'UTC' };
const trpcQuery = async (
request: APIRequestContext,
procedure: string,
input: Record<string, unknown>,
headers: Record<string, string> = {},
): Promise<TrpcResult> => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { ...DEFAULT_ANALYTICS_INPUT, ...input } }));
const response = await request.get(`${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`, { headers });
const body = await response.text();
return { response, body, json: parseJson<TrpcResponseItem>(body) };
};
const trpcBatchQuery = async (
request: APIRequestContext,
calls: Array<{ procedure: string; input: Record<string, unknown> }>,
) => {
const procedures = calls.map((call) => call.procedure).join(',');
const input = Object.fromEntries(
calls.map((call, index) => [index, { json: { ...DEFAULT_ANALYTICS_INPUT, ...call.input } }]),
);
const response = await request.get(
`${WEBAPP_BASE_URL}/api/trpc/${procedures}?batch=1&input=${encodeURIComponent(JSON.stringify(input))}`,
);
const body = await response.text();
return { response, body, results: parseJson<TrpcResponseItem[]>(body) ?? [] };
};
const parseJson = <T>(body: string): T | null => {
try {
return JSON.parse(body);
} catch {
return null;
}
};
const errorMessageOf = (res: TrpcResult) => res.json?.error?.json.message;
const expectNoMarkers = (body: string, markers: string[]) => {
for (const marker of markers) {
expect(body).not.toContain(marker);
}
};
const expectDenied = (res: TrpcResult, markers: string[]) => {
expect(res.response.status()).toBe(401);
expect(res.json?.result).toBeUndefined();
expect(res.json?.error?.json.data.code).toBe('UNAUTHORIZED');
expectNoMarkers(res.body, markers);
};
const expectAllowed = (res: TrpcResult) => {
expect(res.response.status()).toBe(200);
expect(res.json?.result?.data.json).toBeDefined();
};
@@ -0,0 +1,71 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { apiSeedDraftDocument } from '../../fixtures/api-seeds';
const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`;
/**
* `Recipient.signingOrder` is an Int column, but nothing constrained the input
* to an integer. Prisma does not reject a fraction — it truncates it (1.5 -> 1),
* so distinct orders could silently collapse onto the same value, which under
* signing groups means "same step". Negatives were persisted as-is and sort
* ahead of everything, including the `?? 0` fallback in assistant scoping.
* Zero remains valid for legacy handling.
*/
const createRecipient = async (request: APIRequestContext, token: string, envelopeId: string, signingOrder: number) =>
await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: [
{
email: `signing-order-${Date.now()}-${signingOrder}@documenso.com`,
name: 'Signing Order Test',
role: 'SIGNER',
signingOrder,
},
],
},
});
test('[SIGNING_ORDER_VALIDATION]: rejects a fractional signing order with a client error', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation' });
const response = await createRecipient(request, token, envelope.id, 1.5);
expect(response.status()).toBe(400);
// Nothing may be written — in particular not a silently truncated `1`.
const recipients = await prisma.recipient.findMany({ where: { envelopeId: envelope.id } });
expect(recipients).toHaveLength(0);
});
test('[SIGNING_ORDER_VALIDATION]: rejects a negative signing order', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, {
title: '[TEST] Signing order validation negative',
});
const response = await createRecipient(request, token, envelope.id, -1);
expect(response.status()).toBe(400);
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: -1 } });
expect(persisted).toHaveLength(0);
});
test('[SIGNING_ORDER_VALIDATION]: still accepts a valid positive integer signing order', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation valid' });
const response = await createRecipient(request, token, envelope.id, 2);
expect(response.ok(), await response.text()).toBeTruthy();
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: 2 } });
expect(persisted).toHaveLength(1);
});
@@ -0,0 +1,118 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { apiSeedDraftDocument } from '../../fixtures/api-seeds';
const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`;
/**
* AES/QES envelopes cannot contain signing groups: two recipients sharing a
* step sign in parallel, which breaks the per-recipient /ByteRange invariant
* TSP signatures depend on. That rule previously lived only in the editor's
* form schema, so the API would happily create the forbidden state.
*
* The signature level is seeded directly because `resolveSignatureLevel`
* coerces AES/QES down to SES on a non-CSC instance, so it cannot be requested
* through the API here.
*/
const createRecipients = async (
request: APIRequestContext,
token: string,
envelopeId: string,
recipients: Array<{ signingOrder?: number }>,
) =>
await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: recipients.map((recipient, index) => ({
email: `tsp-grouping-${Date.now()}-${index}@documenso.com`,
name: `TSP Recipient ${index}`,
role: 'SIGNER',
...recipient,
})),
},
});
const seedEnvelopeAtSignatureLevel = async (request: APIRequestContext, signatureLevel: string) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: `[TEST] ${signatureLevel} grouping` });
await prisma.envelope.update({ where: { id: envelope.id }, data: { signatureLevel } });
return { envelopeId: envelope.id, token };
};
test('[TSP_GROUPING]: rejects two recipients sharing a signing order on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]);
expect(response.status()).toBe(400);
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(0);
});
test('[TSP_GROUPING]: rejects a second recipient joining an existing step on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const first = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]);
expect(first.ok(), await first.text()).toBeTruthy();
// The payload alone looks fine — only the resulting set reveals the group.
const second = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]);
expect(second.status()).toBe(400);
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(1);
});
test('[TSP_GROUPING]: numbers recipients created without a signing order on a QES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'QES');
const response = await createRecipients(request, token, envelopeId, [{}, { signingOrder: 3 }, {}]);
expect(response.ok(), await response.text()).toBeTruthy();
const recipients = await prisma.recipient.findMany({ where: { envelopeId }, orderBy: { id: 'asc' } });
expect(recipients.map((recipient) => recipient.signingOrder)).toEqual([4, 3, 5]);
const second = await createRecipients(request, token, envelopeId, [{}]);
expect(second.ok(), await second.text()).toBeTruthy();
const recipientsAfter = await prisma.recipient.findMany({ where: { envelopeId }, orderBy: { id: 'asc' } });
expect(recipientsAfter.map((recipient) => recipient.signingOrder)).toEqual([4, 3, 5, 6]);
});
test('[TSP_GROUPING]: accepts distinct signing orders on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 2 }]);
expect(response.ok(), await response.text()).toBeTruthy();
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(2);
});
test('[TSP_GROUPING]: still allows signing groups on an SES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'SES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]);
expect(response.ok(), await response.text()).toBeTruthy();
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients.map((recipient) => recipient.signingOrder)).toEqual([1, 1]);
});
@@ -0,0 +1,116 @@
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { signDirectSignaturePad } from '../fixtures/signature';
/**
* An assistant sharing a signing step with an unsigned peer cannot dictate
* the next signer: the flow does not advance until the whole step completes,
* so the server ignores any dictated identity. The signing page must
* therefore not OFFER dictation in that state — historically it did, because
* the assistant's recipient list excludes their own group peers, and the
* client derived dictation eligibility from that truncated list while the
* server decided from the full one.
*/
test('[NEXT_RECIPIENT_DICTATION]: assistant with an unsigned group peer is not offered dictation', async ({ page }) => {
const { user, team } = await seedUser();
const { user: assistant } = await seedUser();
const { user: peerSigner } = await seedUser();
const { user: lastSigner } = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [assistant, peerSigner, lastSigner],
recipientsCreateOptions: [
// The assistant shares step 1 with an unsigned peer; step 2 holds a
// single recipient — the exact shape where dictation looks available
// from the assistant's truncated recipient list.
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
{ signingOrder: 1, role: RecipientRole.SIGNER },
{ signingOrder: 2, role: RecipientRole.SIGNER },
],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
},
update: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
},
},
},
},
});
const assistantRecipient = recipients[0];
const lastRecipient = recipients[2];
const signUrl = `/sign/${assistantRecipient.token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByRole('radio', { name: assistantRecipient.name }).click();
// Fill in the assistant's own fields.
for (const field of assistantRecipient.fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.SIGNATURE) {
await signDirectSignaturePad(page);
await page.getByRole('button', { name: 'Sign', exact: true }).click();
}
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Continue' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// The unsigned peer blocks advancement, so dictation must not be offered.
await expect(dialog.getByText('The next recipient to sign this document will be')).not.toBeVisible();
await expect(dialog.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Later recipients' fields are still uninserted, so the confirm button
// reads "Proceed" rather than "Continue".
await dialog.getByRole('button', { name: /Continue|Proceed/ }).click();
await page.waitForURL(`${signUrl}/complete`);
// The assistant completed; nobody was renamed and the flow did not advance
// past the unsigned peer.
await expect
.poll(async () => {
const assistantAfter = await prisma.recipient.findUniqueOrThrow({
where: { id: assistantRecipient.id },
});
return assistantAfter.signingStatus;
})
.toBe(SigningStatus.SIGNED);
const lastAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: lastRecipient.id } });
expect(lastAfter.name).toBe(lastRecipient.name);
expect(lastAfter.email).toBe(lastRecipient.email);
const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: document.id } });
expect(envelope.status).toBe('PENDING');
});
@@ -0,0 +1,250 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
/**
* Assistant scoping must follow the same position model as signing (numbered
* first, then unordered by id). Historically `signingOrder ?? 0` treated an
* unordered assistant as FIRST, letting their token prefill every ordered
* recipient's fields.
*/
const seedAssistantDocument = async (options: {
assistantOrder: number | null;
signerOrder: number | null;
internalVersion?: number;
}) => {
const { user, team } = await seedUser();
const { user: assistantUser } = await seedUser();
const { user: signerUser } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [assistantUser, signerUser],
recipientsCreateOptions: [
{ signingOrder: options.assistantOrder, role: RecipientRole.ASSISTANT },
{ signingOrder: options.signerOrder, role: RecipientRole.SIGNER },
],
fields: [FieldType.TEXT],
updateDocumentOptions: {
internalVersion: options.internalVersion ?? 1,
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
// The seed returns recipients ordered by signingOrder (nulls last), so
// positional destructuring would swap roles — select by role instead.
const assistant = recipients.find((recipient) => recipient.role === RecipientRole.ASSISTANT);
const signer = recipients.find((recipient) => recipient.role === RecipientRole.SIGNER);
if (!assistant || !signer) {
throw new Error('Seeded recipients not found');
}
const signerTextField = signer.fields.find((field) => field.type === FieldType.TEXT);
if (!signerTextField) {
throw new Error('Seeded text field not found');
}
return { assistant, signer, signerTextField };
};
const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number }) => {
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({
json: {
token: input.token,
fieldId: input.fieldId,
fieldValue: {
type: FieldType.TEXT,
value: 'TEXT',
},
},
}),
});
};
test('[ASSISTANT_NULL_ORDER]: an ordered assistant can assist a null-order (tail-step) recipient', async () => {
const { assistant, signer, signerTextField } = await seedAssistantDocument({
assistantOrder: 1,
signerOrder: null,
});
// The tail-step recipient is strictly later, so they must be assistable.
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
expect(assistableRecipients.map((recipient) => recipient.id)).toContain(signer.id);
// Their non-signature fields must be visible to the assistant.
const fields = await getFieldsForToken({ token: assistant.token });
expect(fields.map((field) => field.id)).toContain(signerTextField.id);
// And prefillable.
await signFieldWithToken({
token: assistant.token,
fieldId: signerTextField.id,
value: 'TEXT',
});
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(true);
});
test('[ASSISTANT_NULL_ORDER]: a null-order assistant cannot assist an ordered recipient', async () => {
const { assistant, signerTextField } = await seedAssistantDocument({
assistantOrder: null,
signerOrder: 1,
});
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]);
const fields = await getFieldsForToken({ token: assistant.token });
expect(fields.map((field) => field.id)).not.toContain(signerTextField.id);
await expect(
signFieldWithToken({
token: assistant.token,
fieldId: signerTextField.id,
value: 'TEXT',
}),
).rejects.toThrow();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(false);
});
test('[ASSISTANT_NULL_ORDER]: a null-order assistant can assist a null-order recipient created after them', async () => {
const { assistant, signer, signerTextField } = await seedAssistantDocument({
assistantOrder: null,
signerOrder: null,
});
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id, signer.id]);
const fields = await getFieldsForToken({ token: assistant.token });
expect(fields.map((field) => field.id)).toContain(signerTextField.id);
await signFieldWithToken({
token: assistant.token,
fieldId: signerTextField.id,
value: 'TEXT',
});
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(true);
});
test('[ASSISTANT_NULL_ORDER]: a null-order recipient cannot be assisted by a null-order assistant created after them', async () => {
const { user, team } = await seedUser();
const { user: signerUser } = await seedUser();
const { user: assistantUser } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [signerUser, assistantUser],
recipientsCreateOptions: [
{ signingOrder: null, role: RecipientRole.SIGNER },
{ signingOrder: null, role: RecipientRole.ASSISTANT },
],
fields: [FieldType.TEXT],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
const assistant = recipients.find((recipient) => recipient.role === RecipientRole.ASSISTANT);
const signerTextField = recipients
.find((recipient) => recipient.role === RecipientRole.SIGNER)
?.fields.find((field) => field.type === FieldType.TEXT);
if (!assistant || !signerTextField) {
throw new Error('Seeded recipients not found');
}
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]);
await expect(
signFieldWithToken({
token: assistant.token,
fieldId: signerTextField.id,
value: 'TEXT',
}),
).rejects.toThrow();
});
test('[ASSISTANT_NULL_ORDER]: V2 route allows an ordered assistant to prefill a null-order recipient', async ({
page,
}) => {
const { assistant, signerTextField } = await seedAssistantDocument({
assistantOrder: 1,
signerOrder: null,
internalVersion: 2,
});
const response = await callSignEnvelopeField(page, {
token: assistant.token,
fieldId: signerTextField.id,
});
expect(response.ok()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(true);
});
test('[ASSISTANT_NULL_ORDER]: V2 route rejects a null-order assistant prefilling an ordered recipient', async ({
page,
}) => {
const { assistant, signerTextField } = await seedAssistantDocument({
assistantOrder: null,
signerOrder: 1,
internalVersion: 2,
});
const response = await callSignEnvelopeField(page, {
token: assistant.token,
fieldId: signerTextField.id,
});
expect(response.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(false);
});
@@ -0,0 +1,266 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
type SeededGroupEnvelope = {
assistantToken: string;
assistantOwnTextFieldId: number;
peerTextFieldId: number;
peerSignatureFieldId: number;
laterTextFieldId: number;
laterSignatureFieldId: number;
};
/**
* Seeds a pending SEQUENTIAL envelope where the ASSISTANT shares a signing
* step (duplicate signingOrder) with a SIGNER:
*
* - Step 1: ASSISTANT (own TEXT field) + "Peer Signer" (SIGNATURE + TEXT).
* - Step 2: "Later Signer" (SIGNATURE + TEXT).
*
* Product rule under signing groups: assistants only assist STRICTLY LATER
* steps — never their own group peers — and never insert SIGNATURE fields
* belonging to anyone else.
*/
const seedGroupedAssistantEnvelope = async (request: APIRequestContext): Promise<SeededGroupEnvelope> => {
const timestamp = Date.now();
const peerEmail = `peer-signer-${timestamp}@documenso.com`;
const laterEmail = `later-signer-${timestamp}@documenso.com`;
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
title: '[TEST] Grouped assistant envelope',
meta: {
signingOrder: 'SEQUENTIAL',
},
recipients: [
{
email: `assistant-${timestamp}@documenso.com`,
name: 'Assistant',
role: 'ASSISTANT',
signingOrder: 1,
},
{
email: peerEmail,
name: 'Peer Signer',
role: 'SIGNER',
signingOrder: 1,
},
{
email: laterEmail,
name: 'Later Signer',
role: 'SIGNER',
signingOrder: 2,
},
],
fieldsPerRecipient: [
[{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }],
[
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 25, width: 5, height: 5 },
],
[
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 35, width: 5, height: 5 },
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 45, width: 5, height: 5 },
],
],
});
const assistant = distributeResult.recipients.find((r) => r.role === 'ASSISTANT');
const peer = distributeResult.recipients.find((r) => r.email === peerEmail);
const later = distributeResult.recipients.find((r) => r.email === laterEmail);
if (!assistant || !peer || !later) {
throw new Error('Seeded recipients not found');
}
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
const findField = (recipientId: number, type: FieldType) => {
const field = fields.find((f) => f.recipientId === recipientId && f.type === type);
if (!field) {
throw new Error(`Field ${type} not found for recipient ${recipientId}`);
}
return field;
};
return {
assistantToken: assistant.token,
assistantOwnTextFieldId: findField(assistant.id, FieldType.TEXT).id,
peerTextFieldId: findField(peer.id, FieldType.TEXT).id,
peerSignatureFieldId: findField(peer.id, FieldType.SIGNATURE).id,
laterTextFieldId: findField(later.id, FieldType.TEXT).id,
laterSignatureFieldId: findField(later.id, FieldType.SIGNATURE).id,
};
};
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: input }),
});
};
test.describe('[ASSISTANT_SIGNING_GROUPS]: same-step (group peer) field access', () => {
test('field.signFieldWithToken (V1) rejects a group peer field', async ({ request }) => {
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: peerTextFieldId,
value: 'TEXT',
isBase64: false,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: peerTextFieldId },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe('');
});
test('field.removeSignedFieldWithToken (V1) rejects a group peer field', async ({ request }) => {
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
// Pre-insert the peer's field so a successful (incorrect) uninsert is detectable.
await prisma.field.update({
where: { id: peerTextFieldId },
data: { inserted: true, customText: 'pre-existing-value' },
});
const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', {
token: assistantToken,
fieldId: peerTextFieldId,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: peerTextFieldId },
});
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('pre-existing-value');
});
test('envelope.field.sign (V2) rejects a group peer field', async ({ request }) => {
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'envelope.field.sign', {
token: assistantToken,
fieldId: peerTextFieldId,
fieldValue: { type: FieldType.TEXT, value: 'TEXT' },
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: peerTextFieldId },
});
expect(fieldAfter.inserted).toBe(false);
});
test('getFieldsForToken excludes group peer fields but keeps own and later-step fields', async ({ request }) => {
const {
assistantToken,
assistantOwnTextFieldId,
peerTextFieldId,
peerSignatureFieldId,
laterTextFieldId,
laterSignatureFieldId,
} = await seedGroupedAssistantEnvelope(request);
const fields = await getFieldsForToken({ token: assistantToken });
const fieldIds = fields.map((field) => field.id);
// Own fields and strictly-later non-signature fields remain visible.
expect(fieldIds).toContain(assistantOwnTextFieldId);
expect(fieldIds).toContain(laterTextFieldId);
// Group peer fields are never visible to the assistant.
expect(fieldIds).not.toContain(peerTextFieldId);
expect(fieldIds).not.toContain(peerSignatureFieldId);
// Signature fields of other recipients are never visible to the assistant.
expect(fieldIds).not.toContain(laterSignatureFieldId);
});
});
test.describe('[ASSISTANT_SIGNING_GROUPS]: signature fields of other recipients', () => {
test('field.signFieldWithToken (V1) rejects inserting a later recipient signature field', async ({ request }) => {
const { assistantToken, laterSignatureFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: laterSignatureFieldId,
value: 'John Doe',
isBase64: false,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: laterSignatureFieldId },
include: { signature: true },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.signature).toBeNull();
});
});
test.describe('[ASSISTANT_SIGNING_GROUPS]: preserved assistant abilities', () => {
test('field.signFieldWithToken (V1) still allows filling the assistant own field', async ({ request }) => {
const { assistantToken, assistantOwnTextFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: assistantOwnTextFieldId,
value: 'MY OWN TEXT',
isBase64: false,
});
expect(res.ok(), await res.text()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: assistantOwnTextFieldId },
});
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('MY OWN TEXT');
});
test('field.signFieldWithToken (V1) still allows prefilling a later recipient text field', async ({ request }) => {
const { assistantToken, laterTextFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: laterTextFieldId,
value: 'PREFILLED FOR LATER SIGNER',
isBase64: false,
});
expect(res.ok(), await res.text()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: laterTextFieldId },
});
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('PREFILLED FOR LATER SIGNER');
});
});
@@ -0,0 +1,154 @@
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, SendStatus } from '@prisma/client';
/**
* Dictation lets a signer rewrite who signs next. It cannot be allowed to
* operate on a signing group, for two reasons the server enforces separately:
*
* 1. The next step must hold exactly one recipient (`nextGroup.length === 1`),
* otherwise there is no single "next signer" to rewrite.
* 2. A signer whose own step is still pending (a peer has not signed) does not
* advance the flow at all, so they never reach the dictation branch.
*
* Both are silent — passing `nextSigner` into a state that disallows dictation
* is ignored rather than rejected — which is exactly why they need asserting.
* The existing dictation specs all drive the UI and none use a grouped step.
*/
const DICTATED_SIGNER = {
name: 'Dictated Signer',
email: 'dictated-signer@example.com',
};
const expectRecipientUpdatedAuditLogCount = async (envelopeId: string, expected: number) => {
const auditLogs = await prisma.documentAuditLog.findMany({
where: {
envelopeId,
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
},
});
expect(auditLogs.length).toBe(expected);
};
const seedDictationDocument = async (signingOrders: number[]) => {
const { user, team } = await seedUser();
const signers = await Promise.all(signingOrders.map(async () => (await seedUser()).user));
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: signers,
recipientsCreateOptions: signingOrders.map((signingOrder) => ({
signingOrder,
// The seed defaults every recipient to SENT; later steps of a real
// SEQUENTIAL document are NOT_SENT until their step unlocks.
sendStatus: signingOrder === 1 ? SendStatus.SENT : SendStatus.NOT_SENT,
})),
// No fields, so completion is not blocked by unsigned required fields.
fields: [],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
},
update: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
},
},
},
},
});
return signers.map((signer) => {
const recipient = recipients.find((item) => item.email === signer.email);
if (!recipient) {
throw new Error(`Seeded recipient ${signer.email} not found`);
}
return recipient;
});
};
test('[NEXT_RECIPIENT_DICTATION]: dictation is ignored when the next step is a group', async () => {
// Steps: 1 = first, 2 = two grouped recipients.
const [first, groupA, groupB] = await seedDictationDocument([1, 2, 2]);
await completeDocumentWithToken({
token: first.token,
id: { type: 'envelopeId', id: first.envelopeId },
nextSigner: DICTATED_SIGNER,
});
const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } });
const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } });
// Neither member of the group may be rewritten.
expect(groupAAfter.email).toBe(groupA.email);
expect(groupAAfter.name).toBe(groupA.name);
expect(groupBAfter.email).toBe(groupB.email);
expect(groupBAfter.name).toBe(groupB.name);
// The dictated identity must not have leaked onto anyone.
const dictated = await prisma.recipient.findFirst({
where: { envelopeId: first.envelopeId, email: DICTATED_SIGNER.email },
});
expect(dictated).toBeNull();
// A rewrite that did not happen must not be recorded as having happened.
await expectRecipientUpdatedAuditLogCount(first.envelopeId, 0);
// The group is still activated as normal — only the rewrite is suppressed.
expect(groupAAfter.sendStatus).toBe(SendStatus.SENT);
expect(groupBAfter.sendStatus).toBe(SendStatus.SENT);
});
test('[NEXT_RECIPIENT_DICTATION]: a group member cannot dictate while a peer is still unsigned', async () => {
// Steps: 1 = two grouped recipients, 2 = last.
const [groupA, groupB, last] = await seedDictationDocument([1, 1, 2]);
// The first member of the group signs while their peer is still outstanding.
await completeDocumentWithToken({
token: groupA.token,
id: { type: 'envelopeId', id: groupA.envelopeId },
nextSigner: DICTATED_SIGNER,
});
const lastWhilePeerPending = await prisma.recipient.findUniqueOrThrow({
where: { id: last.id },
});
// The step never unlocked, so there was nothing to dictate.
expect(lastWhilePeerPending.email).toBe(last.email);
expect(lastWhilePeerPending.name).toBe(last.name);
expect(lastWhilePeerPending.sendStatus).toBe(SendStatus.NOT_SENT);
await expectRecipientUpdatedAuditLogCount(groupA.envelopeId, 0);
// The peer completing the group *does* advance to a single-recipient step,
// so dictation applies — the positive control for the assertions above.
await completeDocumentWithToken({
token: groupB.token,
id: { type: 'envelopeId', id: groupB.envelopeId },
nextSigner: DICTATED_SIGNER,
});
const lastAfterGroupComplete = await prisma.recipient.findUniqueOrThrow({
where: { id: last.id },
});
expect(lastAfterGroupComplete.email).toBe(DICTATED_SIGNER.email);
expect(lastAfterGroupComplete.name).toBe(DICTATED_SIGNER.name);
expect(lastAfterGroupComplete.sendStatus).toBe(SendStatus.SENT);
await expectRecipientUpdatedAuditLogCount(groupA.envelopeId, 1);
});

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