Compare commits

..
Author SHA1 Message Date
Lucas Smith ef31a16b99 Merge branch 'main' into feat/instance-2fa 2026-09-15 14:42:25 +10:00
Lucas Smith fe2641ff57 chore: tests 2026-09-14 14:16:23 +10:00
Lucas Smith df815e5b44 feat: add instance and org 2fa enrolment and enforcement 2026-09-08 14:12:25 +10:00
364 changed files with 9542 additions and 11018 deletions
-2
View File
@@ -80,8 +80,6 @@ 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.
+20
View File
@@ -30,6 +30,26 @@ jobs:
- name: Build app
run: npm run build
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: ./.github/actions/node-install
- name: Copy env
run: cp .env.example .env
# Includes the 2FA enforcement drift guard (packages/trpc), which is the
# only check that catches a session route without enforcement middleware.
- name: Run unit tests
run: npm run test -w @documenso/lib -w @documenso/trpc
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
@@ -15,8 +15,6 @@ 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. |
@@ -39,28 +37,7 @@ 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
+88 -193
View File
@@ -6,8 +6,6 @@ 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).
@@ -21,13 +19,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` | string | ID of the parent envelope |
| `envelopeId` | number | ID of the parent envelope |
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
| `page` | number | Page number (1-indexed) |
| `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 |
| `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) |
| `customText` | string | Value entered by the recipient |
| `inserted` | boolean | Whether the field has been completed |
| `fieldMeta` | object \| null | Type-specific configuration options |
@@ -40,19 +38,18 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
"secondaryId": "field_abc123",
"type": "SIGNATURE",
"recipientId": 123,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"envelopeId": 789,
"envelopeItemId": "envelope_item_xyz",
"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,
"overflow": "auto"
"required": true
}
}
```
@@ -64,7 +61,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| Type | Description | Auto-filled |
| ---------------- | ----------------------------------------- | ----------- |
| `SIGNATURE` | Drawn, typed, or uploaded signature | 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 |
| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
| `INITIALS` | Recipient's initials | No |
| `NAME` | Recipient's full name | Yes |
| `EMAIL` | Recipient's email address | Yes |
@@ -137,12 +134,10 @@ POST /envelope/field/create-many
### Request Body
| 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`.
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------- |
| `documentId`| number | Yes | The document ID |
| `fields` | array | Yes | Array of field configurations |
### Code Examples
@@ -153,32 +148,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
"documentId": 123,
"fields": [
{
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 80,
"pageNumber": 1,
"pageX": 10,
"pageY": 80,
"width": 30,
"height": 5
},
{
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": 50,
"positionY": 80,
"pageNumber": 1,
"pageX": 50,
"pageY": 80,
"width": 20,
"height": 3
},
{
"type": "TEXT",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 70,
"pageNumber": 1,
"pageX": 10,
"pageY": 70,
"width": 40,
"height": 4,
"fieldMeta": {
@@ -204,32 +199,32 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
documentId: 123,
fields: [
{
type: 'SIGNATURE',
recipientId: 456,
page: 1,
positionX: 10,
positionY: 80,
pageNumber: 1,
pageX: 10,
pageY: 80,
width: 30,
height: 5,
},
{
type: 'DATE',
recipientId: 456,
page: 1,
positionX: 50,
positionY: 80,
pageNumber: 1,
pageX: 50,
pageY: 80,
width: 20,
height: 3,
},
{
type: 'TEXT',
recipientId: 456,
page: 1,
positionX: 10,
positionY: 70,
pageNumber: 1,
pageX: 10,
pageY: 70,
width: 40,
height: 4,
fieldMeta: {
@@ -244,8 +239,8 @@ const response = await fetch(
}
);
const { data } = await response.json();
console.log(`Created ${data.length} fields`);
const { fields } = await response.json();
console.log(`Created ${fields.length} fields`);
````
</Tab>
@@ -255,68 +250,36 @@ console.log(`Created ${data.length} fields`);
```json
{
"data": [
"fields": [
{
"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",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
},
{
"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",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
},
{
"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",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "text",
"label": "Job Title",
"placeholder": "Enter your job title",
"required": true
}
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4
}
]
}
@@ -336,10 +299,8 @@ POST /envelope/field/update-many
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------- |
| `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.
| `documentId` | number | Yes | The document ID |
| `fields` | array | Yes | Array of field update objects |
### Code Examples
@@ -350,17 +311,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
"documentId": 123,
"fields": [
{
"id": 101,
"type": "SIGNATURE",
"positionY": 85
"pageY": 85
},
{
"id": 102,
"type": "DATE",
"positionY": 85
"pageY": 85
}
]
}'
@@ -377,16 +338,16 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{ id: 101, type: 'SIGNATURE', positionY: 85 },
{ id: 102, type: 'DATE', positionY: 85 },
documentId: 123,
fields: [
{ id: 101, type: 'SIGNATURE', pageY: 85 },
{ id: 102, type: 'DATE', pageY: 85 },
],
}),
}
);
const { data } = await response.json();
const { fields } = await response.json();
````
</Tab>
@@ -396,48 +357,9 @@ const { data } = await response.json();
```json
{
"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"
}
}
"fields": [
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
{ "id": 102, "type": "DATE", "positionY": 85 }
]
}
````
@@ -521,8 +443,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
(0,0) ─────────────────────────── (100,0)
│ │
│ ┌─────────┐ │
│ │ Field │ (positionX: 10, │
│ │ │ positionY: 20, │
│ │ Field │ (pageX: 10, │
│ │ │ pageY: 20, │
│ └─────────┘ width: 30, │
│ height: 5) │
│ │
@@ -535,9 +457,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
const field = {
type: 'SIGNATURE',
recipientId: 123,
page: 1,
positionX: 60, // 60% from left
positionY: 85, // 85% from top (near bottom)
pageNumber: 1,
pageX: 60, // 60% from left
pageY: 85, // 85% from top (near bottom)
width: 30, // 30% of page width
height: 8, // 8% of page height
};
@@ -557,33 +479,6 @@ 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
@@ -748,15 +643,15 @@ All field types support these base options:
Create a document with a signature block containing multiple field types:
```typescript
async function addSignatureBlock(envelopeId: string, recipientId: number) {
const data = [
async function addSignatureBlock(documentId: number, recipientId: number) {
const fields = [
// Signature
{
type: 'SIGNATURE',
recipientId,
page: 1,
positionX: 10,
positionY: 80,
pageNumber: 1,
pageX: 10,
pageY: 80,
width: 30,
height: 8,
fieldMeta: {
@@ -768,9 +663,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'NAME',
recipientId,
page: 1,
positionX: 10,
positionY: 90,
pageNumber: 1,
pageX: 10,
pageY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -782,9 +677,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'DATE',
recipientId,
page: 1,
positionX: 50,
positionY: 80,
pageNumber: 1,
pageX: 50,
pageY: 80,
width: 20,
height: 4,
fieldMeta: {
@@ -796,9 +691,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'TEXT',
recipientId,
page: 1,
positionX: 50,
positionY: 90,
pageNumber: 1,
pageX: 50,
pageY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -815,7 +710,7 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId, data }),
body: JSON.stringify({ documentId, fields }),
});
return response.json();
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
href="/docs/developers/api/templates"
/>
<Card
title="Team-scoped access"
description="Use team-scoped API tokens with envelope endpoints."
title="Teams"
description="Manage teams and team members."
href="/docs/developers/api/teams"
/>
</Cards>
@@ -6,8 +6,6 @@ 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).
@@ -18,7 +16,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
```json
{
"id": 123,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -136,7 +134,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": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"data": [
{
"email": "signer@example.com",
@@ -166,7 +164,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
envelopeId: 'clu1abc2def3ghi4jkl',
data: [
{
email: 'signer@example.com',
@@ -198,7 +196,7 @@ const { data: recipients } = await response.json();
"data": [
{
"id": 789,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -211,7 +209,7 @@ const { data: recipients } = await response.json();
},
{
"id": 790,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"email": "approver@example.com",
"name": "Jane Smith",
"role": "APPROVER",
@@ -264,7 +262,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": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"data": [
{
"id": 789,
@@ -286,7 +284,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
envelopeId: 'clu1abc2def3ghi4jkl',
data: [
{
id: 789,
@@ -389,7 +387,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
envelopeId: 'clu1abc2def3ghi4jkl',
data: [
{
email: 'approver@example.com',
@@ -464,7 +462,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
envelopeId: 'clu1abc2def3ghi4jkl',
data: [
{
email: 'signer@example.com',
+42 -29
View File
@@ -1,29 +1,44 @@
---
title: Team-Scoped API Access
description: Use team-scoped API tokens with document and template envelopes.
title: Teams API
description: Manage team resources, documents, and templates with team-scoped API tokens.
---
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 Context
## Team Object
<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>
A team object contains the following properties:
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.
| 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"
}
```
## Team-Scoped API Tokens
@@ -141,26 +156,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?type=DOCUMENT" \
curl -X GET "https://app.documenso.com/api/v2/envelope" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
},
});
const { data, count } = await response.json();
console.log(`Found ${count} team documents`);
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} team documents`);
````
</Tab>
@@ -175,11 +190,10 @@ 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/envelope/create" \
curl -X POST "https://app.documenso.com/api/v2/template/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "TEMPLATE",
"title": "NDA Template",
"recipients": [
{
@@ -209,7 +223,6 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
const form = new FormData();
const payload = {
type: 'TEMPLATE',
title: 'NDA Template',
recipients: [
{
@@ -236,7 +249,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
contentType: 'application/pdf',
});
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
method: 'POST',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -244,8 +257,8 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/create',
body: form,
});
const { id } = await response.json();
console.log('Created team template envelope:', id);
const template = await response.json();
console.log('Created team template:', template.id);
````
</Tab>
</Tabs>
@@ -255,14 +268,14 @@ console.log('Created team template envelope:', id);
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
const response = await fetch('https://app.documenso.com/api/v2/template', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -317,19 +330,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?type=DOCUMENT&status=PENDING', {
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?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?type=DOCUMENT&status=COMPLETED', {
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', {
headers: { Authorization: LEGAL_TEAM_TOKEN },
});
const legalDocs = await legalResponse.json();
console.log(`Sales team: ${salesDocs.count} pending`);
console.log(`Legal team: ${legalDocs.count} completed`);
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
```
## Error Responses
@@ -13,194 +13,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## 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
## Template Object
A template object contains the following properties:
@@ -278,7 +91,7 @@ A template object contains the following properties:
}
```
## List Templates (Deprecated)
## List Templates
Retrieve a paginated list of templates.
@@ -326,8 +139,8 @@ const response = await fetch(`${BASE_URL}/template`, {
},
});
const { data, count } = await response.json();
console.log(`Found ${count} templates`);
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} templates`);
// Filter by type
const privateResponse = await fetch(
@@ -368,16 +181,18 @@ const privateTemplates = await privateResponse.json();
]
}
],
"count": 25,
"currentPage": 1,
"perPage": 10,
"totalPages": 3
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 3,
"totalItems": 25
}
}
```
---
## Get Template (Deprecated)
## Get Template
Retrieve a single template by ID.
@@ -423,9 +238,9 @@ Returns the full template object including recipients, fields, and metadata.
---
## Create Document from Template (Deprecated)
## Create Document from Template
Create a new document using the deprecated template endpoint.
Create a new document using a template. This is the primary way to use templates programmatically.
<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.
@@ -600,57 +415,32 @@ const prefilledDocument = await prefillResponse.json();
### Response
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`.
Returns the created document object with recipients and signing URLs.
```json
{
"id": 789,
"envelopeId": "envelope_xyz789",
"id": "envelope_xyz789",
"type": "DOCUMENT",
"status": "PENDING",
"source": "TEMPLATE",
"title": "Employment Contract",
"source": "TEMPLATE",
"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",
"signingOrder": 1
"signingUrl": "https://app.documenso.com/sign/abc123"
}
]
}
```
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 (Deprecated)
## Override Template Settings
When creating a document from a template, you can override various settings:
@@ -698,7 +488,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Prefill Fields (Deprecated)
## Prefill Fields
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
@@ -787,7 +577,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Update Template (Deprecated)
## Update Template
Update a template's properties.
@@ -853,7 +643,7 @@ const template = await response.json();
---
## Duplicate Template (Deprecated)
## Duplicate Template
Create a copy of an existing template.
@@ -905,7 +695,7 @@ console.log('New template ID:', duplicatedTemplate.id);
---
## Delete Template (Deprecated)
## Delete Template
Delete a template.
@@ -964,7 +754,7 @@ const { success } = await response.json();
---
## Direct Link Templates (Deprecated)
## Direct Link Templates
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.
@@ -1108,7 +898,7 @@ const { success } = await response.json();
---
## Custom Document Data (Deprecated)
## Custom Document Data
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.
@@ -1123,7 +913,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Template Types (Legacy)
## Template Types
| Type | Description |
| --------- | ------------------------------------------------------------------ |
@@ -1132,7 +922,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Complete Legacy Example: Contract Workflow (Deprecated)
## Complete Example: Contract Workflow
This example demonstrates a complete workflow for using templates to send contracts.
@@ -1206,29 +996,16 @@ async function sendEmploymentContract(employeeData: {
subject: `Employment Contract for ${employeeData.name}`,
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
},
distributeDocument: false,
distributeDocument: true,
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 {
envelopeId: document.envelopeId,
signingUrl: distribution.recipients[0].signingUrl,
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
};
}
@@ -1241,7 +1018,7 @@ const result = await sendEmploymentContract({
startDate: '2025-03-01',
});
console.log('Document created:', result.envelopeId);
console.log('Document created:', result.documentId);
console.log('Signing URL:', result.signingUrl);
````
@@ -262,11 +262,8 @@ 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 and prefill data
</Step>
<Step>
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
links
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
<code>distributeDocument: true</code>
</Step>
</Steps>
@@ -300,7 +297,7 @@ type TemplateRecipient = {
async function sendEmploymentContract(
templateId: number,
employee: EmployeeData,
): Promise<{ documentId: number; signingUrl: string }> {
): Promise<{ documentId: string; signingUrl: string }> {
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
headers: { Authorization: API_TOKEN },
});
@@ -377,6 +374,7 @@ 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]}`,
}),
});
@@ -388,25 +386,9 @@ 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: distributeResult.recipients[0].signingUrl,
signingUrl: document.recipients[0].signingUrl,
};
}
@@ -471,17 +453,12 @@ 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\"
}")
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')"
echo "Document created:"
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
````
</Tab>
@@ -499,10 +476,8 @@ 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>
</Step>
<Step>
Distribute each returned envelope via <code>POST /envelope/distribute</code>
For each recipient, call <code>POST /template/use</code> with{' '}
<code>distributeDocument: true</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
@@ -565,6 +540,7 @@ async function bulkSendFromTemplate(
recipients: [
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
],
distributeDocument: true,
externalId: `bulk-${Date.now()}-${recipient.email}`,
}),
});
@@ -575,26 +551,10 @@ 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.envelopeId,
signingUrl: distributeResult.recipients[0].signingUrl,
envelopeId: document.id,
signingUrl: document.recipients[0].signingUrl,
};
}),
);
@@ -667,24 +627,14 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
\"email\": \"${EMAIL}\",
\"name\": \"${NAME}\"
}],
\"distributeDocument\": true,
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
}")
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
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
echo " Success: $(echo $RESPONSE | jq -r '.id')"
else
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
fi
# Rate limiting delay
@@ -887,15 +837,13 @@ 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 = 'original' | 'pending' | 'signed';
type DownloadVersion = 'signed' | 'original';
async function downloadDocument(
envelopeId: string,
@@ -949,7 +897,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
{ headers: { Authorization: API_TOKEN } },
);
const { data, count, currentPage, perPage, totalPages } = await response.json();
const { data, pagination } = await response.json();
for (const envelope of data) {
try {
@@ -963,8 +911,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
}
hasMore = currentPage < totalPages;
page = currentPage + 1;
hasMore = page < pagination.totalPages;
page++;
}
}
@@ -24,18 +24,20 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
{/* prettier-ignore */}
<Steps>
<Step>
### Select a team
### Open settings
Log in to Documenso and open the team that the integration should access. API tokens are scoped to 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)
</Step>
<Step>
### Open API Tokens
### Navigate to the API Tokens tab
Go to **Team Settings** → **API Tokens**, or open
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
Go to **Settings** and open the **API Tokens** tab.
![API tokens page](/public-api-images/api-tokens-page-documenso.webp)
@@ -46,7 +48,7 @@ Go to **Team Settings** → **API Tokens**, or open
- Click **Create Token**
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
- Click **Create Token**
</Step>
@@ -73,21 +75,21 @@ Include the token in the `Authorization` header of your HTTP requests.
### cURL
```bash
curl https://app.documenso.com/api/v2/envelope \
curl https://app.documenso.com/api/v2/document \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
```
### JavaScript / TypeScript
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
const response = await fetch('https://app.documenso.com/api/v2/document', {
method: 'GET',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
});
const envelopes = await response.json();
const documents = await response.json();
```
### Using the TypeScript SDK
@@ -127,7 +129,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
## Token Security
API tokens grant full API access to the team they were created for. Follow these practices to keep them secure:
API tokens grant full access to your account. 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.
@@ -153,11 +155,12 @@ const client = new Documenso({
## Token Scope
API tokens have full API access to the team they were created for, including:
API tokens have full access to your account, 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.
@@ -168,7 +171,7 @@ To revoke a token:
{/* prettier-ignore */}
<Steps>
<Step>
Go to **Team Settings** → **API Tokens**
Go to **Settings** > **API Tokens**
</Step>
<Step>
Find the token you want to revoke
@@ -191,7 +194,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 team.
Ensure you're accessing resources owned by the token's account.
</Accordion>
</Accordions>
@@ -78,10 +78,12 @@ A successful response returns a list of your documents (envelopes):
"createdAt": "2025-01-15T10:30:00.000Z"
}
],
"count": 1,
"currentPage": 1,
"perPage": 10,
"totalPages": 1
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 1,
"totalItems": 1
}
}
````
@@ -226,12 +228,9 @@ 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/distribute" \
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
-H "Authorization: YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123"
}'
-H "Content-Type: application/json"
````
</Tab>
@@ -239,14 +238,16 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
```javascript
const envelopeId = 'envelope_abc123';
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
const response = await fetch(
`https://app.documenso.com/api/v2/envelope/${envelopeId}/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);
@@ -336,14 +337,16 @@ 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/distribute`, {
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: envelope.id }),
});
const distributeResponse = await fetch(
`${BASE_URL}/envelope/${envelope.id}/distribute`,
{
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
}
);
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
@@ -419,12 +422,9 @@ echo "Created envelope: ${ENVELOPE_ID}"
# Step 2: Send the document for signing
echo "Sending document..."
curl -s -X POST "${BASE_URL}/envelope/distribute" \
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"envelopeId\": \"${ENVELOPE_ID}\"
}"
-H "Content-Type: application/json"
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 limit or plan quota. If `Retry-After` is present, retry the request. |
| `429` | Rate limited - wait 60 seconds and retry |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,9 +485,7 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
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.
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:
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -495,15 +493,8 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
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));
console.log('Rate limited, waiting 60 seconds...');
await new Promise((resolve) => setTimeout(resolve, 60000));
continue;
}
@@ -9,6 +9,7 @@
"background-jobs",
"signing-certificate",
"telemetry",
"two-factor-enforcement",
"organisation-limits",
"advanced"
]
@@ -0,0 +1,54 @@
---
title: Two-Factor Enforcement
description: Require two-factor authentication instance-wide or per organisation, with grace periods and important limitations.
---
import { Callout } from 'fumadocs-ui/components/callout';
## Overview
Documenso can require users to enable two-factor authentication (2FA) at two levels:
- **Instance-wide enforcement** — configured by an instance administrator under **Admin → Site Settings**. Requires a Documenso license that includes the feature. Once a user's grace period expires, they are redirected to a forced enrolment page before they can continue using the app.
- **Per-organisation enforcement** — available to everyone, configured by organisation admins in the organisation settings. Once a member's grace period expires, only access to that organisation (and its teams) is blocked; the rest of the app stays usable.
A user satisfies enforcement when they have 2FA enabled **and** their current session has passed a second factor (a TOTP/backup-code challenge, a user-verified passkey sign-in, or enabling 2FA during the session). Sessions created before the user enabled 2FA must sign out and back in to verify.
## Grace Periods
Both levels support a grace period of 0–365 days:
- **Instance**: the window starts at the later of the user's grace start (typically account creation, restarted by an admin 2FA reset) and the moment enforcement was enabled.
- **Organisation**: the window starts at the latest of joining the organisation, the moment the organisation enabled enforcement, and the user's grace start.
A grace period of **0 days** enforces immediately: for the instance policy, users are forced to enrol right after signing up or signing in; for the organisation policy, members are blocked from the organisation until they enrol.
**Joining is never blocked; access is.** Invitations and SSO sign-ins always succeed — the grace window starts at join. Members who never comply still occupy a seat and count towards member limits; organisation admins can see per-member 2FA compliance in the members list.
Reducing an active grace period requires an explicit acknowledgement in the settings UI, since it can immediately block users who have not yet enrolled.
Enabling enforcement requires the acting administrator to already satisfy the policy themselves (2FA enabled and verified on their current session). This prevents administrators from locking themselves out with a 0-day grace period.
## Known Limitation: API Tokens Are Exempt
<Callout type="warn">
Enforcement applies to interactive (session-based) access only. **API tokens minted before a
user's deadline keep working after it.** A blocked user cannot mint new tokens, but existing
tokens are not revoked by enforcement. If you need to cut off a non-compliant user's API access,
revoke their tokens explicitly.
</Callout>
## Licensing
Instance-wide enforcement is license-gated:
- Without the license, the instance-wide section in Admin → Site Settings is visible but disabled.
- If enforcement was configured while licensed and the license later lapses, the stored configuration becomes **inactive** (nothing is enforced) and the only permitted change is disabling it.
Per-organisation enforcement does not require a license. When instance-wide enforcement is active, it takes precedence over organisation policies.
---
## See Also
- [License](/docs/self-hosting/configuration/license) - Configuring your Documenso license
+128 -9
View File
@@ -1,23 +1,142 @@
/**
* Apply the public statistics API's wildcard CORS policy, without credentials.
* 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
*/
export default async function cors(req: Request, res: Response): Promise<Response> {
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise<StaticOrigin>;
interface CorsOptions {
origin?: StaticOrigin | OriginFn;
methods?: string | string[];
allowedHeaders?: string | string[];
exposedHeaders?: string | string[];
credentials?: boolean;
maxAge?: number;
preflightContinue?: boolean;
optionsSuccessStatus?: number;
}
const defaultOptions: CorsOptions = {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204,
};
function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
return Array.isArray(allowed)
? allowed.some((o) => isOriginAllowed(origin, o))
: typeof allowed === 'string'
? origin === allowed
: allowed instanceof RegExp
? allowed.test(origin)
: !!allowed;
}
function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
const headers = new Headers();
if (origin === '*') {
headers.set('Access-Control-Allow-Origin', '*');
} else if (typeof origin === 'string') {
headers.set('Access-Control-Allow-Origin', origin);
headers.append('Vary', 'Origin');
} else {
const allowed = isOriginAllowed(reqOrigin ?? '', origin);
if (allowed && reqOrigin) {
headers.set('Access-Control-Allow-Origin', reqOrigin);
}
headers.append('Vary', 'Origin');
}
return headers;
}
async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) {
const reqOrigin = req.headers.get('Origin') || undefined;
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
if (!value) {
return;
}
return getOriginHeaders(reqOrigin, value);
}
function getAllowedHeaders(req: Request, allowed?: string | string[]) {
const headers = new Headers();
if (!allowed) {
allowed = req.headers.get('Access-Control-Request-Headers')!;
headers.append('Vary', 'Access-Control-Request-Headers');
} else if (Array.isArray(allowed)) {
allowed = allowed.join(',');
}
if (allowed) {
headers.set('Access-Control-Allow-Headers', allowed);
}
return headers;
}
export default async function cors(req: Request, res: Response, options?: CorsOptions) {
const opts = { ...defaultOptions, ...options };
const { headers } = res;
headers.set('Access-Control-Allow-Origin', '*');
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);
}
};
// 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') {
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
headers.set('Vary', 'Access-Control-Request-Headers');
if (opts.methods) {
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
headers.set('Access-Control-Allow-Methods', methods);
}
if (allowedHeaders) {
headers.set('Access-Control-Allow-Headers', allowedHeaders);
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;
}
headers.set('Content-Length', '0');
return new Response(null, { status: 204, headers });
return new Response(null, { status: opts.optionsSuccessStatus, 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);
}
@@ -56,7 +56,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
() => msg`You are not authorized to reset two factor authentication for this user.`,
)
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
@@ -85,7 +85,8 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will disable two factor
authentication for the user.
authentication for the user. Their two-factor enforcement grace period will restart from the moment of the
reset.
</Trans>
</AlertDescription>
</div>
@@ -108,7 +109,8 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before proceeding.
This action is irreversible. Please ensure you have informed the user before proceeding. Any
two-factor enforcement grace period for this user will restart from the moment of the reset.
</Trans>
</AlertDescription>
</Alert>
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF or is password protected.`,
description: t`The file is not a valid PDF.`,
variant: 'destructive',
});
}
@@ -44,10 +44,7 @@ export const EnvelopesBulkDeleteDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
await trpcUtils.template.findTemplates.invalidate();
}
if (result.failedIds.length > 0) {
@@ -299,7 +299,7 @@ export const EnvelopesBulkDownloadDialog = ({
</Alert>
)}
<fieldset disabled={isDownloading} className="min-w-0 space-y-4">
<fieldset disabled={isDownloading} className="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,10 +96,7 @@ export const EnvelopesBulkMoveDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
await trpcUtils.template.findTemplates.invalidate();
}
await onSuccess?.(data.folderId);
@@ -8,7 +8,6 @@ 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';
@@ -24,7 +23,6 @@ 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';
@@ -40,64 +38,27 @@ import { useNavigate } from 'react-router';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
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(
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
),
})
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
message: msg`Document name is required`.id,
path: ['customDocumentName'],
});
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@@ -126,7 +87,6 @@ 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(
{
@@ -146,8 +106,6 @@ export function TemplateUseDialog({
return {
distributeDocument: false,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
@@ -182,39 +140,11 @@ 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 documentTitle = getDocumentTitle(data);
const customFilesToUpload = (data.customDocumentData ?? []).filter(
(item): item is typeof item & { data: File } => item.data !== undefined,
const customFilesToUpload = (data.customDocumentData || []).filter(
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
);
const customDocumentData = await Promise.all(
@@ -233,7 +163,6 @@ export function TemplateUseDialog({
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
...(documentTitle ? { override: { title: documentTitle } } : {}),
});
toast({
@@ -266,14 +195,9 @@ 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]);
@@ -314,9 +238,9 @@ export function TemplateUseDialog({
</DialogHeader>
<Form {...form}>
<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">
<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">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
@@ -477,17 +401,7 @@ export function TemplateUseDialog({
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
const customDocumentData = form.getValues('customDocumentData');
form.setValue(
'customDocumentData',
customDocumentData?.map((item) => ({
...item,
data: undefined,
})),
);
form.clearErrors('customDocumentData');
updateLastUploadedFile(undefined);
form.setValue('customDocumentData', undefined);
}
}}
/>
@@ -514,7 +428,7 @@ export function TemplateUseDialog({
)}
/>
{useCustomDocument && (
{form.watch('useCustomDocument') && (
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
@@ -529,7 +443,7 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
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"
className="flex items-center gap-4 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">
@@ -537,15 +451,13 @@ export function TemplateUseDialog({
</div>
</div>
<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>
<div className="min-w-0 flex-1">
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
{field.value ? (
<span>
<div>
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
</span>
</div>
) : (
<Trans>Default file</Trans>
)}
@@ -563,18 +475,6 @@ 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" />
@@ -617,7 +517,7 @@ export function TemplateUseDialog({
}
if (file.type !== 'application/pdf') {
form.setError(`customDocumentData.${i}.data`, {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
@@ -626,7 +526,7 @@ export function TemplateUseDialog({
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError(`customDocumentData.${i}.data`, {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
@@ -637,8 +537,6 @@ export function TemplateUseDialog({
}
field.onChange(file);
form.clearErrors(`customDocumentData.${i}.data`);
updateLastUploadedFile(file);
}}
/>
</div>
@@ -652,112 +550,6 @@ 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">
@@ -1,5 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -68,15 +69,23 @@ export const DisableAuthenticatorAppDialog = () => {
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
// Todo: (2FA enforcement, step 5) Once org enforcement state is available
// client-side, warn BEFORE disabling that org/team access will block at the
// org 2FA deadline. Until then the warning is shown after the fact based on
// the server response.
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
try {
await authClient.twoFactor.disable({ totpCode, backupCode });
const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode });
toast({
title: _(msg`Two-factor authentication disabled`),
description: _(
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
),
description: orgEnforcementApplies
? _(
msg`Two-factor authentication has been disabled for your account. One of your organisations requires two-factor authentication: access to it will be blocked at its deadline until you re-enable 2FA.`,
)
: _(
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
),
});
flushSync(() => {
@@ -84,7 +93,19 @@ export const DisableAuthenticatorAppDialog = () => {
});
await refreshSession();
} catch (_err) {
} catch (err) {
const error = AppError.parseError(err);
if (error.code === 'TWO_FACTOR_DISABLE_FORBIDDEN') {
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(msg`Two-factor authentication is required by this instance and cannot be disabled.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -69,11 +70,18 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
setSetup2FAData(data);
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
description:
error.code === 'TWO_FACTOR_ALREADY_ENABLED'
? _(
msg`Two-factor authentication is already enabled for your account. Disable it before setting it up again.`,
)
: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
}
@@ -0,0 +1,282 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZTwoFactorEnforcementFormSchema = z.object({
twoFactorRequired: z.boolean(),
twoFactorGracePeriodDays: z.coerce.number().int().min(0).max(365),
acknowledgeGracePeriodReduction: z.boolean(),
});
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
/**
* Organisation 2FA enforcement settings (require toggle + grace period).
*
* Rendered only for MANAGE_ORGANISATION_SECURITY holders (ADMIN). When
* instance-wide enforcement is active the fields are shown disabled — not
* hidden — with a banner explaining that the instance policy takes
* precedence, so a configured organisation policy stays visible instead of
* resurfacing already-expired later.
*/
export const OrganisationTwoFactorEnforcementForm = () => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { twoFactorEnforcement: instanceTwoFactorEnforcement } = useSession();
const isInstanceEnforcementActive = instanceTwoFactorEnforcement.required;
const { data: organisationWithSettings, isLoading } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const utils = trpc.useUtils();
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const settings = organisationWithSettings?.organisationGlobalSettings;
const form = useForm<TTwoFactorEnforcementFormSchema>({
values: {
twoFactorRequired: settings?.twoFactorRequired ?? false,
twoFactorGracePeriodDays: settings?.twoFactorGracePeriodDays ?? 7,
acknowledgeGracePeriodReduction: false,
},
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
});
const watchedValues = form.watch();
// Client-side mirror of the server's grace-reduction detection so we can
// surface the acknowledgement checkbox before submitting.
const isGraceReduction =
settings !== undefined &&
isTwoFactorGracePeriodReduction({
previous: settings.twoFactorRequired
? {
anchors: [settings.twoFactorEnforcedFrom],
gracePeriodDays: settings.twoFactorGracePeriodDays,
}
: null,
next: watchedValues.twoFactorRequired
? {
anchors: [settings.twoFactorRequired ? settings.twoFactorEnforcedFrom : new Date()],
gracePeriodDays: watchedValues.twoFactorGracePeriodDays,
}
: null,
now: new Date(),
});
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
try {
await updateOrganisationSettings({
organisationId: organisation.id,
acknowledgeGracePeriodReduction: data.acknowledgeGracePeriodReduction,
data: {
twoFactorRequired: data.twoFactorRequired,
twoFactorGracePeriodDays: data.twoFactorGracePeriodDays,
},
});
await utils.organisation.get.invalidate();
toast({
title: _(msg`Two-factor enforcement settings updated`),
});
form.setValue('acknowledgeGracePeriodReduction', false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
toast({
title: _(msg`Two-factor authentication required`),
description: _(
msg`You must have two-factor authentication enabled and verified on this session before requiring it for the organisation.`,
),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
variant: 'destructive',
});
}
};
if (isLoading || !settings) {
return (
<div className="flex justify-center rounded-lg border py-16">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isInstanceEnforcementActive}
className="flex flex-col gap-y-4"
>
{isInstanceEnforcementActive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Instance policy takes precedence</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor authentication is enforced instance-wide by your administrator, so the organisation policy
below is not editable while the instance policy is active.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="twoFactorRequired"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Members must enable two-factor authentication to access this organisation. Joining is never
blocked — the grace period starts when a member joins.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="twoFactorGracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a member has to enable two-factor authentication after joining. 0 blocks organisation
access immediately until they enrol.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{settings.twoFactorRequired && settings.twoFactorEnforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since {i18n.date(settings.twoFactorEnforcedFrom, { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<ul className="list-disc space-y-1 pl-4">
<li>
<Trans>
API tokens are exempt: tokens minted before a member's deadline keep working after it. Blocked
members cannot mint new tokens.
</Trans>
</li>
<li>
<Trans>
Members who have not yet complied still occupy a seat and count towards your member limit.
</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Members who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their organisation access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for members</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -0,0 +1,332 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { LoaderIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZTwoFactorEnforcementFormSchema = z.object({
enabled: z.boolean(),
gracePeriodDays: z.coerce.number().int().min(0).max(365),
acknowledgeGracePeriodReduction: z.boolean(),
});
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
/**
* Instance-wide 2FA enforcement settings for the admin site-settings page.
*
* License-gated states:
*
* - Licensed: full form.
* - Unlicensed + unconfigured: section visible but disabled with a "requires
* license" note.
* - Unlicensed + configured ("configured but inactive", e.g. license lapsed):
* stored values shown read-only with a disable-only affordance — the only
* permitted unlicensed update is turning the stored policy off.
*/
export const AdminTwoFactorEnforcementSection = () => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const { data: enforcementConfig, isLoading } = trpc.admin.getTwoFactorEnforcement.useQuery();
const utils = trpc.useUtils();
const { mutateAsync: updateTwoFactorEnforcement, isPending: isUpdatePending } =
trpc.admin.updateTwoFactorEnforcement.useMutation();
const form = useForm<TTwoFactorEnforcementFormSchema>({
values: {
enabled: enforcementConfig?.enabled ?? false,
gracePeriodDays: enforcementConfig?.gracePeriodDays ?? 7,
acknowledgeGracePeriodReduction: false,
},
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
});
const watchedValues = form.watch();
const isLicensed = enforcementConfig?.isLicensed ?? false;
const isConfiguredButInactive = !isLicensed && (enforcementConfig?.enabled ?? false);
// Client-side mirror of the server's grace-reduction detection so we can
// surface the acknowledgement checkbox before submitting. The server resets
// `enforcedFrom` to now on an off→on transition, hence the `new Date()`
// anchor when the stored policy is currently disabled.
const isGraceReduction =
enforcementConfig !== undefined &&
isTwoFactorGracePeriodReduction({
previous: enforcementConfig.enabled
? {
anchors: [enforcementConfig.enforcedFrom ? new Date(enforcementConfig.enforcedFrom) : null],
gracePeriodDays: enforcementConfig.gracePeriodDays,
}
: null,
next: watchedValues.enabled
? {
anchors: [
enforcementConfig.enabled && enforcementConfig.enforcedFrom
? new Date(enforcementConfig.enforcedFrom)
: new Date(),
],
gracePeriodDays: watchedValues.gracePeriodDays,
}
: null,
now: new Date(),
});
const onUpdate = async (data: {
enabled: boolean;
gracePeriodDays: number;
acknowledgeGracePeriodReduction?: boolean;
}) => {
try {
await updateTwoFactorEnforcement(data);
await utils.admin.getTwoFactorEnforcement.invalidate();
toast({
title: _(msg`Two-factor enforcement settings updated`),
});
form.setValue('acknowledgeGracePeriodReduction', false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
toast({
title: _(msg`Two-factor authentication required`),
description: _(
msg`Enable two-factor authentication on your own account and verify it on this session before requiring it for the instance.`,
),
variant: 'destructive',
});
return;
}
if (error.code === AppErrorCode.FORBIDDEN) {
toast({
title: _(msg`License required`),
description: _(
msg`Your license does not include instance-wide two-factor enforcement. Only disabling the stored configuration is permitted.`,
),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
variant: 'destructive',
});
}
};
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
await onUpdate(data);
};
// Disable-only affordance for the "configured but inactive" state: submits
// an enabled→disabled transition with the stored values unchanged, which is
// the only unlicensed update the server accepts.
const onDisableOnly = async () => {
if (!enforcementConfig) {
return;
}
await onUpdate({
enabled: false,
gracePeriodDays: enforcementConfig.gracePeriodDays,
});
};
return (
<div>
<h2 className="font-semibold">
<Trans>Instance Two-Factor Enforcement</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Require every user on this instance, including administrators, to enable two-factor authentication within a
grace period.
</Trans>
</p>
{isLoading || !enforcementConfig ? (
<div className="mt-4 flex justify-center rounded-lg border py-16">
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting || !isLicensed} className="mt-4 flex flex-col gap-y-4">
{!isLicensed && !isConfiguredButInactive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Requires a license</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Instance-wide two-factor enforcement requires a Documenso license that includes this feature.
</Trans>
</AlertDescription>
</Alert>
)}
{isConfiguredButInactive && (
<Alert variant="warning">
<AlertTitle>
<Trans>Configured but inactive</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor enforcement is configured but your current license does not include this feature, so it
is not being enforced. You can disable the stored configuration below; changing it requires a
license.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Users who have not enabled two-factor authentication by their deadline are redirected to a
forced enrolment page before they can continue.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="gracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a user has to enable two-factor authentication. 0 forces enrolment immediately
after signing up or signing in.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{enforcementConfig.isActive && enforcementConfig.enforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since{' '}
{i18n.date(new Date(enforcementConfig.enforcedFrom), { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<Trans>
API tokens are exempt: tokens minted before a user's deadline keep working after it. Blocked users
cannot mint new tokens.
</Trans>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Users who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for users</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
{isConfiguredButInactive && (
<div className="mt-4 flex justify-end">
<Button type="button" variant="destructive" loading={isUpdatePending} onClick={onDisableOnly}>
<Trans>Disable enforcement</Trans>
</Button>
</div>
)}
</form>
</Form>
)}
</div>
);
};
@@ -1,371 +0,0 @@
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';
@@ -1,203 +0,0 @@
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);
};
@@ -1,17 +0,0 @@
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>
);
};
@@ -1,45 +0,0 @@
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>
);
};
@@ -1,214 +0,0 @@
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';
};
@@ -1,67 +0,0 @@
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>
);
};
@@ -1,37 +0,0 @@
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>
);
};
@@ -1,264 +0,0 @@
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],
}));
@@ -1,63 +0,0 @@
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>
);
};
@@ -1,198 +0,0 @@
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 }));
};
@@ -1,145 +0,0 @@
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,10 +599,18 @@ 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,9 +1,6 @@
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';
@@ -25,7 +22,6 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const { organisations } = useSession();
const currentTeam = useOptionalCurrentTeam();
const currentOrganisation = useOptionalCurrentOrganisation();
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
{
@@ -41,19 +37,18 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
};
const menuNavigationLinks = useMemo(() => {
const navigationTeam =
currentTeam ??
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
let teamUrl = currentTeam?.url || null;
if (!navigationTeam) {
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
return [
{
href: '/inbox',
text: t`Inbox`,
},
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
@@ -61,8 +56,6 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
const teamUrl = navigationTeam.url;
return [
{
href: `/t/${teamUrl}/documents`,
@@ -76,15 +69,12 @@ 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, currentOrganisation, organisations, t]);
}, [currentTeam, organisations]);
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -0,0 +1,66 @@
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>
);
}
@@ -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 { useQueryStates } from 'nuqs';
import { useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { documentsSearchParams } from '~/utils/documents-search-params';
@@ -10,26 +10,16 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
const { _ } = useLingui();
const [{ query }, setSearchParams] = useQueryStates(
{
query: documentsSearchParams.query,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
// Reset pagination so a new search never lands on an empty page.
void setSearchParams({
query: debouncedSearchTerm || null,
page: null,
});
void setQuery(debouncedSearchTerm || null);
}
}, [debouncedSearchTerm, query, setSearchParams]);
}, [debouncedSearchTerm, query, setQuery]);
return (
<Input
@@ -16,7 +16,7 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-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 === '' || isHttpUrl(value), {
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
language: z
@@ -31,8 +31,6 @@ type FilterPillCommonProps = {
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
/** Whether the selection can be removed. Defaults to true. */
clearable?: boolean;
testId?: string;
};
@@ -63,7 +61,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, clearable = true, testId } = props;
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
const [open, setOpen] = useState(false);
@@ -86,7 +84,7 @@ export const FilterPill = (props: FilterPillProps) => {
return;
}
props.onChange(nextValue === props.value && clearable ? null : nextValue);
props.onChange(nextValue === props.value ? null : nextValue);
setOpen(false);
};
@@ -170,7 +168,7 @@ export const FilterPill = (props: FilterPillProps) => {
))}
</CommandGroup>
{hasSelection && clearable && (
{hasSelection && (
<>
<CommandSeparator />
<CommandGroup>
@@ -7,10 +7,9 @@ export type CardMetricProps = {
value?: string | number;
className?: string;
children?: React.ReactNode;
testId?: string;
};
export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => {
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
return (
<div
className={cn(
@@ -30,7 +29,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children, test
</div>
{children || (
<p className="mt-auto font-semibold text-4xl text-foreground leading-8" data-testid={testId}>
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
@@ -6,13 +6,9 @@ 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 {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
import { canExecuteTeamAction } 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';
@@ -66,13 +62,6 @@ 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
@@ -282,14 +271,6 @@ 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={
@@ -0,0 +1,37 @@
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,5 +1,4 @@
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 } = {
@@ -15,10 +14,9 @@ export type StackAvatarProps = {
zIndex?: string;
fallbackText?: string;
type: RecipientStatusType;
className?: string;
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
let classes = '';
let zIndexClass = '';
const firstClass = first ? '' : '-ml-3';
@@ -48,14 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className
}
return (
<Avatar
className={cn(
zIndexClass,
firstClass,
'h-10 w-10 border-2 border-white border-solid dark:border-border',
className,
)}
>
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
</Avatar>
);
@@ -1,33 +1,16 @@
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 { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
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 { 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 { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { useMemo } from 'react';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
@@ -44,238 +27,125 @@ export const StackAvatarsWithTooltip = ({
}: StackAvatarsWithTooltipProps) => {
const { _ } = useLingui();
const sections = useMemo(() => {
const groups = groupRecipientsByStatus(recipients);
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
);
return RECIPIENT_STATUS_SECTIONS.map((section) => ({
...section,
recipients: groups[section.type],
})).filter((section) => section.recipients.length > 0);
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;
}),
];
}, [recipients]);
const canCopySigningLink = documentStatus === DocumentStatus.PENDING;
return (
<PopoverHover
trigger={children || <RecipientAvatarStack recipients={recipients} />}
trigger={children || <StackAvatars recipients={sortedRecipients} />}
contentProps={{
className:
'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm',
className: 'flex flex-col gap-y-5 py-2',
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(),
}}
>
{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
{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}
key={recipient.id}
recipient={recipient}
signingToken={canCopySigningLink && section.hasSigningLink ? recipient.token : null}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
))}
</div>
<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>
))}
)}
{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,
},
];
@@ -0,0 +1,41 @@
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)}</>;
}
@@ -1,42 +0,0 @@
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"
/>
);
};
@@ -0,0 +1,144 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, XIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router';
type GraceBannerCandidate = {
/**
* Dismissal scope: `instance` or `org:<organisationId>`.
*/
scope: string;
deadline: Date;
isSessionUnverified: boolean;
};
const buildDismissalKey = (userId: number, candidate: GraceBannerCandidate) =>
`2fa-grace-banner:${userId}:${candidate.scope}:${candidate.deadline.getTime()}`;
const toCandidate = (
status: TTwoFactorEnforcementStatus,
scope: string,
isSessionUnverified: boolean,
): GraceBannerCandidate | null => {
// Banner territory is the grace window only: required, not yet satisfied,
// not yet expired. Expiry is handled by the org 403 screen (and, for
// instance enforcement, the onboarding redirect).
if (!status.required || status.isSatisfied || status.isDeadlineExpired) {
return null;
}
return {
scope,
deadline: status.deadline,
isSessionUnverified,
};
};
/**
* Shared grace-period banner for 2FA enforcement.
*
* Shows the NEAREST applicable deadline between instance enforcement and the
* current organisation's enforcement. Dismissal is stored in `sessionStorage`
* keyed by userId + scope + deadline, so a changed deadline re-shows the
* banner.
*/
export const TwoFactorGraceBanner = () => {
const { i18n } = useLingui();
const { sessionData } = useOptionalSession();
const currentOrganisation = useOptionalCurrentOrganisation();
const location = useLocation();
const [dismissedKeys, setDismissedKeys] = useState<string[]>([]);
const candidate = useMemo(() => {
if (!sessionData) {
return null;
}
const isSessionUnverified = sessionData.user.twoFactorEnabled && !sessionData.session.twoFactorVerified;
const candidates = [
toCandidate(sessionData.twoFactorEnforcement, 'instance', isSessionUnverified),
currentOrganisation
? toCandidate(currentOrganisation.twoFactorEnforcement, `org:${currentOrganisation.id}`, isSessionUnverified)
: null,
].filter((value): value is GraceBannerCandidate => value !== null);
if (candidates.length === 0) {
return null;
}
return candidates.reduce((nearest, current) =>
current.deadline.getTime() < nearest.deadline.getTime() ? current : nearest,
);
}, [sessionData, currentOrganisation]);
if (!sessionData || !candidate) {
return null;
}
const dismissalKey = buildDismissalKey(sessionData.user.id, candidate);
const isDismissed =
dismissedKeys.includes(dismissalKey) ||
(typeof window !== 'undefined' && window.sessionStorage.getItem(dismissalKey) === 'true');
if (isDismissed) {
return null;
}
const onDismiss = () => {
try {
window.sessionStorage.setItem(dismissalKey, 'true');
} catch {
// Storage may be unavailable (private browsing); fall back to state.
}
setDismissedKeys((keys) => [...keys, dismissalKey]);
};
const returnTo = encodeURIComponent(`${location.pathname}${location.search}`);
return (
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-between gap-x-4 px-4 py-2 font-medium text-sm text-yellow-900">
<div className="flex items-center gap-x-2">
<AlertTriangleIcon className="h-4 w-4 flex-shrink-0" />
<span>
{candidate.isSessionUnverified ? (
<Trans>
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.
Two-factor authentication is enabled for your account, but this session has not been verified with a
second factor — sign out and log back in to verify this session.
</Trans>
) : (
<Trans>
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.{' '}
<Link to={`/onboarding/2fa?returnTo=${returnTo}`} className="underline">
Enable it now
</Link>{' '}
to keep access.
</Trans>
)}
</span>
</div>
<button
type="button"
className="rounded p-1 hover:bg-yellow-300 dark:hover:bg-yellow-500"
aria-label="Dismiss"
onClick={onDismiss}
>
<XIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
@@ -0,0 +1,75 @@
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 { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useCurrentOrganisation } 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,26 +14,13 @@ import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
/**
* 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[];
stats: TFindDocumentsInternalResponse['stats'];
};
export const DocumentsTableStatusFilter = ({
stats,
statuses = SELECTABLE_STATUSES,
}: DocumentsTableStatusFilterProps) => {
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
const organisation = useOptionalCurrentOrganisation();
const organisation = useCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
@@ -45,14 +32,14 @@ export const DocumentsTableStatusFilter = ({
const selectableStatuses = useMemo(
() =>
statuses.filter((value) => {
if (organisation?.type === OrganisationType.PERSONAL) {
SELECTABLE_STATUSES.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
[organisation?.type, statuses],
[organisation.type],
);
const selectedStatus = useMemo(
@@ -78,22 +65,20 @@ export const DocumentsTableStatusFilter = ({
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: stats ? formatStatsCount(stats[value]) : undefined,
trailing: formatStatsCount(stats[value]),
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
{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>
)}
<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,17 +16,22 @@ 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 = () => {
@@ -35,24 +40,17 @@ export const InboxTable = () => {
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [findInboxSearchParams] = useQueryStates(inboxSearchParams, {
history: 'push',
});
const status = resolveInboxStatus(findInboxSearchParams.status);
const query = findInboxSearchParams.query ?? '';
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
page: Math.max(findInboxSearchParams.page ?? 1, 1),
perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100),
query: query || undefined,
status,
page: page || 1,
perPage: perPage || 10,
});
const hasSearchQuery = query.trim().length > 0;
const columns = useMemo(() => {
return [
{
@@ -125,20 +123,7 @@ export const InboxTable = () => {
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
{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>
))}
<Trans>Documents that require your attention will appear here</Trans>
</p>
</div>
}
@@ -6,6 +6,7 @@ import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/orga
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -100,6 +101,23 @@ export const OrganisationMembersDataTable = () => {
header: _(msg`Groups`),
cell: ({ row }) => row.original.groups.filter((group) => group.type === OrganisationGroupType.CUSTOM).length,
},
{
// Per-member 2FA compliance indicator: enrolment is the durable half
// of the satisfaction rule, so org admins can see who has and hasn't
// enrolled (non-compliant members still consume seats).
header: _(msg`2FA`),
accessorKey: 'twoFactorEnabled',
cell: ({ row }) =>
row.original.twoFactorEnabled ? (
<Badge variant="default">
<Trans>Enrolled</Trans>
</Badge>
) : (
<Badge variant="neutral">
<Trans>Not enrolled</Trans>
</Badge>
),
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
@@ -170,10 +170,7 @@ export const TemplatesTableActionDropdown = ({
onOpenChange={setRenameDialogOpen}
envelopeType="template"
onSuccess={async () => {
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
await trpcUtils.template.findTemplates.invalidate();
}}
/>
</DropdownMenu>
@@ -1,61 +0,0 @@
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"
/>
);
};
@@ -1,42 +0,0 @@
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"
/>
);
};
+14 -1
View File
@@ -3,8 +3,10 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { getBasePath } from '@documenso/lib/constants/app';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { createPublicEnv } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
import { TrpcProvider } from '@documenso/trpc/react';
import { getOrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session';
import { Toaster } from '@documenso/ui/primitives/toaster';
@@ -63,9 +65,19 @@ export async function loader({ context, request }: Route.LoaderArgs) {
const disableAnimations = cookieHeader.includes('__disable_animations=true');
let organisations = null;
let twoFactorEnforcement: TTwoFactorEnforcementStatus = { required: false };
if (session.isAuthenticated) {
organisations = await getOrganisationSession({ userId: session.user.id });
[organisations, twoFactorEnforcement] = await Promise.all([
getOrganisationSession({
userId: session.user.id,
user: session.user,
session: session.session,
}),
// Instance 2FA enforcement status is part of the session payload so
// layouts can derive banners/redirects client-side without new queries.
getTwoFactorEnforcementStatus({ user: session.user, session: session.session }),
]);
}
return data(
@@ -83,6 +95,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
user: session.user,
session: session.session,
organisations: organisations || [],
twoFactorEnforcement,
}
: null,
publicEnv: createPublicEnv(),
@@ -1,31 +1,44 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { calculateTwoFactorDeadlineTimerDelay } from '@documenso/lib/utils/two-factor';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Link, Outlet, redirect } from 'react-router';
import { useEffect } from 'react';
import { Link, Outlet, redirect, useLocation, useNavigate } from 'react-router';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { OrganisationBillingBanner } from '~/components/general/organisations/organisation-billing-banner';
import { OrganisationQuotaBanner } from '~/components/general/organisations/organisation-quota-banner';
import { TwoFactorGraceBanner } from '~/components/general/two-factor-grace-banner';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
/**
* Don't revalidate (run the loader on sequential navigations)
*
* Update values via providers.
* Builds the enrolment redirect for an instance-blocked user, carrying the
* current path so they land back where they were after enrolling.
*/
export const shouldRevalidate = () => false;
const buildTwoFactorOnboardingPath = (currentPath: string) => {
const returnTo = (isValidReturnTo(currentPath) && normalizeReturnTo(currentPath)) || '/';
return `/onboarding/2fa?returnTo=${encodeURIComponent(returnTo)}`;
};
// Note: no `shouldRevalidate` suppression on this layout (the root layout
// keeps its own) — the loader must rerun on navigations so the instance
// enforcement redirect below is re-evaluated server-side.
export async function loader({ request }: Route.LoaderArgs) {
const [session, banner] = await Promise.all([
@@ -37,6 +50,22 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/signin');
}
// Instance-wide 2FA enforcement (UX chokepoint — the security boundary is
// the tRPC/Hono asserts): a blocked user is redirected into forced
// enrolment. `/onboarding/2fa` lives outside this layout, so the redirect
// cannot loop. Never blocks login itself — signin/onboarding are outside
// this layout too.
const twoFactorEnforcement = await getTwoFactorEnforcementStatus({
user: session.user,
session: session.session,
});
if (twoFactorEnforcement.required && twoFactorEnforcement.isBlocked) {
const url = new URL(request.url);
throw redirect(buildTwoFactorOnboardingPath(`${url.pathname}${url.search}`));
}
return {
banner,
};
@@ -45,7 +74,51 @@ export async function loader({ request }: Route.LoaderArgs) {
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
const { banner } = loaderData;
const { user, organisations } = useSession();
const { user, session, organisations, twoFactorEnforcement } = useSession();
const location = useLocation();
const navigate = useNavigate();
// Client-side counterpart of the loader's instance enforcement redirect:
// parent-layout loaders don't rerun on every child navigation, and a grace
// deadline can pass while the app is open. The session provider refreshes
// the enforcement status on navigation/focus; the timer covers a deadline
// crossing while the tab sits idle.
const isInstanceTwoFactorBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked;
useEffect(() => {
if (!twoFactorEnforcement.required || twoFactorEnforcement.isSatisfied) {
return;
}
const redirectToOnboarding = () => {
void navigate(buildTwoFactorOnboardingPath(`${location.pathname}${location.search}`));
};
if (twoFactorEnforcement.isBlocked) {
redirectToOnboarding();
return;
}
// Within grace: fire at the deadline instant. A `null` delay means the
// deadline is beyond `setTimeout` range (~24.8 days) — no timer needed,
// the status is re-evaluated long before then.
const timerDelay = calculateTwoFactorDeadlineTimerDelay({
deadline: twoFactorEnforcement.deadline,
now: new Date(),
});
if (timerDelay === null) {
return;
}
const timeout = window.setTimeout(redirectToOnboarding, timerDelay);
return () => {
window.clearTimeout(timeout);
};
}, [twoFactorEnforcement, location.pathname, location.search, navigate]);
const { layoutMode } = useChildRouteFlags();
@@ -80,6 +153,65 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
match?.id === 'routes/_authenticated+/t.$teamUrl+/templates.$id.edit',
);
// Per-organisation 2FA enforcement: when the current org/team context's
// organisation blocks the user, render a 403 screen (NOT a redirect — the
// rest of the app stays usable) linking to the enrolment page. Derived
// client-side from the session provider's bootstrap payload, which stays
// readable while blocked. This is UX only — the security boundary is the
// tRPC/Hono asserts.
const isCurrentOrganisationTwoFactorBlocked =
Boolean(orgUrl || teamUrl) &&
Boolean(currentOrganisation?.twoFactorEnforcement.required && currentOrganisation.twoFactorEnforcement.isBlocked);
// State (b): enrolled, but this session never passed a second factor —
// enrolment would rightly refuse, so the remediation is a fresh sign-in.
const requiresRelogin = user.twoFactorEnabled && !session.twoFactorVerified;
// Instance enforcement takes precedence over the org 403 below: render
// nothing while the effect above navigates to forced enrolment.
if (isInstanceTwoFactorBlocked) {
return null;
}
if (isCurrentOrganisationTwoFactorBlocked) {
const returnTo = encodeURIComponent(`${location.pathname}${location.search}`);
return (
<GenericErrorLayout
errorCode={403}
errorCodeMap={{
403: {
heading: msg`Two-factor authentication required`,
subHeading: msg`403 Forbidden`,
message: requiresRelogin
? msg`This organisation requires two-factor authentication. Two-factor authentication is enabled for your account, but this session has not been verified with a second factor. Sign out and log back in to verify this session.`
: msg`This organisation requires two-factor authentication. Enable it for your account to regain access. The rest of your account remains available.`,
},
}}
primaryButton={
requiresRelogin ? (
<Button onClick={() => void authClient.signOut()}>
<Trans>Sign out</Trans>
</Button>
) : (
<Button asChild>
<Link to={`/onboarding/2fa?returnTo=${returnTo}`}>
<Trans>Set up two-factor authentication</Trans>
</Link>
</Button>
)
}
secondaryButton={
<Button variant="ghost" asChild>
<Link to="/">
<Trans>Go home</Trans>
</Link>
</Button>
}
/>
);
}
if (orgNotFound || teamNotFound) {
return (
<GenericErrorLayout
@@ -112,6 +244,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={currentTeam || null}>
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
<TwoFactorGraceBanner />
<OrganisationBillingBanner />
<OrganisationQuotaBanner />
@@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react';
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
import { AdminTwoFactorEnforcementSection } from '~/components/general/admin-two-factor-enforcement-section';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/site-settings';
@@ -31,6 +32,8 @@ export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentPro
<AdminSiteBannerSection banner={banner} />
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
<AdminTwoFactorEnforcementSection />
</div>
</div>
);
@@ -1,3 +1,4 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
@@ -75,6 +76,11 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { user: currentUser } = useSession();
// Self-reset is forbidden server-side; hide the affordance entirely.
const canResetTwoFactor = user.twoFactorEnabled && user.id !== currentUser.id;
const roles = user.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
@@ -228,7 +234,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
</Accordion>
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{canResetTwoFactor && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
@@ -2,11 +2,8 @@ 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() {
@@ -29,14 +26,6 @@ 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,11 +1,7 @@
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 {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } 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';
@@ -21,7 +17,6 @@ import {
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRight,
BarChart3Icon,
CalendarIcon,
MoreVerticalIcon,
PlusIcon,
@@ -119,22 +114,11 @@ export default function OrganisationSettingsTeamsPage() {
</p>
</div>
<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>
<Button asChild>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -1,179 +0,0 @@
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;
@@ -7,6 +7,7 @@ import { Trans } from '@lingui/react/macro';
import { OrganisationDeleteDialog } from '~/components/dialogs/organisation-delete-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { OrganisationTwoFactorEnforcementForm } from '~/components/forms/organisation-two-factor-enforcement-form';
import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
@@ -29,6 +30,19 @@ export default function OrganisationSettingsGeneral() {
<OrganisationUpdateForm />
</div>
{canExecuteOrganisationAction('MANAGE_ORGANISATION_SECURITY', organisation.currentOrganisationRole) && (
<>
<hr className="my-6" />
<SettingsHeader
title={_(msg`Two-factor authentication`)}
subtitle={_(msg`Require members of this organisation to use two-factor authentication.`)}
/>
<OrganisationTwoFactorEnforcementForm />
</>
)}
{canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -1,183 +0,0 @@
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();
};
@@ -5,28 +5,26 @@ 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, XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { Bird } from 'lucide-react';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router';
import { useParams, useSearchParams } 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';
import { templatesSearchParams } from '~/utils/templates-search-params';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
export function meta() {
return appMetaTags(msg`Templates`);
@@ -41,26 +39,15 @@ export default function TemplatesPage() {
const organisation = useCurrentOrganisation();
const { folderId } = useParams();
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 [searchParams] = useSearchParams();
const isOrgView = findTemplateSearchParams.view === 'organisation';
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const hasActiveFilters = Boolean(ownerIds?.length);
const isSearchingOrFiltering = hasActiveFilters || Boolean(query);
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
const onResetFilters = () => {
void setFindTemplateSearchParams({
ownerIds: null,
page: null,
});
};
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
@@ -77,13 +64,11 @@ export default function TemplatesPage() {
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
const teamTemplatesQuery = trpc.template.findTemplatesInternal.useQuery(
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
{
page,
perPage,
folderId,
query,
ownerIds,
},
{
enabled: !isOrgView,
@@ -94,7 +79,6 @@ export default function TemplatesPage() {
{
page,
perPage,
query,
},
{
enabled: isOrgView,
@@ -103,6 +87,14 @@ 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">
@@ -120,25 +112,31 @@ export default function TemplatesPage() {
</h1>
</div>
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<TemplateSearch />
{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>
{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 && !isSearchingOrFiltering ? (
{activeQuery.data && activeQuery.data.count === 0 ? (
<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} />
@@ -0,0 +1,244 @@
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigate } from 'react-router';
import { z } from 'zod';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/2fa-challenge';
export function meta() {
return appMetaTags(msg`Two-Factor Authentication`);
}
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
// A signed-in user has no pending challenge to complete (any successful
// sign-in clears it server-side).
if (isAuthenticated) {
throw redirect('/');
}
return null;
}
const ZTwoFactorChallengeFormSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
type TTwoFactorChallengeFormSchema = z.infer<typeof ZTwoFactorChallengeFormSchema>;
export default function TwoFactorChallenge() {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [isValidatingChallenge, setIsValidatingChallenge] = useState(true);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<'totp' | 'backup'>('totp');
const form = useForm<TTwoFactorChallengeFormSchema>({
values: {
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZTwoFactorChallengeFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onRedirectToSignIn = async () => {
toast({
title: _(msg`Sign in required`),
description: _(msg`Your sign-in attempt has expired. Please sign in again.`),
variant: 'destructive',
});
await navigate('/signin');
};
useEffect(() => {
void authClient.twoFactor
.getChallenge()
.then(async ({ valid }) => {
if (!valid) {
await onRedirectToSignIn();
return;
}
setIsValidatingChallenge(false);
})
.catch(async () => onRedirectToSignIn());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onToggleTwoFactorAuthenticationMethodClick = () => {
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
if (method === 'totp') {
form.setValue('backupCode', '');
}
if (method === 'backup') {
form.setValue('totpCode', '');
}
setTwoFactorAuthenticationMethod(method);
};
const onFormSubmit = async ({ totpCode, backupCode }: TTwoFactorChallengeFormSchema) => {
try {
// On success this navigates to the server-provided redirect path.
await authClient.twoFactor.verifyChallenge(
twoFactorAuthenticationMethod === 'totp' ? { totpCode } : { backupCode },
);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AuthenticationErrorCode.TwoFactorChallengeExpired) {
await onRedirectToSignIn();
return;
}
if (error.code === AuthenticationErrorCode.InvalidTwoFactorCode) {
toast({
title: _(msg`Unable to sign in`),
description: _(msg`The two-factor authentication code provided is incorrect.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your code. Please try again.`),
variant: 'destructive',
});
}
};
if (isValidatingChallenge) {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Checking your sign-in...</Trans>
</p>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<h1 className="font-semibold text-2xl">
<Trans>Two-Factor Authentication</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
{twoFactorAuthenticationMethod === 'totp' ? (
<Trans>Enter the code from your authenticator app to finish signing in.</Trans>
) : (
<Trans>Enter one of your backup codes to finish signing in.</Trans>
)}
</p>
<hr className="-mx-6 my-4" />
<Form {...form}>
<form className="flex w-full flex-col gap-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
{twoFactorAuthenticationMethod === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{twoFactorAuthenticationMethod === 'backup' && (
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
<Button type="button" variant="secondary" onClick={onToggleTwoFactorAuthenticationMethodClick}>
{twoFactorAuthenticationMethod === 'totp' ? (
<Trans>Use Backup Code</Trans>
) : (
<Trans>Use Authenticator</Trans>
)}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</div>
</fieldset>
</form>
</Form>
<p className="mt-6 text-center text-muted-foreground text-sm">
<Trans>
Not you?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Back to sign in
</Link>
</Trans>
</p>
</div>
</div>
);
}
@@ -32,6 +32,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
organisation: {
select: {
name: true,
organisationGlobalSettings: {
select: {
twoFactorRequired: true,
twoFactorGracePeriodDays: true,
},
},
},
},
},
@@ -71,6 +77,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
},
});
// Non-blocking notice data: joining always succeeds, but the member's 2FA
// grace window starts at join when the organisation requires 2FA.
const twoFactorSettings = organisationMemberInvite.organisation.organisationGlobalSettings;
return {
state: 'Pending',
token: organisationMemberInvite.token,
@@ -78,6 +88,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
organisationName,
userExists: user !== null,
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
organisationTwoFactorRequired: twoFactorSettings.twoFactorRequired,
organisationTwoFactorGracePeriodDays: twoFactorSettings.twoFactorGracePeriodDays,
} as const;
}
@@ -141,6 +153,8 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
organisationName={data.organisationName}
userExists={data.userExists}
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
organisationTwoFactorRequired={data.organisationTwoFactorRequired}
organisationTwoFactorGracePeriodDays={data.organisationTwoFactorGracePeriodDays}
/>
);
}
@@ -151,6 +165,8 @@ type PendingInvitationProps = {
organisationName: string;
userExists: boolean;
isSessionUserTheInvitedUser: boolean;
organisationTwoFactorRequired: boolean;
organisationTwoFactorGracePeriodDays: number;
};
type InvitationResult = 'idle' | 'accepted' | 'declined';
@@ -163,6 +179,8 @@ const PendingInvitation = ({
organisationName,
userExists,
isSessionUserTheInvitedUser,
organisationTwoFactorRequired,
organisationTwoFactorGracePeriodDays,
}: PendingInvitationProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -289,6 +307,24 @@ const PendingInvitation = ({
</Trans>
</p>
{/* Non-blocking notice: accepting always succeeds; access to the
organisation blocks only after the grace period expires. */}
{organisationTwoFactorRequired && !actionIsDecline && (
<p className="mt-2 mb-4 text-muted-foreground text-sm">
{organisationTwoFactorGracePeriodDays > 0 ? (
<Trans>
This organisation requires two-factor authentication. You will need to enable it within{' '}
{organisationTwoFactorGracePeriodDays} days of joining to keep access to the organisation.
</Trans>
) : (
<Trans>
This organisation requires two-factor authentication. You will need to enable it immediately after
joining to access the organisation.
</Trans>
)}
</p>
)}
{acceptFailureReason && (
<p className="mt-2 mb-4 text-destructive text-sm">
{match(acceptFailureReason)
@@ -86,6 +86,12 @@ export async function loader({ params }: Route.LoaderArgs) {
name: true,
url: true,
avatarImageId: true,
organisationGlobalSettings: {
select: {
twoFactorRequired: true,
twoFactorGracePeriodDays: true,
},
},
},
});
@@ -107,6 +113,10 @@ export async function loader({ params }: Route.LoaderArgs) {
name: organisation.name,
url: organisation.url,
avatar: organisation.avatarImageId,
// Non-blocking notice data: SSO membership creation always succeeds;
// the 2FA grace window starts at join.
twoFactorRequired: organisation.organisationGlobalSettings.twoFactorRequired,
twoFactorGracePeriodDays: organisation.organisationGlobalSettings.twoFactorGracePeriodDays,
},
} as const;
}
@@ -266,6 +276,26 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
</div>
</div>
{/* Non-blocking notice: confirming always succeeds; organisation
access blocks only after the grace period expires. */}
{organisation.twoFactorRequired && (
<Alert variant="neutral">
<AlertDescription>
{organisation.twoFactorGracePeriodDays > 0 ? (
<Trans>
This organisation requires two-factor authentication. You will need to enable it within{' '}
{organisation.twoFactorGracePeriodDays} days of joining to keep access to the organisation.
</Trans>
) : (
<Trans>
This organisation requires two-factor authentication. You will need to enable it immediately after
joining to access the organisation.
</Trans>
)}
</AlertDescription>
</Alert>
)}
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`accept-conditions`}
@@ -137,6 +137,9 @@ export default function AuthoringLayout() {
teams: [team],
subscription: null,
currentOrganisationRole: OrganisationMemberRole.MEMBER,
// Hardcoded non-enforcing status: embed authoring is presign-token
// authorized (machine access), which is exempt from 2FA enforcement.
twoFactorEnforcement: { required: false },
};
return (
+385
View File
@@ -0,0 +1,385 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { AppError } from '@documenso/lib/errors/app-error';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { isTwoFactorSatisfied } from '@documenso/lib/utils/two-factor';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigate, useRevalidator } from 'react-router';
import { renderSVG } from 'uqr';
import { z } from 'zod';
import { RecoveryCodeList } from '~/components/forms/2fa/recovery-code-list';
import { appMetaTags } from '~/utils/meta';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/2fa';
export function meta() {
return appMetaTags(msg`Two-Factor Authentication`);
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
const url = new URL(request.url);
const rawReturnTo = url.searchParams.get('returnTo') ?? undefined;
const returnTo = (isValidReturnTo(rawReturnTo) && normalizeReturnTo(rawReturnTo)) || '/';
if (!session.isAuthenticated) {
throw redirect(`/signin?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);
}
// Auto-redirect ONLY when enforcement is already satisfied on arrival.
// Every other state (including "not required") renders the page — a user
// landing here after a backup-code recovery gets an explicit skip link
// instead of being bounced away.
if (
isTwoFactorSatisfied({
userTwoFactorEnabled: session.user.twoFactorEnabled,
sessionTwoFactorVerified: session.session.twoFactorVerified,
})
) {
throw redirect(returnTo);
}
const twoFactorEnforcement = await getTwoFactorEnforcementStatus({
user: session.user,
session: session.session,
});
return superLoaderJson({
returnTo,
isTwoFactorEnabled: session.user.twoFactorEnabled,
isSessionTwoFactorVerified: session.session.twoFactorVerified,
twoFactorEnforcement,
});
}
const ZEnableTwoFactorFormSchema = z.object({
token: z.string().min(6).max(6),
});
type TEnableTwoFactorFormSchema = z.infer<typeof ZEnableTwoFactorFormSchema>;
export default function OnboardingTwoFactorPage() {
const { returnTo, isTwoFactorEnabled, isSessionTwoFactorVerified, twoFactorEnforcement } =
useSuperLoaderData<typeof loader>();
const { _, i18n } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const { revalidate } = useRevalidator();
// The whole page renders from the loader snapshot + local state, never from
// the live session context. The session provider refreshes in the
// background (and `twoFactorEnabled` flips the moment 2FA is enabled), but
// navigation is controlled exclusively by this page's state machine —
// recovery codes are shown exactly once and must stay on screen until the
// user explicitly acknowledges saving them.
const [setupData, setSetupData] = useState<{ uri: string; secret: string } | null>(null);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [hasSetupFailed, setHasSetupFailed] = useState(false);
const hasRequestedSetupRef = useRef(false);
const isBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked;
const canSkip = !isBlocked;
// State (b): enrolled, but this session was created before 2FA was enabled
// so it never passed a second factor. Setup would rightly refuse
// (already enabled), so the only remediation is a fresh sign-in.
const requiresRelogin = isTwoFactorEnabled && !isSessionTwoFactorVerified;
const form = useForm<TEnableTwoFactorFormSchema>({
defaultValues: {
token: '',
},
resolver: zodResolver(ZEnableTwoFactorFormSchema),
});
const { isSubmitting: isEnabling } = form.formState;
// Enrolment goes through the auth routes (`authClient.twoFactor.*`), NOT
// tRPC: while the user is blocked by instance enforcement, session tRPC
// procedures respond 403 — the remediation page must not depend on them.
const setupTwoFactor = async () => {
setHasSetupFailed(false);
try {
const data = await authClient.twoFactor.setup();
setSetupData(data);
} catch (err) {
const error = AppError.parseError(err);
// The user enrolled concurrently (e.g. in another tab). Re-run the
// loader instead of dead-ending on a retry that would refuse forever:
// it auto-redirects when this session became verified by the
// concurrent enable, or renders the sign-out-and-re-login prompt.
if (error.code === 'TWO_FACTOR_ALREADY_ENABLED') {
await revalidate();
return;
}
setHasSetupFailed(true);
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(msg`We were unable to setup two-factor authentication for your account. Please try again.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (isTwoFactorEnabled || hasRequestedSetupRef.current) {
return;
}
hasRequestedSetupRef.current = true;
void setupTwoFactor();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onEnableSubmit = async ({ token }: TEnableTwoFactorFormSchema) => {
try {
const data = await authClient.twoFactor.enable({ code: token });
// Phase one complete. Do NOT navigate — show the recovery codes and
// wait for the explicit acknowledgement below.
setRecoveryCodes(data.recoveryCodes);
} catch (err) {
const error = AppError.parseError(err);
// Enabled concurrently (e.g. another tab) between setup and enable —
// the recovery codes were shown there. Re-run the loader to land on
// the correct state instead of claiming the code was wrong.
if (error.code === 'TWO_FACTOR_ALREADY_ENABLED') {
toast({
title: _(msg`Two-factor authentication is already enabled`),
description: _(msg`Two-factor authentication was already enabled for your account.`),
});
await revalidate();
return;
}
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
}
};
const onDownloadRecoveryCodes = () => {
if (!recoveryCodes) {
return;
}
const blob = new Blob([recoveryCodes.join('\n')], {
type: 'text/plain',
});
downloadFile({
filename: 'documenso-2FA-recovery-codes.txt',
data: blob,
});
};
// Phase two: only the explicit acknowledgement navigates away.
const onRecoveryCodesAcknowledged = async () => {
await navigate(returnTo);
};
const onSignOut = async () => {
await authClient.signOut();
};
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<h1 className="font-semibold text-2xl">
<Trans>Two-factor authentication</Trans>
</h1>
{twoFactorEnforcement.required && isBlocked && (
<p className="mt-2 text-muted-foreground text-sm">
<Trans>Two-factor authentication is required to continue using your account.</Trans>
</p>
)}
{twoFactorEnforcement.required && !isBlocked && (
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Two-factor authentication is required for your account from{' '}
{i18n.date(twoFactorEnforcement.deadline, { dateStyle: 'long' })}.
</Trans>
</p>
)}
<hr className="-mx-6 my-4" />
{requiresRelogin ? (
<div className="flex flex-col gap-y-4">
<p className="text-muted-foreground text-sm">
<Trans>
Two-factor authentication is enabled for your account, but this session has not been verified with a
second factor. Sign out and log back in to verify this session.
</Trans>
</p>
<Button className="w-full sm:w-auto sm:self-end" onClick={() => void onSignOut()}>
<Trans>Sign out</Trans>
</Button>
</div>
) : recoveryCodes ? (
<div className="flex flex-col gap-y-4">
<div>
<h2 className="font-medium text-lg">
<Trans>Save your recovery codes</Trans>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Your recovery codes are listed below. Please store them in a safe place — they will not be shown
again.
</Trans>
</p>
</div>
<RecoveryCodeList recoveryCodes={recoveryCodes} />
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
<Button variant="secondary" onClick={onDownloadRecoveryCodes}>
<Trans>Download</Trans>
</Button>
<Button onClick={() => void onRecoveryCodesAcknowledged()}>
<Trans>I have saved my recovery codes</Trans>
</Button>
</div>
</div>
) : !setupData ? (
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
{hasSetupFailed ? (
<>
<p className="text-muted-foreground text-sm">
<Trans>We were unable to prepare two-factor authentication.</Trans>
</p>
<Button variant="secondary" onClick={() => void setupTwoFactor()}>
<Trans>Try again</Trans>
</Button>
</>
) : (
<>
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Preparing two-factor authentication...</Trans>
</p>
</>
)}
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onEnableSubmit)}>
<fieldset disabled={isEnabling} className="flex flex-col gap-y-4">
<p className="text-muted-foreground text-sm">
<Trans>
To enable two-factor authentication, scan the following QR code using your authenticator app.
</Trans>
</p>
<div
className="flex h-36 justify-center"
dangerouslySetInnerHTML={{
__html: renderSVG(setupData.uri),
}}
/>
<p className="text-muted-foreground text-sm">
<Trans>
If your authenticator app does not support QR codes, you can use the following code instead:
</Trans>
</p>
<p className="rounded-lg bg-muted/60 p-2 text-center font-mono text-muted-foreground tracking-widest">
{setupData.secret}
</p>
<FormField
name="token"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
<Trans>Token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={isEnabling} className="w-full sm:w-auto sm:self-end">
<Trans>Enable 2FA</Trans>
</Button>
</fieldset>
</form>
</Form>
)}
{(canSkip || !requiresRelogin) && (
<p className="mt-6 flex flex-col items-center gap-y-1 text-center text-muted-foreground text-sm">
{canSkip && !recoveryCodes && (
<Link to={returnTo} className="text-documenso-700 duration-200 hover:opacity-70">
<Trans>Skip for now</Trans>
</Link>
)}
{!requiresRelogin && (
<button
type="button"
className="text-documenso-700 duration-200 hover:opacity-70"
onClick={() => void onSignOut()}
>
<Trans>Sign out</Trans>
</button>
)}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Outlet } from 'react-router';
/**
* Onboarding routes require a session (each route's own loader asserts it)
* but deliberately live OUTSIDE the `_authenticated+` layout: that layout is
* the UX chokepoint for 2FA enforcement redirects, and remediation pages such
* as `/onboarding/2fa` must be exempt from it or the redirect would loop.
*/
export default function Layout() {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div>
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<img
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<div className="relative w-full">
<Outlet />
</div>
</div>
</main>
);
}
-193
View File
@@ -1,193 +0,0 @@
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 };
};
@@ -1,33 +0,0 @@
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);
};
@@ -1,17 +0,0 @@
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`The file is not a valid PDF or is password protected.`,
description: msg`You cannot upload encrypted PDFs.`,
}))
.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.19.0"
"version": "2.18.0"
}
@@ -1,6 +1,7 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
import { detectFieldsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-fields';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sValidator } from '@hono/standard-validator';
@@ -41,6 +42,14 @@ export const detectFieldsRoute = new Hono<HonoEnv>().post(
});
}
// 2FA enforcement: session-authenticated endpoint — instance assert +
// the owning organisation's policy for the envelope's team.
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [team.organisationId],
});
// Check if AI features are enabled for the team
const { aiFeaturesEnabled } = team.derivedSettings;
@@ -1,6 +1,7 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
import { detectRecipientsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-recipients';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sValidator } from '@hono/standard-validator';
@@ -41,6 +42,14 @@ export const detectRecipientsRoute = new Hono<HonoEnv>().post(
});
}
// 2FA enforcement: session-authenticated endpoint — instance assert +
// the owning organisation's policy for the envelope's team.
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [team.organisationId],
});
// Check if AI features are enabled for the team
const { aiFeaturesEnabled } = team.derivedSettings;
+62
View File
@@ -1,6 +1,7 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { prisma } from '@documenso/prisma';
@@ -28,6 +29,17 @@ export const filesRoute = new Hono<HonoEnv>()
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
// 2FA enforcement applies only when the request is session
// authenticated — presign-token access (embedding) is machine access
// and stays exempt. `resolveFileUploadUserId` prefers the session, so
// asserting on the session here cannot be bypassed by a session user.
const { user: sessionUser, session } = await getOptionalSession(c);
if (sessionUser && session) {
// No organisation scope: the upload creates unattached document data.
await assertTwoFactorEnforcementForSession({ user: sessionUser, session });
}
const userId = await resolveFileUploadUserId(c);
if (!userId) {
@@ -54,6 +66,13 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json(result);
} catch (error) {
console.error('Upload failed:', error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Upload failed' }, 500);
}
})
@@ -69,6 +88,10 @@ export const filesRoute = new Hono<HonoEnv>()
let userId = session.user?.id;
// Presign-token access (embedding) is machine access and exempt from
// 2FA enforcement; the assert below only applies to session auth.
const isPresignTokenAccess = Boolean(token);
if (token) {
const presignToken = await verifyEmbeddingPresignToken({
token,
@@ -86,6 +109,11 @@ export const filesRoute = new Hono<HonoEnv>()
id: envelopeId,
},
include: {
team: {
select: {
organisationId: true,
},
},
envelopeItems: {
where: {
id: envelopeItemId,
@@ -101,6 +129,26 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope not found' }, 404);
}
// 2FA enforcement (session auth only): instance assert + the owning
// organisation's policy for the envelope being accessed.
if (!isPresignTokenAccess && session.user && session.session) {
try {
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [envelope.team.organisationId],
});
} catch (error) {
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
throw error;
}
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
@@ -152,6 +200,11 @@ export const filesRoute = new Hono<HonoEnv>()
id: envelopeId,
},
include: {
team: {
select: {
organisationId: true,
},
},
envelopeItems: {
where: {
id: envelopeItemId,
@@ -173,6 +226,15 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope not found' }, 404);
}
// 2FA enforcement: this route is session-only, so both the instance
// assert and the owning organisation's policy apply. The thrown
// AppError is mapped to a 403 by the catch below.
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [envelope.team.organisationId],
});
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
@@ -1,4 +1,6 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError } from '@documenso/lib/errors/app-error';
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { sha256 } from '@documenso/lib/universal/crypto';
@@ -41,6 +43,10 @@ route.get(
let userId = session.user?.id;
// Presign-token access (embedding) is machine access and exempt from 2FA
// enforcement; the assert below only applies to session auth.
const isPresignTokenAccess = Boolean(presignToken);
// Check presignToken if provided
if (presignToken) {
const verifiedToken = await verifyEmbeddingPresignToken({
@@ -69,6 +75,11 @@ route.get(
type: true,
teamId: true,
templateType: true,
team: {
select: {
organisationId: true,
},
},
},
},
},
@@ -78,6 +89,26 @@ route.get(
return c.json({ error: 'Not found' }, 404);
}
// 2FA enforcement (session auth only): instance assert + the owning
// organisation's policy for the envelope being accessed.
if (!isPresignTokenAccess && session.user && session.session) {
try {
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [envelopeItem.envelope.team.organisationId],
});
} catch (error) {
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
throw error;
}
}
// Check whether the user has access to the document.
const hasAccess = await checkEnvelopeFileAccess({
userId,
+38 -1
View File
@@ -1,3 +1,4 @@
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';
@@ -13,13 +14,49 @@ 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),
});
return next();
// 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();
};
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\/|^\/__/;
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.19.0",
"version": "2.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.19.0",
"version": "2.18.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.5.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -193,7 +193,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.19.0",
"version": "2.18.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -4480,9 +4480,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.5.1.tgz",
"integrity": "sha512-q+y4AEk9ngqyC1pdX/hNScnfh0ROr4vSiqX4C9CmuBzhtuVukbfTesXCaGvHZ3pksi8AJYDPGQ/0HzSeHZfi3A==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
@@ -17662,6 +17662,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
@@ -30843,7 +30844,6 @@
"clsx": "^1.2.1",
"cmdk": "^1.1.1",
"colord": "^2.9.3",
"date-fns": "^3.6.0",
"framer-motion": "^12.43.0",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
+2 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.19.0",
"version": "2.18.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -106,7 +106,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.5.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -1,423 +0,0 @@
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);
});
});
@@ -1,545 +0,0 @@
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();
};
@@ -167,29 +167,6 @@ test.describe('Find Documents UI - Personal Context', () => {
await expect(page.getByRole('link', { name: 'Annual Budget Plan', exact: true })).not.toBeVisible();
});
test('should reset pagination when the search query changes', async ({ page }) => {
const { user: owner, team } = await seedUser();
await seedDraftDocument(owner, team.id, [], {
createDocumentOptions: { title: 'Quarterly Report 2024' },
});
await seedDraftDocument(owner, team.id, [], {
createDocumentOptions: { title: 'Annual Budget Plan' },
});
// Start on a page that would be empty once the search narrows the results.
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/documents?page=2&perPage=1`,
});
await page.getByPlaceholder('Search documents...').fill('Quarterly');
await page.waitForURL((url) => url.searchParams.get('query') === 'Quarterly' && !url.searchParams.has('page'));
await expect(page.getByRole('link', { name: 'Quarterly Report 2024' })).toBeVisible();
});
test('should not show deleted documents', async ({ page }) => {
const { user: owner, team } = await seedUser();
@@ -1,335 +0,0 @@
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, RecipientRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({
mode: 'parallel',
});
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const DEFAULT_EMPTY_STATE = 'Documents that require your attention will appear here';
const COMPLETED_EMPTY_STATE = 'Documents that you have completed will appear here';
const SEARCH_EMPTY_STATE = 'No documents match your search';
const inboxRow = (page: Page, title: string) => page.getByRole('row').filter({ hasText: title });
const searchInbox = async (page: Page, query: string) => {
await page.getByPlaceholder('Search documents...').fill(query);
// An empty search removes the param entirely.
await page.waitForURL((url) => (url.searchParams.get('query') ?? '') === query);
};
// Rendered labels come from the compiled English catalog, which uses the US
// spelling "Canceled" for the `Cancelled` source string.
const INBOX_STATUS_LABELS = {
[DocumentStatus.PENDING]: 'Pending',
[DocumentStatus.COMPLETED]: 'Completed',
[DocumentStatus.REJECTED]: 'Rejected',
[DocumentStatus.CANCELLED]: 'Canceled',
} as const;
type InboxStatus = keyof typeof INBOX_STATUS_LABELS;
const selectInboxStatus = async (page: Page, status: InboxStatus) => {
await page.getByTestId('documents-table-status-filter').click();
await page.getByRole('option', { name: INBOX_STATUS_LABELS[status], exact: true }).click();
await page.waitForURL((url) => url.searchParams.get('status') === status);
};
// ─── Behaviour ───────────────────────────────────────────────────────────────
test.describe('Inbox - Search & Status Filter', () => {
test('should show every non-draft document by default', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Inbox Pending Document' },
});
await seedCompletedDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Inbox Completed Document' },
});
await seedCancelledDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Inbox Cancelled Document' },
});
await seedDraftDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Inbox Draft Document' },
});
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
// No status selected by default.
expect(new URL(page.url()).searchParams.get('status')).toBeNull();
await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
await expect(inboxRow(page, 'Inbox Pending Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Draft Document')).not.toBeVisible();
await selectInboxStatus(page, DocumentStatus.COMPLETED);
await expect(page.getByTestId('documents-table-status-filter')).toContainText('Completed');
await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Pending Document')).not.toBeVisible();
await expect(inboxRow(page, 'Inbox Cancelled Document')).not.toBeVisible();
await selectInboxStatus(page, DocumentStatus.CANCELLED);
await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Pending Document')).not.toBeVisible();
await expect(inboxRow(page, 'Inbox Completed Document')).not.toBeVisible();
// Clearing the filter returns to every non-draft document.
await page.getByTestId('documents-table-status-filter').click();
await page.getByRole('option', { name: 'Clear' }).click();
await page.waitForURL((url) => url.searchParams.get('status') === null);
await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
await expect(inboxRow(page, 'Inbox Pending Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Draft Document')).not.toBeVisible();
});
test('should offer every status except draft', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email, redirectPath: '/inbox' });
await page.getByTestId('documents-table-status-filter').click();
for (const visibleStatus of Object.values(INBOX_STATUS_LABELS)) {
await expect(page.getByRole('option', { name: visibleStatus, exact: true })).toBeVisible();
}
for (const hiddenStatus of ['Draft', 'Inbox', 'All', 'Expired']) {
await expect(page.getByRole('option', { name: hiddenStatus, exact: true })).not.toBeVisible();
}
});
test('should filter documents by title', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Alpha Agreement' },
});
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Beta Agreement' },
});
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
await expect(inboxRow(page, 'Alpha Agreement')).toBeVisible();
await expect(inboxRow(page, 'Beta Agreement')).toBeVisible();
await searchInbox(page, 'alpha');
await expect(inboxRow(page, 'Alpha Agreement')).toBeVisible();
await expect(inboxRow(page, 'Beta Agreement')).not.toBeVisible();
await searchInbox(page, 'Gamma');
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
// Search is combined with the status filter.
await selectInboxStatus(page, DocumentStatus.COMPLETED);
await searchInbox(page, 'Agreement');
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Alpha Agreement')).not.toBeVisible();
// Clearing the search shows the status specific empty state.
await searchInbox(page, '');
await expect(page.getByText(COMPLETED_EMPTY_STATE)).toBeVisible();
});
});
// ─── Adversarial ─────────────────────────────────────────────────────────────
test.describe('Inbox - Adversarial Access', () => {
test('should not leak another user documents through search', 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' },
});
// Attacker lands directly on a crafted URL with the exact title.
await apiSignin({
page,
email: attacker.email,
redirectPath: '/inbox?query=Confidential%20Merger%20Agreement',
});
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Confidential Merger Agreement')).not.toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Confidential&status=COMPLETED`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Confidential Severance Package')).not.toBeVisible();
// Wildcards must not widen the search to everything.
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=%25`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(page.getByText('Confidential', { exact: false })).not.toBeVisible();
});
test('should ignore tampered status values', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
// The recipient is attached to a draft that has not been sent yet. It must
// never be reachable via the URL, regardless of the status requested.
await seedDraftDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Unsent Draft Document' },
});
await seedCancelledDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Cancelled Document' },
});
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Legit Pending Document' },
});
// Draft, virtual and derived statuses are ignored, showing the unfiltered
// non-draft view. Kept to a handful of full page loads per test since each
// one is a fresh navigation.
const expectUnfilteredView = async () => {
await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
await expect(inboxRow(page, 'Legit Pending Document')).toBeVisible();
await expect(inboxRow(page, 'Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Unsent Draft Document')).not.toBeVisible();
};
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox?status=DRAFT' });
await expectUnfilteredView();
await page.goto(`${WEBAPP_BASE_URL}/inbox?status=ALL`);
await expectUnfilteredView();
await page.goto(`${WEBAPP_BASE_URL}/inbox?status=EXPIRED`);
await expectUnfilteredView();
// Searching by the exact title of the draft must not surface it either.
await searchInbox(page, 'Unsent Draft');
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
// Supported non-pending statuses are scoped to exactly that status.
await page.goto(`${WEBAPP_BASE_URL}/inbox?status=CANCELLED`);
await expect(page.getByTestId('documents-table-status-filter')).toContainText(
INBOX_STATUS_LABELS[DocumentStatus.CANCELLED],
);
await expect(inboxRow(page, 'Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Legit Pending Document')).not.toBeVisible();
await expect(inboxRow(page, 'Unsent Draft Document')).not.toBeVisible();
});
test('should not show deleted or CC documents even when searched by exact title', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Deleted Pending Document' },
});
await prisma.envelope.update({
where: { id: deletedDocument.id },
data: { deletedAt: new Date() },
});
const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'CC Only Pending Document' },
});
await prisma.recipient.updateMany({
where: { envelopeId: ccDocument.id, email: recipient.email },
data: { role: RecipientRole.CC },
});
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
await expect(page.getByText(DEFAULT_EMPTY_STATE)).toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Deleted%20Pending`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Deleted Pending Document')).not.toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=CC%20Only`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'CC Only Pending Document')).not.toBeVisible();
});
test('should not show team documents to team members who are not recipients', async ({ page }) => {
const { team, owner } = await seedTeam();
const { user: outsideRecipient } = await seedUser();
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' },
});
// A team admin sees these on the team documents page, but the personal
// inbox is strictly recipient scoped.
await apiSignin({ page, email: teamAdmin.email, redirectPath: '/inbox?query=Team' });
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Team Payroll Summary')).not.toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Team&status=COMPLETED`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Team Board Minutes')).not.toBeVisible();
// Positive control: the actual recipient can find both.
await page.context().clearCookies();
await apiSignin({ page, email: outsideRecipient.email, redirectPath: '/inbox?query=Team' });
await expect(inboxRow(page, 'Team Payroll Summary')).toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Team&status=COMPLETED`);
await expect(inboxRow(page, 'Team Board Minutes')).toBeVisible();
});
test('should redirect unauthenticated users away from the inbox', async ({ page }) => {
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Confidential&status=COMPLETED`);
await expect(page).toHaveURL(/\/signin/);
});
});
@@ -6,6 +6,12 @@ type LoginOptions = {
email?: string;
password?: string;
/**
* TOTP code for accounts with 2FA enabled. Sign-ins that pass a valid code
* create a session with `twoFactorVerified: true`.
*/
totpCode?: string;
/**
* Where to navigate after login.
*/
@@ -16,6 +22,7 @@ export const apiSignin = async ({
page,
email = 'example@documenso.com',
password = 'password',
totpCode,
redirectPath = '/',
}: LoginOptions) => {
const { request } = page.context();
@@ -26,6 +33,7 @@ export const apiSignin = async ({
data: {
email,
password,
totpCode,
csrfToken,
},
});
@@ -0,0 +1,111 @@
import crypto from 'node:crypto';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { prisma } from '@documenso/prisma';
import { base32 } from '@scure/base';
import { generateHOTP } from 'oslo/otp';
/**
* Must match the period used by `verifyTwoFactorAuthenticationToken` in
* `packages/lib/server-only/2fa/verify-2fa-token.ts`.
*/
const TOTP_PERIOD_MS = 30_000;
/**
* Enables 2FA for an existing user using the exact storage format the server
* writes in `setup-2fa.ts`/`enable-2fa.ts` (base32 TOTP secret + JSON backup
* codes, both symmetrically encrypted with the instance encryption key).
*
* No mocks: the server validates codes generated from this secret with the
* same OTP library used here.
*/
export const seedUserTwoFactorAuthentication = async ({ userId }: { userId: number }) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error('NEXT_PRIVATE_ENCRYPTION_KEY must be set to seed 2FA users');
}
const secret = crypto.randomBytes(10);
const backupCodes = Array.from({ length: 10 })
.fill(null)
.map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
await prisma.user.update({
where: {
id: userId,
},
data: {
twoFactorEnabled: true,
twoFactorSecret: symmetricEncrypt({
key,
data: base32.encode(new Uint8Array(secret)),
}),
twoFactorBackupCodes: symmetricEncrypt({
key,
data: JSON.stringify(backupCodes),
}),
},
});
return {
secret,
backupCodes,
};
};
/**
* Computes the TOTP code for the current time window, mirroring the server's
* verification (`generateHOTP` with a 30 second period).
*/
export const generateTotpCode = async ({ secret }: { secret: Buffer }) => {
return await generateHOTP(new Uint8Array(secret), Math.floor(Date.now() / TOTP_PERIOD_MS));
};
/**
* The server only accepts the code for the current 30s window (window = 1).
* If we are close to a window boundary, wait for the next window so the code
* cannot expire between generation and verification.
*/
export const waitForStableTotpWindow = async () => {
const remainingMs = TOTP_PERIOD_MS - (Date.now() % TOTP_PERIOD_MS);
if (remainingMs < 5_000) {
await new Promise((resolve) => {
setTimeout(resolve, remainingMs + 250);
});
}
};
type SeedOrganisationTwoFactorEnforcementOptions = {
organisationId: string;
twoFactorRequired?: boolean;
twoFactorGracePeriodDays?: number;
};
/**
* Directly seeds the organisation-level 2FA enforcement settings, bypassing
* the tRPC settings write path (which is covered by its own tests).
*/
export const seedOrganisationTwoFactorEnforcement = async ({
organisationId,
twoFactorRequired = true,
twoFactorGracePeriodDays = 0,
}: SeedOrganisationTwoFactorEnforcementOptions) => {
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
organisationGlobalSettings: {
update: {
twoFactorRequired,
twoFactorGracePeriodDays,
twoFactorEnforcedFrom: twoFactorRequired ? new Date() : null,
},
},
},
});
};
@@ -1,289 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { DocumentStatus, DocumentVisibility, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import { customAlphabet } from 'nanoid';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const nanoid = customAlphabet('1234567890abcdef', 10);
type AnalyticsRange = '7d' | '30d' | '90d' | '12m';
/**
* Timestamps are relative to now and kept at least a day away from every window
* boundary (7, 30, 60 and 90 days) so the assertions hold regardless of timezone.
*/
const daysAgo = (days: number) => DateTime.now().minus({ days }).toJSDate();
test.describe.configure({ mode: 'parallel' });
test('[ORG ANALYTICS]: admin sees aggregated numbers across teams', async ({ page }) => {
const { owner, teamA, teamB, organisation } = await seedOrganisationWithTwoTeams();
// Team A: 4 sent, 3 completed.
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) });
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) });
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(4) });
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.PENDING, sentAt: daysAgo(5) });
// Team B: 3 sent, 2 completed. Organisation admins see every document, so the
// ADMIN-visibility one counts too.
await seedAnalyticsDocument({ owner, teamId: teamB.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(6) });
await seedAnalyticsDocument({ owner, teamId: teamB.id, status: DocumentStatus.PENDING, sentAt: daysAgo(8) });
await seedAnalyticsDocument({
owner,
teamId: teamB.id,
status: DocumentStatus.COMPLETED,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(9),
});
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(organisation.url) });
await waitForAnalytics(page);
// Overview: 7 sent, 5 completed (71%), both teams active.
await expect(page.getByTestId('analytics-sent')).toHaveText('7');
await expect(page.getByTestId('analytics-completion-rate')).toHaveText('71%');
await expect(page.getByTestId('analytics-teams')).toHaveText('2/2');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('7 total');
await expect(page.getByTestId('analytics-status-completed')).toHaveText('5');
await expect(page.getByTestId('analytics-status-pending')).toHaveText('2');
// Team activity: sorted by sent desc, so team A (4) comes before team B (3).
const rows = page.getByTestId('analytics-team-row');
await expect(page.getByTestId('analytics-team-summary')).toContainText('2 teams');
await expect(page.getByTestId('analytics-team-summary')).toContainText('2 active');
await expect(rows).toHaveCount(2);
await expect(rows.nth(0)).toContainText(teamA.name);
await expect(rows.nth(0)).toContainText(`/t/${teamA.url}`);
await expectTeamRow(rows.nth(0), { sent: '4', completed: '3', pending: '1', completionRate: '75%' });
await expect(rows.nth(1)).toContainText(teamB.name);
await expect(rows.nth(1)).toContainText(`/t/${teamB.url}`);
await expectTeamRow(rows.nth(1), { sent: '3', completed: '2', pending: '1', completionRate: '67%' });
});
test("[ORG ANALYTICS]: clicking a team row opens that team's analytics", async ({ page }) => {
const { owner, teamA, organisation } = await seedOrganisationWithTwoTeams();
// Team A has activity so it is sorted first.
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) });
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(organisation.url) });
await waitForAnalytics(page);
const firstRow = page.getByTestId('analytics-team-row').first();
// The title is a real link to the team's analytics.
await expect(firstRow.getByRole('link', { name: teamA.name })).toHaveAttribute('href', `/t/${teamA.url}/analytics`);
// Clicking a non-link cell navigates via the row click handler.
await firstRow.getByTestId('analytics-team-sent').click();
await page.waitForURL(new RegExp(`/t/${teamA.url}/analytics(?:\\?.*)?$`));
});
test('[ORG ANALYTICS]: only organisation admins can access', async ({ page }) => {
const { team, owner, organisation } = await seedTeam();
// Team members seeded this way are organisation MEMBERs.
const member = await seedTeamMember({
teamId: team.id,
name: 'Analytics Member',
role: TeamMemberRole.ADMIN,
});
const [manager] = await seedOrganisationMembers({
members: [{ name: 'Analytics Manager', organisationRole: OrganisationMemberRole.MANAGER }],
organisationId: organisation.id,
});
const organisationHomePattern = new RegExp(`/o/${organisation.url}(?:\\?.*)?$`);
// Unauthenticated: the page redirects to sign in and the API rejects the call.
await page.goto(analyticsPath(organisation.url));
await page.waitForURL(/\/signin(?:\?.*)?$/);
const unauthenticatedResponse = await requestOverview(page, organisation.id);
expect(unauthenticatedResponse.status()).toBe(401);
// Organisation member: redirected to the organisation home, API rejects the call.
await apiSignin({ page, email: member.email, redirectPath: analyticsPath(organisation.url) });
await page.waitForURL(organisationHomePattern);
const memberResponse = await requestOverview(page, organisation.id);
expect(memberResponse.status()).toBe(401);
await apiSignout({ page });
// Organisation manager: the gate is ADMIN only, so managers are rejected too.
await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(organisation.url) });
await page.waitForURL(organisationHomePattern);
const managerResponse = await requestOverview(page, organisation.id);
expect(managerResponse.status()).toBe(401);
await apiSignout({ page });
// Non-member denial: a user outside the organisation is redirected away and rejected by the API.
const { user: nonMember } = await seedUser();
await apiSignin({ page, email: nonMember.email, redirectPath: analyticsPath(organisation.url) });
await page.waitForURL(organisationHomePattern);
const nonMemberResponse = await requestOverview(page, organisation.id);
expect(nonMemberResponse.status()).toBe(401);
await apiSignout({ page });
// Organisation admin: the menu switcher links to the page and the API responds.
await apiSignin({ page, email: owner.email, redirectPath: `/o/${organisation.url}` });
await page.getByTestId('menu-switcher').click();
// Outside a team context the single "Analytics" item points at organisation analytics.
const analyticsMenuItem = page.getByRole('menuitem', { name: 'Analytics', exact: true });
await expect(analyticsMenuItem).toHaveAttribute('href', `/o/${organisation.url}/analytics`);
await analyticsMenuItem.click();
await page.waitForURL(new RegExp(`/o/${organisation.url}/analytics(?:\\?.*)?$`));
await waitForAnalytics(page);
// Inside a team context the item points at team analytics, and the team page links onwards.
await page.goto(`/t/${team.url}/analytics`);
await waitForAnalytics(page);
await page.getByTestId('menu-switcher').click();
await expect(page.getByRole('menuitem', { name: 'Analytics', exact: true })).toHaveAttribute(
'href',
`/t/${team.url}/analytics`,
);
await page.keyboard.press('Escape');
await page.getByRole('link', { name: 'View organisation analytics' }).click();
await page.waitForURL(new RegExp(`/o/${organisation.url}/analytics(?:\\?.*)?$`));
const adminResponse = await requestOverview(page, organisation.id);
expect(adminResponse.ok()).toBe(true);
expect(await adminResponse.text()).toContain('"completionRate"');
});
/**
* Seed an organisation with two teams. The owner is an organisation ADMIN and,
* through `inheritMembers`, an admin of both teams.
*/
const seedOrganisationWithTwoTeams = async () => {
const { owner, team: teamA, organisation } = await seedTeam();
const teamBUrl = `analytics-team-b-${nanoid()}`;
await createTeam({
userId: owner.id,
teamName: 'Analytics Team B',
teamUrl: teamBUrl,
organisationId: organisation.id,
inheritMembers: true,
});
const teamB = await prisma.team.findFirstOrThrow({
where: {
url: teamBUrl,
},
});
return { owner, teamA, teamB, organisation };
};
/**
* Seed a team document with an optional DOCUMENT_SENT audit log.
*/
const seedAnalyticsDocument = async ({
owner,
teamId,
status,
visibility = DocumentVisibility.EVERYONE,
sentAt,
}: {
owner: User;
teamId: number;
status: DocumentStatus;
visibility?: DocumentVisibility;
sentAt?: Date;
}) => {
const envelope = await seedBlankDocument(owner, teamId, {
createDocumentOptions: {
status,
visibility,
...(status === DocumentStatus.COMPLETED && sentAt ? { completedAt: sentAt } : {}),
},
});
if (sentAt) {
await prisma.documentAuditLog.createMany({
data: [
{
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: sentAt,
data: {},
},
],
});
}
return envelope;
};
const analyticsPath = (organisationUrl: string, range?: AnalyticsRange) => {
const path = `/o/${organisationUrl}/analytics`;
return range ? `${path}?range=${range}` : path;
};
/**
* The analytics queries only run after hydration, which can be slow on a cold dev
* server, so wait for the hydrate fallback to be replaced before asserting values.
*/
const waitForAnalytics = async (page: Page) => {
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('analytics-loading')).toHaveCount(0, { timeout: 30_000 });
};
const expectTeamRow = async (
row: Locator,
expected: { sent: string; completed: string; pending: string; completionRate: string },
) => {
await expect(row.getByTestId('analytics-team-sent')).toHaveText(expected.sent);
await expect(row.getByTestId('analytics-team-completed')).toHaveText(expected.completed);
await expect(row.getByTestId('analytics-team-pending')).toHaveText(expected.pending);
await expect(row.getByTestId('analytics-team-completion-rate')).toHaveText(expected.completionRate);
};
const requestAnalytics = async (page: Page, procedure: 'getOverview' | 'getTeamActivity', organisationId: string) => {
const input = encodeURIComponent(JSON.stringify({ json: { organisationId, range: '30d', timezone: 'UTC' } }));
return await page
.context()
.request.get(`${WEBAPP_BASE_URL}/api/trpc/organisation.analytics.${procedure}?input=${input}`);
};
const requestOverview = async (page: Page, organisationId: string) => {
return await requestAnalytics(page, 'getOverview', organisationId);
};
@@ -0,0 +1,272 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import {
generateTotpCode,
seedOrganisationTwoFactorEnforcement,
seedUserTwoFactorAuthentication,
waitForStableTotpWindow,
} from '../fixtures/two-factor';
test('[ORGANISATIONS]: joining succeeds then org context is blocked at 0-day grace', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
// The owner must satisfy the policy themselves to use the org settings
// pages while enforcement is active with a 0-day grace period.
const { secret: ownerSecret } = await seedUserTwoFactorAuthentication({ userId: owner.id });
await seedOrganisationTwoFactorEnforcement({
organisationId: organisation.id,
twoFactorGracePeriodDays: 0,
});
const { user: member } = await seedUser({ isPersonalOrganisation: false });
await waitForStableTotpWindow();
await apiSignin({
page,
email: owner.email,
totpCode: await generateTotpCode({ secret: ownerSecret }),
redirectPath: `/o/${organisation.url}/settings/members`,
});
// Invite the member while the 0-day enforcement policy is already active.
await page.getByRole('button', { name: 'Invite member' }).click();
await page.getByRole('textbox', { name: 'Email address *' }).fill(member.email);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('tab', { name: 'Pending' }).click();
await expect(page.getByText(member.email)).toBeVisible();
// Joining is never blocked: the member accepts the invite without 2FA.
await apiSignout({ page });
await apiSignin({ page, email: member.email, redirectPath: `/settings/organisations` });
await page.getByRole('button', { name: 'View invites' }).click();
await page.getByRole('button', { name: 'Accept' }).click();
await expect(page.getByText('Invitation accepted').first()).toBeVisible();
const membership = await prisma.organisationMember.findFirst({
where: {
userId: member.id,
organisationId: organisation.id,
},
});
expect(membership).not.toBeNull();
// Access, however, is blocked immediately (0-day grace): the org context
// renders the 403 screen linking to forced enrolment.
await page.goto(`/o/${organisation.url}`);
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
await expect(page.getByText('403 Forbidden')).toBeVisible();
const enrolmentLink = page.getByRole('link', { name: 'Set up two-factor authentication' });
await expect(enrolmentLink).toBeVisible();
await expect(enrolmentLink).toHaveAttribute('href', /\/onboarding\/2fa\?returnTo=/);
// Team contexts resolve to the owning organisation and are blocked too.
await page.goto(`/t/${team.url}/documents`);
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
// The security boundary: a blocked org-scoped tRPC call returns 403.
const blockedResponse = await page
.context()
.request.get(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.get?input=${encodeURIComponent(
JSON.stringify({ json: { organisationReference: organisation.url } }),
)}`,
);
expect(blockedResponse.status()).toBe(403);
// The rest of the app stays usable: the member's own organisation is
// unaffected.
await page.goto(`/settings/organisations`);
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
});
test('[ORGANISATIONS]: member with satisfied 2FA is unaffected by enforcement', async ({ page }) => {
const { organisation, team } = await seedUser({ isPersonalOrganisation: false });
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const [member] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Member 2FA',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
const { secret } = await seedUserTwoFactorAuthentication({ userId: member.id });
await seedOrganisationTwoFactorEnforcement({
organisationId: organisation.id,
twoFactorGracePeriodDays: 0,
});
await waitForStableTotpWindow();
// Signing in with a valid TOTP code marks the session as second-factor
// verified, which satisfies enforcement.
await apiSignin({
page,
email: memberEmail,
totpCode: await generateTotpCode({ secret }),
redirectPath: `/o/${organisation.url}`,
});
await expect(page.getByText(team.name).first()).toBeVisible();
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
await page.goto(`/t/${team.url}/documents`);
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
const allowedResponse = await page
.context()
.request.get(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.get?input=${encodeURIComponent(
JSON.stringify({ json: { organisationReference: organisation.url } }),
)}`,
);
expect(allowedResponse.status()).toBe(200);
});
test('[ORGANISATIONS]: blocked member can leave the organisation', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const [member] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Blocked Member',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
await seedOrganisationTwoFactorEnforcement({
organisationId: organisation.id,
twoFactorGracePeriodDays: 0,
});
await apiSignin({
page,
email: memberEmail,
redirectPath: `/o/${organisation.url}`,
});
// Confirm the member is blocked from the organisation context.
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
// A member must always be able to walk away: `organisation.leave` is on
// the remediation allow-list, so leaving works while blocked.
await page.goto('/settings/organisations');
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
await expect(page.getByText('No results found').first()).toBeVisible();
const membership = await prisma.organisationMember.findFirst({
where: {
userId: member.id,
organisationId: organisation.id,
},
});
expect(membership).toBeNull();
});
test('[ORGANISATIONS]: admin with satisfied 2FA can enable and persist enforcement settings', async ({ page }) => {
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
const { secret } = await seedUserTwoFactorAuthentication({ userId: owner.id });
await waitForStableTotpWindow();
await apiSignin({
page,
email: owner.email,
totpCode: await generateTotpCode({ secret }),
redirectPath: `/o/${organisation.url}/settings/general`,
});
const requireSwitch = page.getByRole('switch', { name: 'Require two-factor authentication' });
await expect(requireSwitch).toBeVisible();
await expect(requireSwitch).not.toBeChecked();
await requireSwitch.click();
await page.getByLabel('Grace period (days)').fill('30');
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByText('Two-factor enforcement settings updated').first()).toBeVisible();
// Roundtrip: values persist across a reload.
await page.reload();
await expect(page.getByRole('switch', { name: 'Require two-factor authentication' })).toBeChecked();
await expect(page.getByLabel('Grace period (days)')).toHaveValue('30');
const settings = await prisma.organisation.findFirstOrThrow({
where: {
id: organisation.id,
},
include: {
organisationGlobalSettings: true,
},
});
expect(settings.organisationGlobalSettings.twoFactorRequired).toBe(true);
expect(settings.organisationGlobalSettings.twoFactorGracePeriodDays).toBe(30);
expect(settings.organisationGlobalSettings.twoFactorEnforcedFrom).not.toBeNull();
});
test('[ORGANISATIONS]: admin without 2FA cannot enable enforcement', async ({ page }) => {
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: owner.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
const requireSwitch = page.getByRole('switch', { name: 'Require two-factor authentication' });
await expect(requireSwitch).toBeVisible();
await requireSwitch.click();
await page.getByRole('button', { name: 'Update' }).click();
// Enable-time guard: the acting admin must already satisfy the policy
// being enabled.
await expect(
page.getByText('You must have two-factor authentication enabled and verified on this session').first(),
).toBeVisible();
const settings = await prisma.organisation.findFirstOrThrow({
where: {
id: organisation.id,
},
include: {
organisationGlobalSettings: true,
},
});
expect(settings.organisationGlobalSettings.twoFactorRequired).toBe(false);
});
@@ -1,690 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
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 type { User } from '@documenso/prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
type AnalyticsRange = '7d' | '30d' | '90d' | '12m';
/**
* Timestamps are relative to now and kept at least a day away from every window
* boundary (7, 30, 60 and 90 days) so the assertions hold regardless of timezone.
*/
const daysAgo = (days: number) => DateTime.now().minus({ days }).toJSDate();
/**
* The same day as `daysAgo` as a yyyy-MM-dd calendar date in the host timezone,
* which is also the browser timezone the page sends with custom ranges.
*/
const daysAgoDate = (days: number) => DateTime.now().minus({ days }).toFormat('yyyy-MM-dd');
test.describe.configure({ mode: 'parallel' });
test('[ANALYTICS]: admin sees overview numbers for the last 30 days', async ({ page }) => {
const { team, owner } = await seedTeam();
// Current window: 5 sent, 3 completed.
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(3) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(4) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(12) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(15) });
// Previous window: 2 sent, 1 completed.
const resentDocument = await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.COMPLETED,
sentAt: daysAgo(40),
});
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(45) });
// First send: a re-send inside the current window leaves the document counted once, in the previous window.
await prisma.documentAuditLog.create({
data: {
envelopeId: resentDocument.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: daysAgo(20),
data: {},
},
});
// Soft delete: a deleted document sent in the window is excluded, so none of the numbers below change.
await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.COMPLETED,
sentAt: daysAgo(6),
deletedAt: new Date(),
});
// Never sent. Every document above without `createdAt` was created today.
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.DRAFT });
// Never sent, created just after local midnight so bucketing in the wrong timezone would shift their day.
const createdDaysAgo = [3, 3, 5];
for (const days of createdDaysAgo) {
await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.DRAFT,
createdAt: DateTime.now().minus({ days }).startOf('day').plus({ minutes: 30 }).toJSDate(),
});
}
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-range')).toContainText('Last 30 days');
await expect(page.getByTestId('analytics-sent')).toHaveText('5');
await expect(page.getByTestId('analytics-sent-delta')).toHaveText('+150%');
await expect(page.getByTestId('analytics-completion-rate')).toHaveText('60%');
await expect(page.getByTestId('analytics-completion-rate-delta')).toHaveText('+10%');
await expect(page.getByTestId('analytics-members')).toHaveText('1/1');
// Day buckets: documents land on their creation day in the request timezone and empty days are zero-filled.
const daily = await requestDocumentsOverTime(page, team.id, { range: '30d' });
expect(daily.points).toHaveLength(30);
expect(daily.points).toContainEqual({ date: daysAgoDate(3), count: 2 });
expect(daily.points).toContainEqual({ date: daysAgoDate(4), count: 0 });
expect(daily.points).toContainEqual({ date: daysAgoDate(5), count: 1 });
// Month buckets: keyed by month start, the last one sums this month's documents (8 created today, deleted excluded).
const monthStart = DateTime.now().startOf('month');
const createdThisMonth = 8 + createdDaysAgo.filter((days) => DateTime.now().minus({ days }) >= monthStart).length;
const monthly = await requestDocumentsOverTime(page, team.id, { range: '12m' });
expect(monthly.points).toHaveLength(12);
expect(monthly.points.at(-1)).toEqual({ date: monthStart.toFormat('yyyy-MM-dd'), count: createdThisMonth });
// Switching to 90 days pulls the previous window into the current one.
await selectRange(page, 'Last 90 days');
await expectRangeParam(page, '90d');
await expect(page.getByTestId('analytics-sent')).toHaveText('7');
// Loading a range directly from the URL works too. The 7 day previous window
// (7-14 days ago) only contains the document sent 12 days ago.
await page.goto(analyticsPath(team.url, '7d'));
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('3');
await expect(page.getByTestId('analytics-sent-delta')).toHaveText('+200%');
});
test('[ANALYTICS]: a custom date range scopes activity to the selected days', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(5) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(20) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(40) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(50) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(200) });
// A 16 day window loaded from the URL only contains the document sent 20 days
// ago. The equally sized previous window (41-26 days ago) contains the one sent
// 40 days ago, so the delta is flat.
const windowFrom = daysAgoDate(25);
const windowTo = daysAgoDate(10);
await apiSignin({ page, email: owner.email, redirectPath: customAnalyticsPath(team.url, windowFrom, windowTo) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('1');
await expect(page.getByTestId('analytics-sent-delta')).toHaveText('0%');
await expect(page.getByTestId('analytics-documents-over-time')).toContainText('Daily');
// The trigger shows the formatted window, e.g. "Aug 29 – Sep 13, 2026".
const rangeTrigger = page.getByTestId('analytics-range');
await expect(rangeTrigger).toContainText(String(DateTime.fromISO(windowTo).year));
// Picking a new window in the calendar replaces the current one on apply.
const pickedFrom = daysAgoDate(6);
const pickedTo = daysAgoDate(2);
await rangeTrigger.click();
await page.getByTestId('analytics-range-custom').click();
await clickCalendarDay(page, pickedFrom);
await clickCalendarDay(page, pickedTo);
await page.getByTestId('analytics-range-apply').click();
await expectCustomRangeParams(page, pickedFrom, pickedTo);
await expect(page.getByTestId('analytics-range-calendar')).toHaveCount(0);
await expect(page.getByTestId('analytics-sent')).toHaveText('2');
await expect(page.getByTestId('analytics-sent-delta')).toHaveText('New');
// Windows longer than 92 days are bucketed by month.
await page.goto(customAnalyticsPath(team.url, daysAgoDate(120), daysAgoDate(1)));
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-documents-over-time')).toContainText('Monthly');
await expect(page.getByTestId('analytics-sent')).toHaveText('5');
// An invalid window (from after to) falls back to the default preset and the
// stray params are cleared from the URL.
await page.goto(customAnalyticsPath(team.url, daysAgoDate(5), daysAgoDate(10)));
await waitForAnalytics(page);
await expect.poll(() => page.url()).not.toContain('range=custom');
await expect.poll(() => new URL(page.url()).searchParams.has('from')).toBe(false);
await expect(page.getByTestId('analytics-sent')).toHaveText('3');
// A window starting more than 12 months ago is rejected the same way.
await page.goto(customAnalyticsPath(team.url, daysAgoDate(400), daysAgoDate(380)));
await waitForAnalytics(page);
await expect.poll(() => page.url()).not.toContain('range=custom');
// The API rejects an invalid custom window outright (the resolver rules
// themselves are unit tested).
const invalidResponse = await requestOverview(page, team.id, {
range: 'custom',
from: daysAgoDate(5),
to: daysAgoDate(10),
});
expect(invalidResponse.status()).toBe(400);
});
test('[ANALYTICS]: a manager only sees documents within their visibility scope', async ({ page }) => {
const { team, owner } = await seedTeam();
const manager = await seedTeamMember({
teamId: team.id,
name: 'Analytics Manager',
role: TeamMemberRole.MANAGER,
});
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED, DocumentStatus.PENDING]) {
await seedAnalyticsDocument({
owner,
teamId: team.id,
status,
visibility: DocumentVisibility.EVERYONE,
sentAt: daysAgo(3),
});
}
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED]) {
await seedAnalyticsDocument({
owner,
teamId: team.id,
status,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(4),
});
}
// Owner clause: an ADMIN-only document the manager owns is in their scope, unlike the owner's ADMIN-only ones.
await seedAnalyticsDocument({
owner: manager,
teamId: team.id,
status: DocumentStatus.COMPLETED,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(3),
});
await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('4');
await expect(page.getByTestId('analytics-status-completed')).toHaveText('3');
await expect(page.getByTestId('analytics-status-pending')).toHaveText('1');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('4 total');
await expect(page.getByTestId('analytics-members')).toHaveText('2/2');
// An ADMIN-only document still counts for the manager when they are a recipient.
await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.PENDING,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(5),
recipientEmail: manager.email,
});
await page.reload();
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('5');
await expect(page.getByTestId('analytics-status-completed')).toHaveText('3');
await expect(page.getByTestId('analytics-status-pending')).toHaveText('2');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('5 total');
await apiSignout({ page });
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('7');
await expect(page.getByTestId('analytics-status-completed')).toHaveText('5');
await expect(page.getByTestId('analytics-status-pending')).toHaveText('2');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('7 total');
});
test('[ANALYTICS]: template usage ranks templates by documents created from them', async ({ page }) => {
const { team, owner } = await seedTeam();
const popularTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Analytics Popular Template' },
});
const otherTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Analytics Other Template' },
});
const deletedTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Analytics Deleted Template', deletedAt: new Date() },
});
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(2) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) });
// Distinct counts so the order does not depend on the tie-breaker.
for (const [template, count] of [
[popularTemplate, 3],
[otherTemplate, 2],
[deletedTemplate, 1],
] as const) {
for (let index = 0; index < count; index += 1) {
await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.PENDING,
sentAt: daysAgo(2),
templateSecondaryId: template.secondaryId,
});
}
}
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
const rows = page.getByTestId('analytics-template-row');
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('Analytics Popular Template');
await expect(rows.nth(0)).toContainText('3 uses');
await expect(rows.nth(0).getByRole('link', { name: 'Analytics Popular Template' })).toHaveAttribute(
'href',
`/t/${team.url}/templates/${popularTemplate.id}`,
);
await expect(rows.nth(1)).toContainText('Analytics Other Template');
await expect(rows.nth(1)).toContainText('2 uses');
await expect(rows.nth(2)).toContainText('Unavailable template');
await expect(rows.nth(2)).toContainText('1 use');
await expect(rows.nth(2)).not.toContainText('Analytics Deleted Template');
await expect(rows.nth(2).getByRole('link')).toHaveCount(0);
});
test('[ANALYTICS]: member activity respects visibility per member', async ({ page }) => {
// `seedTeam` hardcodes the owner name, so seed the owner directly to control it.
const { user: jane, team } = await seedUser({ name: 'Jane Analytics' });
const manager = await seedTeamMember({
teamId: team.id,
name: 'Analytics Manager',
role: TeamMemberRole.MANAGER,
});
await seedTeamMember({
teamId: team.id,
name: 'Analytics Member',
role: TeamMemberRole.MEMBER,
});
// Jane: 3 EVERYONE (2 completed, 1 pending) + 2 ADMIN-only (both completed).
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED, DocumentStatus.PENDING]) {
await seedAnalyticsDocument({
owner: jane,
teamId: team.id,
status,
visibility: DocumentVisibility.EVERYONE,
sentAt: daysAgo(3),
});
}
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED]) {
await seedAnalyticsDocument({
owner: jane,
teamId: team.id,
status,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(4),
});
}
// Manager: 2 EVERYONE (1 completed, 1 pending).
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.PENDING]) {
await seedAnalyticsDocument({
owner: manager,
teamId: team.id,
status,
visibility: DocumentVisibility.EVERYONE,
sentAt: daysAgo(5),
});
}
const rows = page.getByTestId('analytics-member-row');
// Admin sees everything.
await apiSignin({ page, email: jane.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-member-summary')).toContainText('3 members');
await expect(page.getByTestId('analytics-member-summary')).toContainText('2 active');
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('Jane Analytics');
await expectMemberRow(rows.nth(0), { sent: '5', completed: '4', pending: '1', completionRate: '80%' });
await expect(rows.nth(1)).toContainText('Analytics Manager');
await expectMemberRow(rows.nth(1), { sent: '2', completed: '1', pending: '1', completionRate: '50%' });
await expect(rows.nth(2)).toContainText('Analytics Member');
await expectMemberRow(rows.nth(2), { sent: '0', completed: '0', pending: '0', completionRate: '—' });
// Search filters the table case-insensitively.
const search = page.getByTestId('analytics-member-search');
await search.fill('MANAGER');
await expect(rows).toHaveCount(1);
await expect(rows.nth(0)).toContainText('Analytics Manager');
await search.fill('');
await expect(rows).toHaveCount(3);
// Manager: Jane's two ADMIN-only documents are excluded from her row.
await apiSignout({ page });
await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('Jane Analytics');
await expectMemberRow(rows.nth(0), { sent: '3', completed: '2', pending: '1', completionRate: '67%' });
});
test('[ANALYTICS]: member activity previews 8 members and can show all', async ({ page }) => {
// 8 organisation members inherited into the team + the owner = 9 team members.
const { team, owner } = await seedTeam({ createTeamMembers: 8 });
const rows = page.getByTestId('analytics-member-row');
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-member-summary')).toContainText('9 members');
await expect(rows).toHaveCount(8);
await page.getByTestId('analytics-member-show-all').click();
await expect(rows).toHaveCount(9);
await expect(page.getByTestId('analytics-member-show-all')).toHaveCount(0);
});
test('[ANALYTICS]: members and unauthenticated users cannot access analytics', async ({ page }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({
teamId: team.id,
name: 'Analytics Member',
role: TeamMemberRole.MEMBER,
});
const documentsPathPattern = new RegExp(`/t/${team.url}/documents(?:\\?.*)?$`);
// Unauthenticated: the page redirects to sign in and the API rejects the call.
await page.goto(analyticsPath(team.url));
await page.waitForURL(/\/signin(?:\?.*)?$/);
const unauthenticatedResponse = await requestOverview(page, team.id);
expect(unauthenticatedResponse.status()).toBe(401);
// Member: no nav link, redirected away from the page, API rejects the calls.
await apiSignin({ page, email: member.email, redirectPath: `/t/${team.url}/documents` });
await page.waitForURL(documentsPathPattern);
await page.getByTestId('menu-switcher').click();
// Anchor on an item every user sees, so the absence check can't pass on an unopened menu.
await expect(page.getByRole('menuitem', { name: 'Inbox', exact: true })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Analytics', exact: true })).toHaveCount(0);
await page.keyboard.press('Escape');
await page.goto(analyticsPath(team.url));
await page.waitForURL(documentsPathPattern);
const memberOverviewResponse = await requestOverview(page, team.id);
expect(memberOverviewResponse.status()).toBe(401);
const memberActivityResponse = await requestMemberActivity(page, team.id);
expect(memberActivityResponse.status()).toBe(401);
await apiSignout({ page });
// Non-member denial: a user outside the team's organisation gets "Team not found", not a crash,
// and is rejected by the API.
const { user: nonMember } = await seedUser();
await apiSignin({ page, email: nonMember.email });
await page.goto(analyticsPath(team.url));
await page.waitForURL(documentsPathPattern);
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
const nonMemberResponse = await requestOverview(page, team.id);
expect(nonMemberResponse.status()).toBe(401);
await apiSignout({ page });
// Admin: the menu switcher item leads to the analytics page.
await apiSignin({ page, email: owner.email, redirectPath: `/t/${team.url}/documents` });
await page.getByTestId('menu-switcher').click();
await page.getByRole('menuitem', { name: 'Analytics', exact: true }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/analytics(?:\\?.*)?$`));
await waitForAnalytics(page);
const adminResponse = await requestOverview(page, team.id);
expect(adminResponse.ok()).toBe(true);
});
test('[ANALYTICS]: an empty team renders empty states without errors', async ({ page }) => {
const { team, owner } = await seedTeam();
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('0');
await expect(page.getByTestId('analytics-sent-delta')).toHaveCount(0);
await expect(page.getByTestId('analytics-completion-rate')).toHaveText('—');
await expect(page.getByTestId('analytics-completion-rate-delta')).toHaveCount(0);
await expect(page.getByTestId('analytics-members')).toHaveText('0/1');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('0 total');
await expect(page.getByTestId('analytics-status-completed')).toHaveCount(0);
await expect(page.getByTestId('analytics-template-row')).toHaveCount(0);
// The zero-row checks above would also pass if a card errored, so assert that none did.
await expect(page.getByTestId('analytics-error')).toHaveCount(0);
});
/**
* Seed a team document with an optional DOCUMENT_SENT audit log, recipient and
* source template.
*/
const seedAnalyticsDocument = async ({
owner,
teamId,
status,
visibility = DocumentVisibility.EVERYONE,
sentAt,
recipientEmail,
templateSecondaryId,
createdAt,
deletedAt,
}: {
owner: User;
teamId: number;
status: DocumentStatus;
visibility?: DocumentVisibility;
sentAt?: Date;
recipientEmail?: string;
templateSecondaryId?: string;
createdAt?: Date;
deletedAt?: Date;
}) => {
const envelope = await seedBlankDocument(owner, teamId, {
createDocumentOptions: {
status,
visibility,
createdAt,
deletedAt,
...(status === DocumentStatus.COMPLETED && sentAt ? { completedAt: sentAt } : {}),
...(templateSecondaryId ? { templateId: mapSecondaryIdToTemplateId(templateSecondaryId) } : {}),
},
});
if (sentAt) {
await prisma.documentAuditLog.createMany({
data: [
{
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: sentAt,
data: {},
},
],
});
}
if (recipientEmail) {
await prisma.recipient.create({
data: {
envelopeId: envelope.id,
email: recipientEmail,
name: 'Analytics Recipient',
token: Math.random().toString().slice(2, 12),
},
});
}
return envelope;
};
const analyticsPath = (teamUrl: string, range?: AnalyticsRange) => {
const path = `/t/${teamUrl}/analytics`;
return range ? `${path}?range=${range}` : path;
};
const customAnalyticsPath = (teamUrl: string, from: string, to: string) => {
return `${analyticsPath(teamUrl)}?range=custom&from=${from}&to=${to}`;
};
/**
* The analytics queries only run after hydration, which can be slow on a cold dev
* server, so wait for the hydrate fallback to be replaced before asserting values.
*/
const waitForAnalytics = async (page: Page) => {
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('analytics-loading')).toHaveCount(0, { timeout: 30_000 });
};
const selectRange = async (page: Page, label: string) => {
await page.getByTestId('analytics-range').click();
await page.getByRole('option', { name: label, exact: true }).click();
};
const expectRangeParam = async (page: Page, range: AnalyticsRange) => {
await expect.poll(() => new URL(page.url()).searchParams.get('range')).toBe(range);
};
const expectCustomRangeParams = async (page: Page, from: string, to: string) => {
await expect
.poll(() => {
const { searchParams } = new URL(page.url());
return { range: searchParams.get('range'), from: searchParams.get('from'), to: searchParams.get('to') };
})
.toEqual({ range: 'custom', from, to });
};
/**
* Click a yyyy-MM-dd day in the open range calendar. Each visible month renders a
* grid labelled by its caption (e.g. "September 2026"), so the day button is
* scoped to the matching grid to avoid hitting the same day number in the other month.
*/
const clickCalendarDay = async (page: Page, date: string) => {
const day = DateTime.fromISO(date).setLocale('en');
const monthGrid = page
.getByTestId('analytics-range-calendar')
.getByRole('grid', { name: day.toFormat('LLLL yyyy'), exact: true });
await monthGrid.getByRole('gridcell', { name: String(day.day), exact: true }).click();
};
const expectMemberRow = async (
row: Locator,
expected: { sent: string; completed: string; pending: string; completionRate: string },
) => {
await expect(row.getByTestId('analytics-member-sent')).toHaveText(expected.sent);
await expect(row.getByTestId('analytics-member-completed')).toHaveText(expected.completed);
await expect(row.getByTestId('analytics-member-pending')).toHaveText(expected.pending);
await expect(row.getByTestId('analytics-member-completion-rate')).toHaveText(expected.completionRate);
};
type AnalyticsRequestRange = { range: AnalyticsRange } | { range: 'custom'; from: string; to: string };
const requestAnalytics = async (
page: Page,
procedure: 'getOverview' | 'getMemberActivity' | 'getDocumentsOverTime',
teamId: number,
range: AnalyticsRequestRange = { range: '30d' },
timezone = 'UTC',
) => {
const input = encodeURIComponent(JSON.stringify({ json: { teamId, timezone, ...range } }));
return await page.context().request.get(`${WEBAPP_BASE_URL}/api/trpc/team.analytics.${procedure}?input=${input}`);
};
const requestOverview = async (page: Page, teamId: number, range?: AnalyticsRequestRange) => {
return await requestAnalytics(page, 'getOverview', teamId, range);
};
const requestMemberActivity = async (page: Page, teamId: number) => {
return await requestAnalytics(page, 'getMemberActivity', teamId);
};
/**
* Fetch documents over time in the host timezone, so bucket dates line up with
* `daysAgoDate` and the `createdAt` of documents seeded with `daysAgo`.
*/
const requestDocumentsOverTime = async (page: Page, teamId: number, range: AnalyticsRequestRange) => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const response = await requestAnalytics(page, 'getDocumentsOverTime', teamId, range, timezone);
expect(response.ok()).toBe(true);
const body: { result: { data: { json: Pick<TGetTeamAnalyticsDocumentsOverTimeResponse, 'points'> } } } =
await response.json();
return body.result.data.json;
};
@@ -40,88 +40,6 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
test('[TEMPLATES]: search templates by title', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedTemplate({
title: 'Quarterly Report Template',
userId: owner.id,
teamId: team.id,
});
await seedTemplate({
title: 'Annual Budget Template',
userId: owner.id,
teamId: team.id,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
await page.getByPlaceholder('Search templates...').fill('Quarterly');
await page.waitForURL(/query=Quarterly/);
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
await expect(page.getByRole('link', { name: 'Quarterly Report Template' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Annual Budget Template' })).not.toBeVisible();
// Clearing the search should restore the full list and drop the URL param.
await page.getByPlaceholder('Search templates...').fill('');
await page.waitForURL((url) => !url.searchParams.has('query'));
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
test('[TEMPLATES]: filter templates by owner', async ({ page }) => {
const { team, owner } = await seedTeam();
const teamMemberUser = await seedTeamMember({
teamId: team.id,
name: 'Filter Member',
role: TeamMemberRole.MEMBER,
});
await seedTemplate({
title: 'Owner Template',
userId: owner.id,
teamId: team.id,
});
await seedTemplate({
title: 'Member Template',
userId: teamMemberUser.id,
teamId: team.id,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
await page.getByTestId('templates-table-owner-filter').click();
await page.getByRole('option', { name: 'Filter Member' }).click();
await page.waitForURL(/ownerIds=/);
await page.keyboard.press('Escape');
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
await expect(page.getByRole('link', { name: 'Member Template' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Owner Template' })).not.toBeVisible();
// Reset should clear the owner filter.
await page.getByRole('button', { name: 'Reset' }).click();
await page.waitForURL((url) => !url.searchParams.has('ownerIds'));
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
test('[TEMPLATES]: delete template', async ({ page }) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
@@ -238,8 +156,8 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Get input with Email label placeholder.
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(teamMemberUser.email);
await page.getByRole('textbox', { name: 'Name', exact: true }).click();
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('name');
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
@@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record<string,
return { res, json: res.ok() ? await res.json() : null };
};
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
test.describe('Organisation Templates - UI View Filter', () => {
test('should show the view filter for non-personal orgs', async ({ page }) => {
test.describe('Organisation Templates - UI Tabs', () => {
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
const { ownerA, teamA } = await seedOrgTemplateScenario();
await apiSignin({
@@ -110,10 +110,11 @@ test.describe('Organisation Templates - UI View Filter', () => {
redirectPath: `/t/${teamA.url}/templates`,
});
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
await expect(page.getByTestId('template-tab-team')).toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
});
test('should not show the view filter for personal organisations', async ({ page }) => {
test('should not show tabs for personal organisations', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
@@ -122,14 +123,15 @@ test.describe('Organisation Templates - UI View Filter', () => {
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
});
});
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
test('should list org templates from other teams under the organisation view', async ({ page }) => {
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
@@ -138,30 +140,17 @@ test.describe('Organisation Templates - Listing', () => {
redirectPath: `/t/${teamB.url}/templates`,
});
// Team view is active by default (memberB has no templates on teamB).
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
// Team tab should show 0 (memberB has no templates on teamB).
await expect(page.getByTestId('template-tab-team')).toBeVisible();
// Switch to the organisation view.
await page.getByTestId('templates-table-view-filter').click();
await page.getByRole('option', { name: 'Organization' }).click();
// Switch to Organisation tab.
await page.getByTestId('template-tab-organisation').click();
// Should see the org template from teamA.
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should use default pagination when URL values are zero', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates?view=organisation&page=0&perPage=0`,
});
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should not show private templates from other teams under the organisation view', async ({ page }) => {
test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
// Create a private template on teamA — should NOT appear in org tab.
@@ -1,514 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { prisma } from '@documenso/prisma';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { APIResponse, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentVisibility, FolderType, TeamMemberRole, TemplateType } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
type FindTemplatesResult = {
data: Array<{ title: string; userId: number; teamId: number }>;
count: number;
};
type TrpcResponse = {
response: APIResponse;
result: FindTemplatesResult | null;
};
const trpcQuery = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
): Promise<TrpcResponse> => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { page: 1, perPage: 50, ...input } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
const headers: Record<string, string> = teamId ? { 'x-team-id': teamId.toString() } : {};
const response = await page.context().request.get(url, { headers });
const json = response.ok() ? await response.json() : null;
const result: FindTemplatesResult | null = json ? json.result.data.json : null;
return { response, result };
};
const FIND_TEMPLATE_PROCEDURES = ['template.findTemplates', 'template.findTemplatesInternal'] as const;
const titlesOf = (res: TrpcResponse) => (res.result?.data ?? []).map((row) => row.title);
const expectRejected = (res: TrpcResponse, status: number) => {
expect(res.response.status()).toBe(status);
expect(res.result).toBeNull();
};
// Check both count and data so one cannot be wrong while the other looks fine.
const expectNoResults = (res: TrpcResponse) => {
expect(res.response.ok()).toBeTruthy();
expect(res.result?.count).toBe(0);
expect(res.result?.data).toEqual([]);
};
const expectExactTitles = (res: TrpcResponse, titles: string[], teamId: number) => {
expect(res.response.ok()).toBeTruthy();
expect(res.result?.count).toBe(titles.length);
expect(titlesOf(res).sort()).toEqual([...titles].sort());
expect(res.result?.data.every((row) => row.teamId === teamId)).toBe(true);
};
/**
* Org A has two teams. teamA is the default team, so every org member is in it.
* teamB was created with inheritMembers: false, so only people added directly are in it.
* - ownerA: org owner and teamA admin. Owns all the templates below.
* - memberA, managerA: member and manager of teamA. Not in teamB.
* - memberB: member of teamB, and also a member of teamA because teamA inherits.
* - teamA has four templates with the same suffix in the title, one per visibility:
* everyone, manager, admin (with a unique externalId and recipient email), and org.
*
* Org B is a separate org owned by "outsider". No overlap with Org A.
*
* So: memberB with a teamB header checks that teamA rows never come back.
* memberA with a teamB header checks that non-members are rejected.
*/
const seedScenario = async () => {
const { user: ownerA, organisation, team: teamA } = await seedUser();
const teamBUrl = `team-b-${nanoid()}`;
await createTeam({
userId: ownerA.id,
teamName: `Team B ${teamBUrl}`,
teamUrl: teamBUrl,
organisationId: organisation.id,
inheritMembers: false,
});
const teamB = await prisma.team.findFirstOrThrow({ where: { url: teamBUrl } });
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
const managerA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MANAGER });
const memberB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MEMBER });
const { user: outsider, team: outsiderTeam } = await seedUser();
const suffix = nanoid();
const hiddenRecipientEmail = `hidden-recipient-${suffix}@example.com`;
const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Everyone Template ${suffix}`,
visibility: DocumentVisibility.EVERYONE,
},
});
const managerTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Manager Template ${suffix}`,
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
});
const adminTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Admin Only Template ${suffix}`,
externalId: `admin-external-${suffix}`,
visibility: DocumentVisibility.ADMIN,
recipients: {
create: {
email: hiddenRecipientEmail,
name: `Hidden Recipient ${suffix}`,
token: nanoid(),
},
},
},
});
const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Org Template ${suffix}`,
templateType: TemplateType.ORGANISATION,
visibility: DocumentVisibility.EVERYONE,
},
});
const allTitles = [everyoneTemplate.title, orgTemplate.title, managerTemplate.title, adminTemplate.title];
// Which templates each role on teamA should see.
const visibilityMatrix = [
{ caller: memberA, visible: [everyoneTemplate.title, orgTemplate.title] },
{ caller: managerA, visible: [everyoneTemplate.title, orgTemplate.title, managerTemplate.title] },
{ caller: ownerA, visible: allTitles },
];
return {
ownerA,
memberA,
managerA,
memberB,
teamA,
teamB,
outsider,
outsiderTeam,
suffix,
everyoneTemplate,
managerTemplate,
adminTemplate,
orgTemplate,
hiddenRecipientEmail,
allTitles,
visibilityMatrix,
};
};
// ─── Not logged in, or using a team header for a team you are not in ─────────
test.describe('Find Templates API - Adversarial: Auth and Team Header', () => {
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
test(`${procedure}: should reject unauthenticated requests`, async ({ page }) => {
const { teamA } = await seedScenario();
const res = await trpcQuery(page, procedure, {}, teamA.id);
expectRejected(res, 401);
});
test(`${procedure}: should reject a team header for a team the user is not in`, async ({ page }) => {
const { memberA, teamA, teamB, outsider } = await seedScenario();
const adminA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN });
const cases = [
{ name: 'org member, not in team', caller: memberA, teamId: teamB.id },
{ name: 'admin of another team', caller: adminA, teamId: teamB.id },
{ name: 'other organisation', caller: outsider, teamId: teamA.id },
{ name: 'no team header', caller: memberA, teamId: undefined },
];
for (const { caller, teamId } of cases) {
await apiSignin({ page, email: caller.email });
const res = await trpcQuery(page, procedure, {}, teamId);
expectRejected(res, 404);
await apiSignout({ page });
}
});
}
test('findOrganisationTemplates: should reject a team header for a team in another org', async ({ page }) => {
const { outsider, teamA } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const res = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamA.id);
expectRejected(res, 404);
});
});
// ─── Search must not find templates you cannot see ──────────────────────────
test.describe('Find Templates API - Adversarial: Search', () => {
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
test(`${procedure}: search must not find templates hidden by role`, async ({ page }) => {
const { memberA, teamA, adminTemplate, hiddenRecipientEmail } = await seedScenario();
await apiSignin({ page, email: memberA.email });
for (const query of [adminTemplate.title, adminTemplate.externalId, hiddenRecipientEmail]) {
const res = await trpcQuery(page, procedure, { query }, teamA.id);
expectNoResults(res);
}
});
test(`${procedure}: search must not find templates from another team`, async ({ page }) => {
const { memberB, teamB, everyoneTemplate } = await seedScenario();
await apiSignin({ page, email: memberB.email });
const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, teamB.id);
expectNoResults(res);
});
test(`${procedure}: search must not find templates from another org`, async ({ page }) => {
const { outsider, outsiderTeam, everyoneTemplate } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, outsiderTeam.id);
expectNoResults(res);
});
test(`${procedure}: list and search show only what each role is allowed to see`, async ({ page }) => {
const { teamA, suffix, allTitles, visibilityMatrix } = await seedScenario();
for (const { caller, visible } of visibilityMatrix) {
await apiSignin({ page, email: caller.email });
const listRes = await trpcQuery(page, procedure, {}, teamA.id);
expectExactTitles(listRes, visible, teamA.id);
const searchRes = await trpcQuery(page, procedure, { query: suffix }, teamA.id);
expectExactTitles(searchRes, visible, teamA.id);
for (const hidden of allTitles.filter((title) => !visible.includes(title))) {
const res = await trpcQuery(page, procedure, { query: hidden }, teamA.id);
expectNoResults(res);
}
await apiSignout({ page });
}
});
test(`${procedure}: wildcard search must not show more than allowed`, async ({ page }) => {
const { memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario();
await apiSignin({ page, email: memberA.email });
// % and _ are not escaped, so these match everything you are allowed to see.
for (const query of ['%', '_', '%%%']) {
const res = await trpcQuery(page, procedure, { query }, teamA.id);
expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id);
}
for (const query of ['%Admin Only%', '%Manager%']) {
const res = await trpcQuery(page, procedure, { query }, teamA.id);
expectNoResults(res);
}
});
}
test('findOrganisationTemplates: search must not find templates from another org', async ({ page }) => {
const { outsider, outsiderTeam, orgTemplate } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const res = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ query: orgTemplate.title },
outsiderTeam.id,
);
expectNoResults(res);
});
test('findOrganisationTemplates: search must not find team templates from another team', async ({ page }) => {
const { memberB, teamB, everyoneTemplate, managerTemplate, adminTemplate } = await seedScenario();
await apiSignin({ page, email: memberB.email });
for (const { title } of [everyoneTemplate, managerTemplate, adminTemplate]) {
const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: title }, teamB.id);
expectNoResults(res);
}
});
test('findOrganisationTemplates: shows only what the user role on the requesting team allows', async ({ page }) => {
const { ownerA, teamA, teamB, memberB, orgTemplate } = await seedScenario();
const managerOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Manager Org Template ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
});
const adminOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Admin Org Template ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: DocumentVisibility.ADMIN,
},
});
const managerB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MANAGER });
const allOrgTitles = [orgTemplate.title, managerOrgTemplate.title, adminOrgTemplate.title];
const matrix = [
{ caller: memberB, visible: [orgTemplate.title] },
{ caller: managerB, visible: [orgTemplate.title, managerOrgTemplate.title] },
];
for (const { caller, visible } of matrix) {
await apiSignin({ page, email: caller.email });
const listRes = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamB.id);
expectExactTitles(listRes, visible, teamA.id);
for (const hidden of allOrgTitles.filter((title) => !visible.includes(title))) {
const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: hidden }, teamB.id);
expectNoResults(res);
}
await apiSignout({ page });
}
});
});
// ─── Owner filter must not reach other teams or skip visibility checks ───────
test.describe('Find Templates API - Adversarial: Owner Filter', () => {
const procedure = 'template.findTemplatesInternal';
test('owner filter must not find templates from another team', async ({ page }) => {
const { ownerA, memberB, teamB } = await seedScenario();
await apiSignin({ page, email: memberB.email });
const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, teamB.id);
expectNoResults(res);
});
test('owner filter must not find templates from another org', async ({ page }) => {
const { ownerA, outsider, outsiderTeam } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, outsiderTeam.id);
expectNoResults(res);
});
test('owning a template only makes it visible in its own team', async ({ page }) => {
const { memberB, teamA, teamB } = await seedScenario();
// memberB is in both teams and owns an admin-only template in each.
// They can only see these because they own them. That must not cross teams.
const ownedOnA = await seedBlankTemplate(memberB, teamA.id, {
createTemplateOptions: { title: `Owned On A ${nanoid()}`, visibility: DocumentVisibility.ADMIN },
});
const ownedOnB = await seedBlankTemplate(memberB, teamB.id, {
createTemplateOptions: { title: `Owned On B ${nanoid()}`, visibility: DocumentVisibility.ADMIN },
});
await apiSignin({ page, email: memberB.email });
const inputs = [
{},
{ ownerIds: [memberB.id] },
{ query: 'Owned On' },
{ ownerIds: [memberB.id], query: 'Owned On' },
];
for (const input of inputs) {
// teamB has no other templates, so this is the only row.
const fromB = await trpcQuery(page, procedure, input, teamB.id);
expectExactTitles(fromB, [ownedOnB.title], teamB.id);
const fromA = await trpcQuery(page, procedure, input, teamA.id);
expect(fromA.response.ok()).toBeTruthy();
expect(titlesOf(fromA)).toContain(ownedOnA.title);
expect(titlesOf(fromA)).not.toContain(ownedOnB.title);
}
});
test('owner filter shows only what each role is allowed to see', async ({ page }) => {
const { ownerA, teamA, suffix, allTitles, visibilityMatrix } = await seedScenario();
const ownerIds = [ownerA.id];
for (const { caller, visible } of visibilityMatrix) {
await apiSignin({ page, email: caller.email });
const listRes = await trpcQuery(page, procedure, { ownerIds }, teamA.id);
expectExactTitles(listRes, visible, teamA.id);
const searchRes = await trpcQuery(page, procedure, { ownerIds, query: suffix }, teamA.id);
expectExactTitles(searchRes, visible, teamA.id);
for (const hidden of allTitles.filter((title) => !visible.includes(title))) {
const res = await trpcQuery(page, procedure, { ownerIds, query: hidden }, teamA.id);
expectNoResults(res);
}
await apiSignout({ page });
}
});
test('owner filter with unknown user ids returns nothing', async ({ page }) => {
const { ownerA, teamA } = await seedScenario();
await apiSignin({ page, email: ownerA.email });
const res = await trpcQuery(page, procedure, { ownerIds: [-1, 999999999] }, teamA.id);
expectNoResults(res);
});
test('owner filter is ignored by the public findTemplates route', async ({ page }) => {
const { ownerA, memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario();
await apiSignin({ page, email: memberA.email });
// The public route does not accept ownerIds. It should be dropped, not error.
const res = await trpcQuery(page, 'template.findTemplates', { ownerIds: [ownerA.id] }, teamA.id);
expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id);
});
});
// ─── Folder filter must not reach other teams ────────────────────────────────
test.describe('Find Templates API - Adversarial: Folder Filter', () => {
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
test(`${procedure}: folder filter must not find folders from another team`, async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedScenario();
const folderA = await seedBlankFolder(ownerA, teamA.id, {
createFolderOptions: { type: FolderType.TEMPLATE },
});
await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Foldered Template ${nanoid()}`,
visibility: DocumentVisibility.EVERYONE,
folderId: folderA.id,
},
});
await apiSignin({ page, email: memberB.email });
const res = await trpcQuery(page, procedure, { folderId: folderA.id }, teamB.id);
expectNoResults(res);
});
}
});
@@ -0,0 +1,64 @@
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { UserSecurityAuditLogType } from '@prisma/client';
import { checkSessionValid } from '../fixtures/authentication';
import { seedUserTwoFactorAuthentication } from '../fixtures/two-factor';
test('[USER] backup code sign-in resets 2FA and lands on the re-enrolment page', async ({ page }) => {
const { user } = await seedUser();
const { backupCodes } = await seedUserTwoFactorAuthentication({ userId: user.id });
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
// The missing second factor opens the 2FA dialog.
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Two-Factor Authentication')).toBeVisible();
await dialog.getByRole('button', { name: 'Use Backup Code' }).click();
await dialog.getByLabel('Backup Code').fill(backupCodes[0]);
await dialog.getByRole('button', { name: 'Sign In' }).click();
// Backup codes are recovery, not sign-in: the server resets 2FA and the
// client is redirected to the forced re-enrolment page.
await page.waitForURL(/\/onboarding\/2fa/);
await expect(page.getByRole('heading', { name: 'Two-factor authentication' })).toBeVisible();
// Enforcement is not active for this user, so the page offers an explicit
// skip link instead of auto-redirecting away.
await expect(page.getByRole('link', { name: 'Skip for now' })).toBeVisible();
// The recovery sign-in still authorizes a session.
expect(await checkSessionValid(page)).toBe(true);
// The reset is atomic: 2FA disabled, secret and backup codes cleared.
const updatedUser = await prisma.user.findFirstOrThrow({
where: {
id: user.id,
},
});
expect(updatedUser.twoFactorEnabled).toBe(false);
expect(updatedUser.twoFactorSecret).toBeNull();
expect(updatedUser.twoFactorBackupCodes).toBeNull();
// The recovery reset is audit-logged.
const auditLog = await prisma.userSecurityAuditLog.findFirst({
where: {
userId: user.id,
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
},
});
expect(auditLog).not.toBeNull();
// A consumed backup code cannot be used to sign in again: 2FA is no longer
// enabled, so a plain password sign-in succeeds and re-enrolment starts
// from scratch.
});
+71 -6
View File
@@ -5,13 +5,14 @@ import { hc } from 'hono/client';
import superjson from 'superjson';
import type { AuthAppType } from '../server';
import type { SessionValidationResult } from '../server/lib/session/session';
import type { PartialAccount } from '../server/lib/utils/get-accounts';
import type { ActiveSession } from '../server/lib/utils/get-session';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
import type { TSessionJsonResponse } from '../server/routes/session';
import type {
TDisableTwoFactorRequestSchema,
TEnableTwoFactorRequestSchema,
TVerifyTwoFactorChallengeRequestSchema,
TViewTwoFactorRecoveryCodesRequestSchema,
} from '../server/routes/two-factor.types';
import type {
@@ -29,9 +30,8 @@ type TEmailPasswordSignin = InferRequestType<AuthClientType['email-password']['a
redirectPath?: string;
};
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
redirectPath?: string;
};
// `redirectPath` is part of the request schema and validated server-side.
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'];
export class AuthClient {
public client: AuthClientType;
@@ -71,7 +71,7 @@ export class AuthClient {
const result = await response.json();
return superjson.deserialize<SessionValidationResult>(result);
return superjson.deserialize<TSessionJsonResponse>(result);
}
public async getSessions() {
@@ -146,6 +146,20 @@ export class AuthClient {
throw AppError.parseError(error);
}
const result = await response.json();
// The server overrides the redirect when the sign-in requires a
// follow-up page, e.g. a backup-code sign-in resets 2FA and lands on
// the re-enrolment page. The caller's redirect path is preserved as
// `returnTo` (validated by the target page before use).
if (result.redirectPath) {
const returnTo = data.redirectPath ? `?returnTo=${encodeURIComponent(data.redirectPath)}` : '';
handleSignInRedirect(`${result.redirectPath}${returnTo}`);
return;
}
handleSignInRedirect(data.redirectPath);
},
@@ -245,6 +259,8 @@ export class AuthClient {
throw AppError.parseError(error);
}
return response.json();
},
viewRecoveryCodes: async (data: TViewTwoFactorRecoveryCodesRequestSchema) => {
const response = await this.client['two-factor']['view-recovery-codes'].$post({ json: data });
@@ -257,6 +273,51 @@ export class AuthClient {
return response.json();
},
/**
* Check whether a pending 2FA challenge exists for this browser, so the
* /2fa-challenge page can bounce back to sign-in when there is none.
*/
getChallenge: async () => {
const response = await this.client['two-factor'].challenge.$get();
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
return response.json();
},
/**
* Verify the second factor for a pending 2FA challenge. Fetches a fresh
* CSRF token first since the challenge page is reached via a 302
* redirect, not the sign-in form.
*/
verifyChallenge: async (data: Omit<TVerifyTwoFactorChallengeRequestSchema, 'csrfToken'>) => {
const { csrfToken } = await this.client.csrf.$get().then(async (res) => res.json());
const response = await this.client['two-factor'].challenge.$post({
json: {
...data,
csrfToken,
},
});
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
const result = await response.json();
// The server returns the validated redirect path stored when the
// challenge was created (or the re-enrolment page after a backup-code
// recovery). Navigation goes through the same-origin redirect helper.
handleSignInRedirect(result.redirectPath);
},
};
public passkey = {
@@ -269,7 +330,11 @@ export class AuthClient {
throw AppError.parseError(error);
}
handleSignInRedirect(data.redirectPath);
const result = await response.json();
// The server validates the requested redirect path and echoes back a
// safe same-origin path (falling back to `/`).
handleSignInRedirect(result.url);
},
};
@@ -17,6 +17,9 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
// A pending 2FA challenge is missing, expired, or exhausted — the client
// must restart the sign-in flow.
TwoFactorChallengeExpired: 'TWO_FACTOR_CHALLENGE_EXPIRED',
SigninDisabled: 'SIGNIN_DISABLED',
SignupDisabled: 'SIGNUP_DISABLED',
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
@@ -11,7 +11,7 @@ import { generateSessionToken } from './session';
export const sessionCookieName = formatSecureCookieName('sessionId');
export const csrfCookieName = formatSecureCookieName('csrfToken');
const getAuthSecret = () => {
export const getAuthSecret = () => {
const authSecret = env('NEXTAUTH_SECRET');
if (!authSecret) {

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