mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
586b1f5cb5 | ||
|
|
a1d4bec143 | ||
|
|
39ae85483a | ||
|
|
638e92d534 | ||
|
|
c81bc72c4c | ||
|
|
1164d9578e | ||
|
|
e658cc5818 | ||
|
|
0c6249744a | ||
|
|
cf326f825d | ||
|
|
da934c01a6 | ||
|
|
0693a4195b | ||
|
|
2618d4d4be | ||
|
|
c5a6ff42ee | ||
|
|
132c4b08c5 | ||
|
|
9542512ce9 | ||
|
|
0f71a8b67d | ||
|
|
b795883fc7 | ||
|
|
643954ddd1 | ||
|
|
a20e8a2376 |
@@ -80,6 +80,8 @@ NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
|
||||
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
|
||||
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
|
||||
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
|
||||
# OPTIONAL: Reason to embed in PDF signatures. Defaults to "Signed by Documenso".
|
||||
NEXT_PRIVATE_SIGNING_REASON=
|
||||
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
|
||||
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
|
||||
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
|
||||
|
||||
@@ -30,26 +30,6 @@ 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,6 +15,8 @@ This guide provides a comprehensive troubleshooting matrix for the standard erro
|
||||
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
|
||||
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
|
||||
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
|
||||
| `MISSING_ENV_VAR` | A required environment variable is not configured on the server (500). | Primarily affects self-hosted instances: set the environment variable named in the error message and restart. On Documenso Cloud, contact support. |
|
||||
| `MISSING_SIGNATURE_FIELD` | A signer has no signature field placed on the document (400). Returned when distributing an envelope. | Add at least one signature field for every recipient with a signing role before calling `/envelope/distribute`. |
|
||||
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
|
||||
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
|
||||
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
|
||||
@@ -37,7 +39,28 @@ The following errors occur when attempting to perform actions on an envelope tha
|
||||
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
|
||||
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
|
||||
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
|
||||
| `ENVELOPE_CANCELLED` | The action cannot be performed because the envelope was cancelled (400). | Create a new envelope if you need to restart the signing process. |
|
||||
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
|
||||
| `ENVELOPE_TSP_LOCKED` | An AES/QES envelope cannot be modified after it leaves the draft state (400). | Make changes while the envelope is in `DRAFT`, or create a new envelope. |
|
||||
|
||||
## CSC Signing Errors
|
||||
|
||||
These errors apply to Cloud Signature Consortium (CSC) signing flows.
|
||||
|
||||
| Error Code | Description | Recommended Action |
|
||||
| :--- | :--- | :--- |
|
||||
| `CSC_INSTANCE_MODE_MISMATCH` | The requested signature level does not match the instance's CSC mode (400). | Use the signature level supported by the instance's signing configuration. |
|
||||
| `CSC_UNLICENSED` | CSC signing is not licensed for this instance (403). | Enable the CSC signing license before retrying. |
|
||||
| `CSC_PROVIDER_INFO_FAILED` | The CSC provider's discovery request failed or returned unusable information (500). | Check the provider URL, availability, and OAuth configuration. |
|
||||
| `CSC_PROVIDER_NO_TSA` | A timestamp authority is unavailable or unusable for CSC signing (500). | Configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` and verify provider timestamp access. |
|
||||
| `CSC_CREDENTIAL_LIST_EMPTY` | The CSC provider returned no signing credentials for the authenticated user (400). | Enrol a signing credential with the provider, then authenticate again. |
|
||||
| `CSC_CERT_INVALID` | The selected signing certificate is missing, invalid, or outside its validity period (400). | Select or renew a valid certificate, then authenticate again. |
|
||||
| `CSC_ALGORITHM_REFUSED` | The signing credential uses an unsupported key or digest algorithm (400). | Select a credential that satisfies the instance's CSC algorithm policy. |
|
||||
| `CSC_SAD_EXPIRED_PRE_SIGN` | The signature activation data is missing, expired, or unreadable before signing (400). | Repeat the credential authorization flow. |
|
||||
| `CSC_TSP_TIMEOUT` | The trust service provider did not complete the signing request before the timeout (408). | Retry the signing request after checking provider availability. |
|
||||
| `CSC_EMBED_FAILED` | The returned CSC signature could not be embedded into the envelope items (400). | Restart the signing attempt. If it fails again, contact support. |
|
||||
| `CSC_BASE_DOCUMENT_MUTATED` | The document changed between signature preparation and signing (500). | Restart signing from the current envelope state. |
|
||||
| `CSC_REQUEST_FAILED` | A CSC provider request failed without a more specific CSC error (500). | Check provider availability and configuration, then retry. |
|
||||
|
||||
## See Also
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ description: Add signature and form fields to documents via API.
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Callout type="warn">
|
||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||
@@ -19,13 +21,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
| `secondaryId` | string | Secondary identifier for audit logs |
|
||||
| `type` | string | Field type (see [Field Types](#field-types)) |
|
||||
| `recipientId` | number | ID of the recipient assigned to this field |
|
||||
| `envelopeId` | number | ID of the parent envelope |
|
||||
| `envelopeId` | string | ID of the parent envelope |
|
||||
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
|
||||
| `page` | number | Page number (1-indexed) |
|
||||
| `positionX` | number | X coordinate as percentage (0-100) |
|
||||
| `positionY` | number | Y coordinate as percentage (0-100) |
|
||||
| `width` | number | Width as percentage of page (0-100) |
|
||||
| `height` | number | Height as percentage of page (0-100) |
|
||||
| `positionX` | string | X coordinate as percentage (0-100), a decimal serialized as a string |
|
||||
| `positionY` | string | Y coordinate as percentage (0-100), a decimal serialized as a string |
|
||||
| `width` | string | Width as percentage of page (0-100), a decimal serialized as a string |
|
||||
| `height` | string | Height as percentage of page (0-100), a decimal serialized as a string |
|
||||
| `customText` | string | Value entered by the recipient |
|
||||
| `inserted` | boolean | Whether the field has been completed |
|
||||
| `fieldMeta` | object \| null | Type-specific configuration options |
|
||||
@@ -38,18 +40,19 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
"secondaryId": "field_abc123",
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 123,
|
||||
"envelopeId": 789,
|
||||
"envelopeItemId": "envelope_item_xyz",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 80,
|
||||
"width": 30,
|
||||
"height": 5,
|
||||
"positionX": "10",
|
||||
"positionY": "80",
|
||||
"width": "30",
|
||||
"height": "5",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "signature",
|
||||
"required": true
|
||||
"required": true,
|
||||
"overflow": "auto"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -61,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
| Type | Description | Auto-filled |
|
||||
| ---------------- | ----------------------------------------- | ----------- |
|
||||
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
|
||||
| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
|
||||
| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No |
|
||||
| `INITIALS` | Recipient's initials | No |
|
||||
| `NAME` | Recipient's full name | Yes |
|
||||
| `EMAIL` | Recipient's email address | Yes |
|
||||
@@ -134,10 +137,12 @@ POST /envelope/field/create-many
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----------- | ------ | -------- | ------------------------------- |
|
||||
| `documentId`| number | Yes | The document ID |
|
||||
| `fields` | array | Yes | Array of field configurations |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ------------------------------- |
|
||||
| `envelopeId` | string | Yes | The envelope ID |
|
||||
| `data` | array | Yes | Array of field configurations |
|
||||
|
||||
Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`.
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -148,32 +153,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"documentId": 123,
|
||||
"fields": [
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"data": [
|
||||
{
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 456,
|
||||
"pageNumber": 1,
|
||||
"pageX": 10,
|
||||
"pageY": 80,
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 80,
|
||||
"width": 30,
|
||||
"height": 5
|
||||
},
|
||||
{
|
||||
"type": "DATE",
|
||||
"recipientId": 456,
|
||||
"pageNumber": 1,
|
||||
"pageX": 50,
|
||||
"pageY": 80,
|
||||
"page": 1,
|
||||
"positionX": 50,
|
||||
"positionY": 80,
|
||||
"width": 20,
|
||||
"height": 3
|
||||
},
|
||||
{
|
||||
"type": "TEXT",
|
||||
"recipientId": 456,
|
||||
"pageNumber": 1,
|
||||
"pageX": 10,
|
||||
"pageY": 70,
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 70,
|
||||
"width": 40,
|
||||
"height": 4,
|
||||
"fieldMeta": {
|
||||
@@ -199,32 +204,32 @@ const response = await fetch(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
documentId: 123,
|
||||
fields: [
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
data: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
recipientId: 456,
|
||||
pageNumber: 1,
|
||||
pageX: 10,
|
||||
pageY: 80,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 80,
|
||||
width: 30,
|
||||
height: 5,
|
||||
},
|
||||
{
|
||||
type: 'DATE',
|
||||
recipientId: 456,
|
||||
pageNumber: 1,
|
||||
pageX: 50,
|
||||
pageY: 80,
|
||||
page: 1,
|
||||
positionX: 50,
|
||||
positionY: 80,
|
||||
width: 20,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
recipientId: 456,
|
||||
pageNumber: 1,
|
||||
pageX: 10,
|
||||
pageY: 70,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 70,
|
||||
width: 40,
|
||||
height: 4,
|
||||
fieldMeta: {
|
||||
@@ -239,8 +244,8 @@ const response = await fetch(
|
||||
}
|
||||
);
|
||||
|
||||
const { fields } = await response.json();
|
||||
console.log(`Created ${fields.length} fields`);
|
||||
const { data } = await response.json();
|
||||
console.log(`Created ${data.length} fields`);
|
||||
|
||||
````
|
||||
</Tab>
|
||||
@@ -250,36 +255,68 @@ console.log(`Created ${fields.length} fields`);
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": [
|
||||
"data": [
|
||||
{
|
||||
"id": 101,
|
||||
"secondaryId": "field_abc123",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 456,
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 80,
|
||||
"width": 30,
|
||||
"height": 5
|
||||
"positionX": "10",
|
||||
"positionY": "80",
|
||||
"width": "30",
|
||||
"height": "5",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "signature",
|
||||
"fontSize": 18,
|
||||
"overflow": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"secondaryId": "field_def456",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||
"type": "DATE",
|
||||
"recipientId": 456,
|
||||
"page": 1,
|
||||
"positionX": 50,
|
||||
"positionY": 80,
|
||||
"width": 20,
|
||||
"height": 3
|
||||
"positionX": "50",
|
||||
"positionY": "80",
|
||||
"width": "20",
|
||||
"height": "3",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "date",
|
||||
"fontSize": 12,
|
||||
"textAlign": "left",
|
||||
"overflow": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"secondaryId": "field_ghi789",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||
"type": "TEXT",
|
||||
"recipientId": 456,
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 70,
|
||||
"width": 40,
|
||||
"height": 4
|
||||
"positionX": "10",
|
||||
"positionY": "70",
|
||||
"width": "40",
|
||||
"height": "4",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "text",
|
||||
"label": "Job Title",
|
||||
"placeholder": "Enter your job title",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -299,8 +336,10 @@ POST /envelope/field/update-many
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ----------------------------- |
|
||||
| `documentId` | number | Yes | The document ID |
|
||||
| `fields` | array | Yes | Array of field update objects |
|
||||
| `envelopeId` | string | Yes | The envelope ID |
|
||||
| `data` | array | Yes | Array of field update objects |
|
||||
|
||||
Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates.
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -311,17 +350,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"documentId": 123,
|
||||
"fields": [
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"data": [
|
||||
{
|
||||
"id": 101,
|
||||
"type": "SIGNATURE",
|
||||
"pageY": 85
|
||||
"positionY": 85
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"type": "DATE",
|
||||
"pageY": 85
|
||||
"positionY": 85
|
||||
}
|
||||
]
|
||||
}'
|
||||
@@ -338,16 +377,16 @@ const response = await fetch(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
documentId: 123,
|
||||
fields: [
|
||||
{ id: 101, type: 'SIGNATURE', pageY: 85 },
|
||||
{ id: 102, type: 'DATE', pageY: 85 },
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
data: [
|
||||
{ id: 101, type: 'SIGNATURE', positionY: 85 },
|
||||
{ id: 102, type: 'DATE', positionY: 85 },
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const { fields } = await response.json();
|
||||
const { data } = await response.json();
|
||||
|
||||
````
|
||||
</Tab>
|
||||
@@ -357,9 +396,48 @@ const { fields } = await response.json();
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": [
|
||||
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
|
||||
{ "id": 102, "type": "DATE", "positionY": 85 }
|
||||
"data": [
|
||||
{
|
||||
"id": 101,
|
||||
"secondaryId": "field_abc123",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 456,
|
||||
"page": 1,
|
||||
"positionX": "10",
|
||||
"positionY": "85",
|
||||
"width": "30",
|
||||
"height": "5",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "signature",
|
||||
"fontSize": 18,
|
||||
"overflow": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"secondaryId": "field_def456",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||
"type": "DATE",
|
||||
"recipientId": 456,
|
||||
"page": 1,
|
||||
"positionX": "50",
|
||||
"positionY": "85",
|
||||
"width": "20",
|
||||
"height": "3",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "date",
|
||||
"fontSize": 12,
|
||||
"textAlign": "left",
|
||||
"overflow": "auto"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
````
|
||||
@@ -443,8 +521,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
|
||||
(0,0) ─────────────────────────── (100,0)
|
||||
│ │
|
||||
│ ┌─────────┐ │
|
||||
│ │ Field │ (pageX: 10, │
|
||||
│ │ │ pageY: 20, │
|
||||
│ │ Field │ (positionX: 10, │
|
||||
│ │ │ positionY: 20, │
|
||||
│ └─────────┘ width: 30, │
|
||||
│ height: 5) │
|
||||
│ │
|
||||
@@ -457,9 +535,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
|
||||
const field = {
|
||||
type: 'SIGNATURE',
|
||||
recipientId: 123,
|
||||
pageNumber: 1,
|
||||
pageX: 60, // 60% from left
|
||||
pageY: 85, // 85% from top (near bottom)
|
||||
page: 1,
|
||||
positionX: 60, // 60% from left
|
||||
positionY: 85, // 85% from top (near bottom)
|
||||
width: 30, // 30% of page width
|
||||
height: 8, // 8% of page height
|
||||
};
|
||||
@@ -479,6 +557,33 @@ This approach is useful when generating PDFs programmatically or using templates
|
||||
|
||||
See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options.
|
||||
|
||||
### Placeholder Positioning via the API
|
||||
|
||||
`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. |
|
||||
| `width` | number | No | Override the field width. Defaults to the width of the matched text. |
|
||||
| `height` | number | No | Override the field height. Defaults to the height of the matched text. |
|
||||
| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. |
|
||||
|
||||
```json
|
||||
{
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"data": [
|
||||
{
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 456,
|
||||
"placeholder": "{{signature}}",
|
||||
"matchAll": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only.
|
||||
|
||||
---
|
||||
|
||||
## Field Meta Options
|
||||
@@ -643,15 +748,15 @@ All field types support these base options:
|
||||
Create a document with a signature block containing multiple field types:
|
||||
|
||||
```typescript
|
||||
async function addSignatureBlock(documentId: number, recipientId: number) {
|
||||
const fields = [
|
||||
async function addSignatureBlock(envelopeId: string, recipientId: number) {
|
||||
const data = [
|
||||
// Signature
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
recipientId,
|
||||
pageNumber: 1,
|
||||
pageX: 10,
|
||||
pageY: 80,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 80,
|
||||
width: 30,
|
||||
height: 8,
|
||||
fieldMeta: {
|
||||
@@ -663,9 +768,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
|
||||
{
|
||||
type: 'NAME',
|
||||
recipientId,
|
||||
pageNumber: 1,
|
||||
pageX: 10,
|
||||
pageY: 90,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 90,
|
||||
width: 30,
|
||||
height: 4,
|
||||
fieldMeta: {
|
||||
@@ -677,9 +782,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
|
||||
{
|
||||
type: 'DATE',
|
||||
recipientId,
|
||||
pageNumber: 1,
|
||||
pageX: 50,
|
||||
pageY: 80,
|
||||
page: 1,
|
||||
positionX: 50,
|
||||
positionY: 80,
|
||||
width: 20,
|
||||
height: 4,
|
||||
fieldMeta: {
|
||||
@@ -691,9 +796,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
|
||||
{
|
||||
type: 'TEXT',
|
||||
recipientId,
|
||||
pageNumber: 1,
|
||||
pageX: 50,
|
||||
pageY: 90,
|
||||
page: 1,
|
||||
positionX: 50,
|
||||
positionY: 90,
|
||||
width: 30,
|
||||
height: 4,
|
||||
fieldMeta: {
|
||||
@@ -710,7 +815,7 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ documentId, fields }),
|
||||
body: JSON.stringify({ envelopeId, data }),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
|
||||
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
|
||||
href="/docs/developers/api/templates"
|
||||
/>
|
||||
<Card
|
||||
title="Teams"
|
||||
description="Manage teams and team members."
|
||||
title="Team-scoped access"
|
||||
description="Use team-scoped API tokens with envelope endpoints."
|
||||
href="/docs/developers/api/teams"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
@@ -6,6 +6,8 @@ description: Add and manage envelope recipients via API.
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Callout type="warn">
|
||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||
@@ -16,7 +18,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -134,7 +136,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/create-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"data": [
|
||||
{
|
||||
"email": "signer@example.com",
|
||||
@@ -164,7 +166,7 @@ const response = await fetch(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
data: [
|
||||
{
|
||||
email: 'signer@example.com',
|
||||
@@ -196,7 +198,7 @@ const { data: recipients } = await response.json();
|
||||
"data": [
|
||||
{
|
||||
"id": 789,
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -209,7 +211,7 @@ const { data: recipients } = await response.json();
|
||||
},
|
||||
{
|
||||
"id": 790,
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "approver@example.com",
|
||||
"name": "Jane Smith",
|
||||
"role": "APPROVER",
|
||||
@@ -262,7 +264,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/update-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"data": [
|
||||
{
|
||||
"id": 789,
|
||||
@@ -284,7 +286,7 @@ const response = await fetch(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
data: [
|
||||
{
|
||||
id: 789,
|
||||
@@ -387,7 +389,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
data: [
|
||||
{
|
||||
email: 'approver@example.com',
|
||||
@@ -462,7 +464,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
data: [
|
||||
{
|
||||
email: 'signer@example.com',
|
||||
|
||||
@@ -1,44 +1,29 @@
|
||||
---
|
||||
title: Teams API
|
||||
description: Manage team resources, documents, and templates with team-scoped API tokens.
|
||||
title: Team-Scoped API Access
|
||||
description: Use team-scoped API tokens with document and template envelopes.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Callout type="warn">
|
||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||
</Callout>
|
||||
|
||||
## Team Object
|
||||
## Team Context
|
||||
|
||||
A team object contains the following properties:
|
||||
<Callout type="info">
|
||||
The V2 REST API does not expose `/team/*` endpoints. Create and manage teams, members, and team
|
||||
settings in the Documenso web application. This page explains how a team-scoped token applies
|
||||
that team context to supported API resources.
|
||||
</Callout>
|
||||
|
||||
| Property | Type | Description |
|
||||
| ----------------- | -------------- | --------------------------------------------------- |
|
||||
| `id` | number | Unique team identifier |
|
||||
| `name` | string | Team display name |
|
||||
| `url` | string | Unique team URL slug |
|
||||
| `createdAt` | string | ISO 8601 timestamp |
|
||||
| `avatarImageId` | string \| null | ID of the team's avatar image |
|
||||
| `organisationId` | string | ID of the parent organisation |
|
||||
| `currentTeamRole` | string | Your role in the team: `ADMIN`, `MANAGER`, `MEMBER` |
|
||||
|
||||
### Example Team Object
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"name": "Engineering",
|
||||
"url": "engineering",
|
||||
"createdAt": "2025-01-15T10:30:00.000Z",
|
||||
"avatarImageId": null,
|
||||
"organisationId": "org_abc123",
|
||||
"currentTeamRole": "ADMIN"
|
||||
}
|
||||
```
|
||||
The API resolves the team from your token. You do not pass a team ID when creating, listing, or
|
||||
using envelopes. The token's team ID determines which resources the request can access.
|
||||
|
||||
## Team-Scoped API Tokens
|
||||
|
||||
@@ -156,26 +141,26 @@ Retrieve all documents belonging to the team:
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
# List all team documents
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope" \
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
|
||||
# Filter by status
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: TEAM_API_TOKEN,
|
||||
},
|
||||
});
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
console.log(`Found ${pagination.totalItems} team documents`);
|
||||
const { data, count } = await response.json();
|
||||
console.log(`Found ${count} team documents`);
|
||||
|
||||
````
|
||||
</Tab>
|
||||
@@ -190,10 +175,11 @@ Templates created with a team token are shared across the team.
|
||||
<Tabs items={['curl', 'TypeScript']}>
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/template/create" \
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F 'payload={
|
||||
"type": "TEMPLATE",
|
||||
"title": "NDA Template",
|
||||
"recipients": [
|
||||
{
|
||||
@@ -223,6 +209,7 @@ curl -X POST "https://app.documenso.com/api/v2/template/create" \
|
||||
const form = new FormData();
|
||||
|
||||
const payload = {
|
||||
type: 'TEMPLATE',
|
||||
title: 'NDA Template',
|
||||
recipients: [
|
||||
{
|
||||
@@ -249,7 +236,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: TEAM_API_TOKEN,
|
||||
@@ -257,8 +244,8 @@ const response = await fetch('https://app.documenso.com/api/v2/template/create',
|
||||
body: form,
|
||||
});
|
||||
|
||||
const template = await response.json();
|
||||
console.log('Created team template:', template.id);
|
||||
const { id } = await response.json();
|
||||
console.log('Created team template envelope:', id);
|
||||
````
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@@ -268,14 +255,14 @@ console.log('Created team template:', template.id);
|
||||
<Tabs items={['curl', 'TypeScript']}>
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X GET "https://app.documenso.com/api/v2/template" \
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/template', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: TEAM_API_TOKEN,
|
||||
@@ -330,19 +317,19 @@ const SALES_TEAM_TOKEN = process.env.SALES_TEAM_API_TOKEN;
|
||||
const LEGAL_TEAM_TOKEN = process.env.LEGAL_TEAM_API_TOKEN;
|
||||
|
||||
// Get pending documents from sales team
|
||||
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=PENDING', {
|
||||
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING', {
|
||||
headers: { Authorization: SALES_TEAM_TOKEN },
|
||||
});
|
||||
const salesDocs = await salesResponse.json();
|
||||
|
||||
// Get completed documents from legal team
|
||||
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', {
|
||||
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=COMPLETED', {
|
||||
headers: { Authorization: LEGAL_TEAM_TOKEN },
|
||||
});
|
||||
const legalDocs = await legalResponse.json();
|
||||
|
||||
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
|
||||
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
|
||||
console.log(`Sales team: ${salesDocs.count} pending`);
|
||||
console.log(`Legal team: ${legalDocs.count} completed`);
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
@@ -13,7 +13,194 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||
</Callout>
|
||||
|
||||
## Template Object
|
||||
## Use a Template Envelope
|
||||
|
||||
New integrations should create a document from a template envelope with the Envelope API.
|
||||
|
||||
```
|
||||
POST /envelope/use
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
The request uses `multipart/form-data`:
|
||||
|
||||
| Part | Type | Required | Description |
|
||||
| --------- | ------- | -------- | ------------------------------------------------------------------ |
|
||||
| `payload` | JSON | Yes | Template envelope ID, recipient details, and document settings |
|
||||
| `files` | File(s) | No | Replacement PDFs referenced by entries in `customDocumentData` |
|
||||
|
||||
### Payload Schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| -------------------- | ------- | -------- | ------------------------------------------------------------------------ |
|
||||
| `envelopeId` | string | Yes | ID of the template envelope |
|
||||
| `externalId` | string | No | Your identifier for the created document envelope |
|
||||
| `recipients` | array | No | Recipient details mapped to recipients in the template |
|
||||
| `distributeDocument` | boolean | No | If `true`, create the document as pending and distribute it |
|
||||
| `customDocumentData` | array | No | Maps uploaded replacement PDFs to template envelope items |
|
||||
| `folderId` | string | No | Folder in which to create the document |
|
||||
| `prefillFields` | array | No | Field values to prefill before distribution |
|
||||
| `override` | object | No | Template values to override for the created document |
|
||||
| `attachments` | array | No | Link attachments to add to the document |
|
||||
| `formValues` | object | No | PDF form values to apply |
|
||||
|
||||
Each recipient entry accepts the following fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| -------------- | ------- | -------- | -------------------------------------------- |
|
||||
| `id` | number | Yes | Recipient ID from the template envelope |
|
||||
| `email` | string | Yes | Recipient email address |
|
||||
| `name` | string | No | Recipient display name |
|
||||
| `signingOrder` | number | No | Recipient position in sequential signing |
|
||||
|
||||
Each `customDocumentData` entry maps an uploaded file to a template item:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | ---------------- | -------- | --------------------------------------------------------------- |
|
||||
| `identifier` | string \| number | Yes | Uploaded filename or zero-based file index |
|
||||
| `envelopeItemId` | string | Yes | Template envelope item whose PDF the uploaded file replaces |
|
||||
|
||||
### Code Examples
|
||||
|
||||
<Tabs items={['curl', 'TypeScript']}>
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/use" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-F 'payload={
|
||||
"envelopeId": "envelope_template123",
|
||||
"externalId": "contract-2025-001",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 1,
|
||||
"email": "john.doe@example.com",
|
||||
"name": "John Doe"
|
||||
}
|
||||
],
|
||||
"prefillFields": [
|
||||
{
|
||||
"id": 101,
|
||||
"type": "text",
|
||||
"value": "Senior Software Engineer"
|
||||
}
|
||||
],
|
||||
"distributeDocument": false
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const form = new FormData();
|
||||
|
||||
form.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
envelopeId: 'envelope_template123',
|
||||
externalId: 'contract-2025-001',
|
||||
recipients: [
|
||||
{
|
||||
id: 1,
|
||||
email: 'john.doe@example.com',
|
||||
name: 'John Doe',
|
||||
},
|
||||
],
|
||||
prefillFields: [
|
||||
{
|
||||
id: 101,
|
||||
type: 'text',
|
||||
value: 'Senior Software Engineer',
|
||||
},
|
||||
],
|
||||
distributeDocument: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/use', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const document = await response.json();
|
||||
console.log('Created document envelope:', document.id);
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "envelope_document123",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "John Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"token": "recipient_token",
|
||||
"role": "SIGNER",
|
||||
"signingOrder": 1,
|
||||
"signingUrl": "https://app.documenso.com/sign/recipient_token"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Distribute the Created Envelope
|
||||
|
||||
If you leave `distributeDocument` unset or set it to `false`, distribute the created document with
|
||||
`POST /envelope/distribute`. Its response confirms delivery and includes each recipient's signing URL.
|
||||
|
||||
```typescript
|
||||
const distributionResponse = await fetch(
|
||||
'https://app.documenso.com/api/v2/envelope/distribute',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: document.id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const distribution = await distributionResponse.json();
|
||||
console.log('Signing URL:', distribution.recipients[0].signingUrl);
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"id": "envelope_document123",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "John Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"token": "recipient_token",
|
||||
"role": "SIGNER",
|
||||
"signingOrder": 1,
|
||||
"signingUrl": "https://app.documenso.com/sign/recipient_token"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deprecated Template Endpoint Reference
|
||||
|
||||
<Callout type="warn">
|
||||
Every `/template/*` endpoint below is deprecated. Use the Envelope API for new integrations and
|
||||
follow [Migrating to Envelopes](/docs/developers/api/migrate-to-envelopes) to replace existing calls.
|
||||
The legacy reference remains here to support migrations.
|
||||
</Callout>
|
||||
|
||||
## Legacy Template Object
|
||||
|
||||
A template object contains the following properties:
|
||||
|
||||
@@ -91,7 +278,7 @@ A template object contains the following properties:
|
||||
}
|
||||
```
|
||||
|
||||
## List Templates
|
||||
## List Templates (Deprecated)
|
||||
|
||||
Retrieve a paginated list of templates.
|
||||
|
||||
@@ -139,8 +326,8 @@ const response = await fetch(`${BASE_URL}/template`, {
|
||||
},
|
||||
});
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
console.log(`Found ${pagination.totalItems} templates`);
|
||||
const { data, count } = await response.json();
|
||||
console.log(`Found ${count} templates`);
|
||||
|
||||
// Filter by type
|
||||
const privateResponse = await fetch(
|
||||
@@ -181,18 +368,16 @@ const privateTemplates = await privateResponse.json();
|
||||
]
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 3,
|
||||
"totalItems": 25
|
||||
}
|
||||
"count": 25,
|
||||
"currentPage": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Template
|
||||
## Get Template (Deprecated)
|
||||
|
||||
Retrieve a single template by ID.
|
||||
|
||||
@@ -238,9 +423,9 @@ Returns the full template object including recipients, fields, and metadata.
|
||||
|
||||
---
|
||||
|
||||
## Create Document from Template
|
||||
## Create Document from Template (Deprecated)
|
||||
|
||||
Create a new document using a template. This is the primary way to use templates programmatically.
|
||||
Create a new document using the deprecated template endpoint.
|
||||
|
||||
<Callout type="info">
|
||||
This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning.
|
||||
@@ -415,32 +600,57 @@ const prefilledDocument = await prefillResponse.json();
|
||||
|
||||
### Response
|
||||
|
||||
Returns the created document object with recipients and signing URLs.
|
||||
The endpoint returns the full legacy document object. The selected fields below show both the numeric
|
||||
legacy `id` and canonical `envelopeId`. Recipient entries do not include a `signingUrl`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "envelope_xyz789",
|
||||
"type": "DOCUMENT",
|
||||
"id": 789,
|
||||
"envelopeId": "envelope_xyz789",
|
||||
"status": "PENDING",
|
||||
"title": "Employment Contract",
|
||||
"source": "TEMPLATE",
|
||||
"title": "Employment Contract",
|
||||
"externalId": "contract-2025-001",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 1,
|
||||
"envelopeId": "envelope_xyz789",
|
||||
"documentId": 789,
|
||||
"templateId": null,
|
||||
"email": "john.doe@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"signingUrl": "https://app.documenso.com/sign/abc123"
|
||||
"signingOrder": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
````
|
||||
```
|
||||
|
||||
To send a document created with `distributeDocument: false` and receive signing links, call
|
||||
`POST /envelope/distribute` with its `envelopeId`:
|
||||
|
||||
```typescript
|
||||
const document = await response.json();
|
||||
|
||||
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: document.envelopeId,
|
||||
}),
|
||||
});
|
||||
|
||||
const distribution = await distributionResponse.json();
|
||||
console.log('Signing URL:', distribution.recipients[0].signingUrl);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Override Template Settings
|
||||
## Override Template Settings (Deprecated)
|
||||
|
||||
When creating a document from a template, you can override various settings:
|
||||
|
||||
@@ -488,7 +698,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
||||
|
||||
---
|
||||
|
||||
## Prefill Fields
|
||||
## Prefill Fields (Deprecated)
|
||||
|
||||
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
|
||||
|
||||
@@ -577,7 +787,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
||||
|
||||
---
|
||||
|
||||
## Update Template
|
||||
## Update Template (Deprecated)
|
||||
|
||||
Update a template's properties.
|
||||
|
||||
@@ -643,7 +853,7 @@ const template = await response.json();
|
||||
|
||||
---
|
||||
|
||||
## Duplicate Template
|
||||
## Duplicate Template (Deprecated)
|
||||
|
||||
Create a copy of an existing template.
|
||||
|
||||
@@ -695,7 +905,7 @@ console.log('New template ID:', duplicatedTemplate.id);
|
||||
|
||||
---
|
||||
|
||||
## Delete Template
|
||||
## Delete Template (Deprecated)
|
||||
|
||||
Delete a template.
|
||||
|
||||
@@ -754,7 +964,7 @@ const { success } = await response.json();
|
||||
|
||||
---
|
||||
|
||||
## Direct Link Templates
|
||||
## Direct Link Templates (Deprecated)
|
||||
|
||||
Direct link templates allow recipients to create and sign documents without requiring you to explicitly create each document. When a recipient visits the direct link, a new document is automatically created from the template.
|
||||
|
||||
@@ -898,7 +1108,7 @@ const { success } = await response.json();
|
||||
|
||||
---
|
||||
|
||||
## Custom Document Data
|
||||
## Custom Document Data (Deprecated)
|
||||
|
||||
When creating a document from a template, you can replace the template's PDF with a custom PDF by using the `customDocumentData` parameter. This is useful when you need to generate the PDF dynamically while reusing the template's recipient and field configuration.
|
||||
|
||||
@@ -913,7 +1123,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
|
||||
|
||||
---
|
||||
|
||||
## Template Types
|
||||
## Template Types (Legacy)
|
||||
|
||||
| Type | Description |
|
||||
| --------- | ------------------------------------------------------------------ |
|
||||
@@ -922,7 +1132,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
|
||||
|
||||
---
|
||||
|
||||
## Complete Example: Contract Workflow
|
||||
## Complete Legacy Example: Contract Workflow (Deprecated)
|
||||
|
||||
This example demonstrates a complete workflow for using templates to send contracts.
|
||||
|
||||
@@ -996,16 +1206,29 @@ async function sendEmploymentContract(employeeData: {
|
||||
subject: `Employment Contract for ${employeeData.name}`,
|
||||
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
|
||||
},
|
||||
distributeDocument: true,
|
||||
distributeDocument: false,
|
||||
externalId: `emp-contract-${Date.now()}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const document = await documentResponse.json();
|
||||
|
||||
// 5. Distribute the envelope and get recipient signing links
|
||||
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: document.envelopeId,
|
||||
}),
|
||||
});
|
||||
const distribution = await distributionResponse.json();
|
||||
|
||||
return {
|
||||
documentId: document.id,
|
||||
signingUrl: document.recipients[0].signingUrl,
|
||||
envelopeId: document.envelopeId,
|
||||
signingUrl: distribution.recipients[0].signingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1018,7 +1241,7 @@ const result = await sendEmploymentContract({
|
||||
startDate: '2025-03-01',
|
||||
});
|
||||
|
||||
console.log('Document created:', result.documentId);
|
||||
console.log('Document created:', result.envelopeId);
|
||||
console.log('Signing URL:', result.signingUrl);
|
||||
````
|
||||
|
||||
|
||||
@@ -262,8 +262,11 @@ Use templates for repeatable document workflows. This example creates an employm
|
||||
Map template fields by label and build a <code>prefillFields</code> array
|
||||
</Step>
|
||||
<Step>
|
||||
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
|
||||
<code>distributeDocument: true</code>
|
||||
Call <code>POST /template/use</code> with recipients and prefill data
|
||||
</Step>
|
||||
<Step>
|
||||
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
|
||||
links
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -297,7 +300,7 @@ type TemplateRecipient = {
|
||||
async function sendEmploymentContract(
|
||||
templateId: number,
|
||||
employee: EmployeeData,
|
||||
): Promise<{ documentId: string; signingUrl: string }> {
|
||||
): Promise<{ documentId: number; signingUrl: string }> {
|
||||
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
|
||||
headers: { Authorization: API_TOKEN },
|
||||
});
|
||||
@@ -374,7 +377,6 @@ async function sendEmploymentContract(
|
||||
subject: `Your Employment Contract at ${employee.department}`,
|
||||
message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`,
|
||||
},
|
||||
distributeDocument: true,
|
||||
externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`,
|
||||
}),
|
||||
});
|
||||
@@ -386,9 +388,25 @@ async function sendEmploymentContract(
|
||||
|
||||
const document = await createResponse.json();
|
||||
|
||||
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId: document.envelopeId }),
|
||||
});
|
||||
|
||||
if (!distributeResponse.ok) {
|
||||
const error = await distributeResponse.json();
|
||||
throw new Error(`Failed to send document: ${error.message}`);
|
||||
}
|
||||
|
||||
const distributeResult = await distributeResponse.json();
|
||||
|
||||
return {
|
||||
documentId: document.id,
|
||||
signingUrl: document.recipients[0].signingUrl,
|
||||
signingUrl: distributeResult.recipients[0].signingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,12 +471,17 @@ RESPONSE=$(curl -s -X POST "${BASE_URL}/template/use" \
|
||||
\"subject\": \"Your Employment Contract\",
|
||||
\"message\": \"Please review and sign your employment contract.\"
|
||||
},
|
||||
\"distributeDocument\": true,
|
||||
\"externalId\": \"emp-$(date +%s)-alice\"
|
||||
}")
|
||||
|
||||
echo "Document created:"
|
||||
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
|
||||
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
|
||||
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||
-H "Authorization: ${API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
|
||||
|
||||
echo "Document created: $(echo $RESPONSE | jq -r '.id')"
|
||||
echo "Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
@@ -476,8 +499,10 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
|
||||
Fetch the template and get the signer recipient slot ID
|
||||
</Step>
|
||||
<Step>
|
||||
For each recipient, call <code>POST /template/use</code> with{' '}
|
||||
<code>distributeDocument: true</code>
|
||||
For each recipient, call <code>POST /template/use</code>
|
||||
</Step>
|
||||
<Step>
|
||||
Distribute each returned envelope via <code>POST /envelope/distribute</code>
|
||||
</Step>
|
||||
<Step>
|
||||
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
||||
@@ -540,7 +565,6 @@ async function bulkSendFromTemplate(
|
||||
recipients: [
|
||||
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
|
||||
],
|
||||
distributeDocument: true,
|
||||
externalId: `bulk-${Date.now()}-${recipient.email}`,
|
||||
}),
|
||||
});
|
||||
@@ -551,10 +575,26 @@ async function bulkSendFromTemplate(
|
||||
}
|
||||
|
||||
const document = await response.json();
|
||||
|
||||
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId: document.envelopeId }),
|
||||
});
|
||||
|
||||
if (!distributeResponse.ok) {
|
||||
const error = await distributeResponse.json();
|
||||
throw new Error(error.message || 'Failed to distribute document');
|
||||
}
|
||||
|
||||
const distributeResult = await distributeResponse.json();
|
||||
return {
|
||||
email: recipient.email,
|
||||
envelopeId: document.id,
|
||||
signingUrl: document.recipients[0].signingUrl,
|
||||
envelopeId: document.envelopeId,
|
||||
signingUrl: distributeResult.recipients[0].signingUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -627,14 +667,24 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
|
||||
\"email\": \"${EMAIL}\",
|
||||
\"name\": \"${NAME}\"
|
||||
}],
|
||||
\"distributeDocument\": true,
|
||||
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
|
||||
}")
|
||||
|
||||
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
|
||||
echo " Success: $(echo $RESPONSE | jq -r '.id')"
|
||||
if echo $RESPONSE | jq -e '.envelopeId' > /dev/null 2>&1; then
|
||||
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
|
||||
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||
-H "Authorization: ${API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
|
||||
|
||||
if echo $DISTRIBUTE_RESPONSE | jq -e '.success' > /dev/null 2>&1; then
|
||||
echo " Success: ${ENVELOPE_ID}"
|
||||
echo " Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
|
||||
else
|
||||
echo " Failed to distribute: $(echo $DISTRIBUTE_RESPONSE | jq -r '.message')"
|
||||
fi
|
||||
else
|
||||
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
|
||||
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
|
||||
fi
|
||||
|
||||
# Rate limiting delay
|
||||
@@ -837,13 +887,15 @@ After a document is completed, download the signed PDF with all signatures embed
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The `version` query parameter accepts `original`, `pending`, or `signed`.
|
||||
|
||||
<Tabs items={['TypeScript', 'curl']}>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const API_TOKEN = process.env.DOCUMENSO_API_TOKEN;
|
||||
const BASE_URL = 'https://app.documenso.com/api/v2';
|
||||
|
||||
type DownloadVersion = 'signed' | 'original';
|
||||
type DownloadVersion = 'original' | 'pending' | 'signed';
|
||||
|
||||
async function downloadDocument(
|
||||
envelopeId: string,
|
||||
@@ -897,7 +949,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
|
||||
{ headers: { Authorization: API_TOKEN } },
|
||||
);
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
const { data, count, currentPage, perPage, totalPages } = await response.json();
|
||||
|
||||
for (const envelope of data) {
|
||||
try {
|
||||
@@ -911,8 +963,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
hasMore = page < pagination.totalPages;
|
||||
page++;
|
||||
hasMore = currentPage < totalPages;
|
||||
page = currentPage + 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,20 +24,18 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Open settings
|
||||
### Select a team
|
||||
|
||||
- Log in to your Documenso account
|
||||
- Click your avatar in the top right corner
|
||||
- Select **Settings** from the dropdown menu
|
||||
|
||||

|
||||
Log in to Documenso and open the team that the integration should access. API tokens are scoped to a
|
||||
team.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Navigate to the API Tokens tab
|
||||
### Open API Tokens
|
||||
|
||||
Go to **Settings** and open the **API Tokens** tab.
|
||||
Go to **Team Settings** → **API Tokens**, or open
|
||||
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
|
||||
|
||||

|
||||
|
||||
@@ -48,7 +46,7 @@ Go to **Settings** and open the **API Tokens** tab.
|
||||
|
||||
- Click **Create Token**
|
||||
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
|
||||
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
|
||||
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
|
||||
- Click **Create Token**
|
||||
|
||||
</Step>
|
||||
@@ -75,21 +73,21 @@ Include the token in the `Authorization` header of your HTTP requests.
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl https://app.documenso.com/api/v2/document \
|
||||
curl https://app.documenso.com/api/v2/envelope \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
### JavaScript / TypeScript
|
||||
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/document', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
},
|
||||
});
|
||||
|
||||
const documents = await response.json();
|
||||
const envelopes = await response.json();
|
||||
```
|
||||
|
||||
### Using the TypeScript SDK
|
||||
@@ -129,7 +127,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
|
||||
|
||||
## Token Security
|
||||
|
||||
API tokens grant full access to your account. Follow these practices to keep them secure:
|
||||
API tokens grant full API access to the team they were created for. Follow these practices to keep them secure:
|
||||
|
||||
- **Never commit tokens to version control.** Use environment variables instead.
|
||||
- **Use descriptive names.** Names like `zapier-prod` or `backend-staging` help you identify token usage.
|
||||
@@ -155,12 +153,11 @@ const client = new Documenso({
|
||||
|
||||
## Token Scope
|
||||
|
||||
API tokens have full access to your account, including:
|
||||
API tokens have full API access to the team they were created for, including:
|
||||
|
||||
- Creating, reading, updating, and deleting documents
|
||||
- Managing recipients and fields
|
||||
- Accessing templates
|
||||
- Managing team resources (if the token owner has team access)
|
||||
|
||||
There is currently no way to create tokens with limited scopes or permissions.
|
||||
|
||||
@@ -171,7 +168,7 @@ To revoke a token:
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
Go to **Settings** > **API Tokens**
|
||||
Go to **Team Settings** → **API Tokens**
|
||||
</Step>
|
||||
<Step>
|
||||
Find the token you want to revoke
|
||||
@@ -194,7 +191,7 @@ Revoked tokens stop working immediately. Any integrations using that token will
|
||||
</Accordion>
|
||||
<Accordion title="401 Unauthorized — Expired token">Create a new token in settings.</Accordion>
|
||||
<Accordion title="403 Forbidden — Token doesn't have access to the resource">
|
||||
Ensure you're accessing resources owned by the token's account.
|
||||
Ensure you're accessing resources owned by the token's team.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
|
||||
@@ -78,12 +78,10 @@ A successful response returns a list of your documents (envelopes):
|
||||
"createdAt": "2025-01-15T10:30:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 1,
|
||||
"totalItems": 1
|
||||
}
|
||||
"count": 1,
|
||||
"currentPage": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 1
|
||||
}
|
||||
````
|
||||
|
||||
@@ -228,9 +226,12 @@ After creating a document, it's in `DRAFT` status. To send it to recipients, use
|
||||
<Tabs items={['curl', 'JavaScript']}>
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
|
||||
-H "Authorization: YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json"
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "envelope_abc123"
|
||||
}'
|
||||
````
|
||||
|
||||
</Tab>
|
||||
@@ -238,16 +239,14 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribu
|
||||
```javascript
|
||||
const envelopeId = 'envelope_abc123';
|
||||
|
||||
const response = await fetch(
|
||||
`https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'YOUR_API_TOKEN',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'YOUR_API_TOKEN',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({ envelopeId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Document sent:', data);
|
||||
@@ -337,16 +336,14 @@ async function createAndSendDocument(pdfPath, recipientEmail, recipientName) {
|
||||
console.log('Created envelope:', envelope.id);
|
||||
|
||||
// Step 2: Send the document for signing
|
||||
const distributeResponse = await fetch(
|
||||
`${BASE_URL}/envelope/${envelope.id}/distribute`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId: envelope.id }),
|
||||
});
|
||||
|
||||
if (!distributeResponse.ok) {
|
||||
const error = await distributeResponse.json();
|
||||
@@ -422,9 +419,12 @@ echo "Created envelope: ${ENVELOPE_ID}"
|
||||
# Step 2: Send the document for signing
|
||||
|
||||
echo "Sending document..."
|
||||
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
|
||||
curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||
-H "Authorization: ${API_TOKEN}" \
|
||||
-H "Content-Type: application/json"
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"envelopeId\": \"${ENVELOPE_ID}\"
|
||||
}"
|
||||
|
||||
echo "Document sent for signing!"
|
||||
|
||||
@@ -441,7 +441,7 @@ The API returns standard HTTP status codes and JSON error responses:
|
||||
| `400` | Bad request - check your request payload |
|
||||
| `401` | Unauthorized - invalid or missing API token |
|
||||
| `404` | Not found - resource doesn't exist |
|
||||
| `429` | Rate limited - wait 60 seconds and retry |
|
||||
| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. |
|
||||
| `500` | Server error - retry or contact support |
|
||||
|
||||
### Error Response Format
|
||||
@@ -485,7 +485,9 @@ The API returns standard HTTP status codes and JSON error responses:
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
|
||||
The API has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically.
|
||||
|
||||
Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information.
|
||||
|
||||
```javascript
|
||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||
@@ -493,8 +495,15 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (response.status === 429) {
|
||||
console.log('Rate limited, waiting 60 seconds...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 60000));
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
|
||||
if (!retryAfter) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryAfterSeconds = Number.parseInt(retryAfter, 10);
|
||||
console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"background-jobs",
|
||||
"signing-certificate",
|
||||
"telemetry",
|
||||
"two-factor-enforcement",
|
||||
"organisation-limits",
|
||||
"advanced"
|
||||
]
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,142 +1,23 @@
|
||||
/**
|
||||
* Multi purpose CORS lib.
|
||||
* Note: Based on the `cors` package in npm but using only web APIs.
|
||||
* Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts
|
||||
* Apply the public statistics API's wildcard CORS policy, without credentials.
|
||||
*/
|
||||
|
||||
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
|
||||
|
||||
type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise<StaticOrigin>;
|
||||
|
||||
interface CorsOptions {
|
||||
origin?: StaticOrigin | OriginFn;
|
||||
methods?: string | string[];
|
||||
allowedHeaders?: string | string[];
|
||||
exposedHeaders?: string | string[];
|
||||
credentials?: boolean;
|
||||
maxAge?: number;
|
||||
preflightContinue?: boolean;
|
||||
optionsSuccessStatus?: number;
|
||||
}
|
||||
|
||||
const defaultOptions: CorsOptions = {
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 204,
|
||||
};
|
||||
|
||||
function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
|
||||
return Array.isArray(allowed)
|
||||
? allowed.some((o) => isOriginAllowed(origin, o))
|
||||
: typeof allowed === 'string'
|
||||
? origin === allowed
|
||||
: allowed instanceof RegExp
|
||||
? allowed.test(origin)
|
||||
: !!allowed;
|
||||
}
|
||||
|
||||
function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
|
||||
const headers = new Headers();
|
||||
|
||||
if (origin === '*') {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
} else if (typeof origin === 'string') {
|
||||
headers.set('Access-Control-Allow-Origin', origin);
|
||||
headers.append('Vary', 'Origin');
|
||||
} else {
|
||||
const allowed = isOriginAllowed(reqOrigin ?? '', origin);
|
||||
|
||||
if (allowed && reqOrigin) {
|
||||
headers.set('Access-Control-Allow-Origin', reqOrigin);
|
||||
}
|
||||
headers.append('Vary', 'Origin');
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) {
|
||||
const reqOrigin = req.headers.get('Origin') || undefined;
|
||||
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
return getOriginHeaders(reqOrigin, value);
|
||||
}
|
||||
|
||||
function getAllowedHeaders(req: Request, allowed?: string | string[]) {
|
||||
const headers = new Headers();
|
||||
|
||||
if (!allowed) {
|
||||
allowed = req.headers.get('Access-Control-Request-Headers')!;
|
||||
headers.append('Vary', 'Access-Control-Request-Headers');
|
||||
} else if (Array.isArray(allowed)) {
|
||||
allowed = allowed.join(',');
|
||||
}
|
||||
if (allowed) {
|
||||
headers.set('Access-Control-Allow-Headers', allowed);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export default async function cors(req: Request, res: Response, options?: CorsOptions) {
|
||||
const opts = { ...defaultOptions, ...options };
|
||||
export default async function cors(req: Request, res: Response): Promise<Response> {
|
||||
const { headers } = res;
|
||||
const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
|
||||
const mergeHeaders = (v: string, k: string) => {
|
||||
if (k === 'Vary') {
|
||||
headers.append(k, v);
|
||||
} else {
|
||||
headers.set(k, v);
|
||||
}
|
||||
};
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// If there's no origin we won't touch the response
|
||||
if (!originHeaders) {
|
||||
return res;
|
||||
}
|
||||
|
||||
originHeaders.forEach(mergeHeaders);
|
||||
|
||||
if (opts.credentials) {
|
||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(',') : opts.exposedHeaders;
|
||||
|
||||
if (exposed) {
|
||||
headers.set('Access-Control-Expose-Headers', exposed);
|
||||
}
|
||||
|
||||
// Handle the preflight request
|
||||
if (req.method === 'OPTIONS') {
|
||||
if (opts.methods) {
|
||||
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
|
||||
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
|
||||
headers.set('Vary', 'Access-Control-Request-Headers');
|
||||
|
||||
headers.set('Access-Control-Allow-Methods', methods);
|
||||
}
|
||||
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
|
||||
|
||||
getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders);
|
||||
|
||||
if (typeof opts.maxAge === 'number') {
|
||||
headers.set('Access-Control-Max-Age', String(opts.maxAge));
|
||||
}
|
||||
|
||||
if (opts.preflightContinue) {
|
||||
return res;
|
||||
if (allowedHeaders) {
|
||||
headers.set('Access-Control-Allow-Headers', allowedHeaders);
|
||||
}
|
||||
|
||||
headers.set('Content-Length', '0');
|
||||
return new Response(null, { status: opts.optionsSuccessStatus, headers });
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
|
||||
// If we got here, it's a normal request
|
||||
return res;
|
||||
}
|
||||
|
||||
export function initCors(options?: CorsOptions) {
|
||||
return async (req: Request, res: Response) => cors(req, res, options);
|
||||
}
|
||||
|
||||
@@ -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 authentication for this user.`,
|
||||
() => msg`You are not authorized to reset two factor authentcation for this user.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
|
||||
|
||||
@@ -85,8 +85,7 @@ 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. Their two-factor enforcement grace period will restart from the moment of the
|
||||
reset.
|
||||
authentication for the user.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
@@ -109,8 +108,7 @@ 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. Any
|
||||
two-factor enforcement grace period for this user will restart from the moment of the reset.
|
||||
This action is irreversible. Please ensure you have informed the user before proceeding.
|
||||
</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.`,
|
||||
description: t`The file is not a valid PDF or is password protected.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (result.failedIds.length > 0) {
|
||||
|
||||
@@ -299,7 +299,7 @@ export const EnvelopesBulkDownloadDialog = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset disabled={isDownloading} className="space-y-4">
|
||||
<fieldset disabled={isDownloading} className="min-w-0 space-y-4">
|
||||
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
|
||||
<div className="divide-y divide-border rounded-lg border border-border">
|
||||
{envelopes.map((envelope) => {
|
||||
|
||||
@@ -96,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
}
|
||||
|
||||
await onSuccess?.(data.folderId);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -38,27 +40,64 @@ import { useNavigate } from 'react-router';
|
||||
import * as z from 'zod';
|
||||
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
const DOCUMENT_NAME_SOURCE = {
|
||||
TEMPLATE: 'template',
|
||||
UPLOAD: 'upload',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
|
||||
const getUploadedDocumentTitle = (file: File) => {
|
||||
return file.name.replace(/\.[^/.]+$/, '').trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the file name can be used as a document title.
|
||||
*/
|
||||
const isUploadedFileNameUsable = (file?: File): file is File => {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = getUploadedDocumentTitle(file);
|
||||
|
||||
return title.length > 0 && title.length <= DOCUMENT_TITLE_MAX_LENGTH;
|
||||
};
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
documentNameSource: z.enum([
|
||||
DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||
DOCUMENT_NAME_SOURCE.UPLOAD,
|
||||
DOCUMENT_NAME_SOURCE.CUSTOM,
|
||||
]),
|
||||
customDocumentName: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(DOCUMENT_TITLE_MAX_LENGTH, { message: msg`Document name is too long`.id }),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
),
|
||||
})
|
||||
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
|
||||
message: msg`Document name is required`.id,
|
||||
path: ['customDocumentName'],
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
@@ -87,6 +126,7 @@ export function TemplateUseDialog({
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lastUploadedFile, setLastUploadedFile] = useState<File>();
|
||||
|
||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||
{
|
||||
@@ -106,6 +146,8 @@ export function TemplateUseDialog({
|
||||
return {
|
||||
distributeDocument: false,
|
||||
useCustomDocument: false,
|
||||
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||
customDocumentName: '',
|
||||
customDocumentData: envelopeItems.map((item) => ({
|
||||
title: item.title,
|
||||
data: undefined,
|
||||
@@ -140,11 +182,39 @@ export function TemplateUseDialog({
|
||||
|
||||
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
/**
|
||||
* Track the most recently uploaded file so its name can be used as the document name.
|
||||
* Files with an unusable name are ignored, and the document name source is reset if
|
||||
* no usable file remains.
|
||||
*/
|
||||
const updateLastUploadedFile = (file?: File) => {
|
||||
const usableFile = isUploadedFileNameUsable(file) ? file : undefined;
|
||||
|
||||
setLastUploadedFile(usableFile);
|
||||
|
||||
if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) {
|
||||
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
|
||||
}
|
||||
};
|
||||
|
||||
const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
|
||||
return data.customDocumentName;
|
||||
}
|
||||
|
||||
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) {
|
||||
return getUploadedDocumentTitle(lastUploadedFile);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
try {
|
||||
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||
const documentTitle = getDocumentTitle(data);
|
||||
|
||||
const customFilesToUpload = (data.customDocumentData ?? []).filter(
|
||||
(item): item is typeof item & { data: File } => item.data !== undefined,
|
||||
);
|
||||
|
||||
const customDocumentData = await Promise.all(
|
||||
@@ -163,6 +233,7 @@ export function TemplateUseDialog({
|
||||
recipients: data.recipients,
|
||||
distributeDocument: data.distributeDocument,
|
||||
customDocumentData,
|
||||
...(documentTitle ? { override: { title: documentTitle } } : {}),
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -195,9 +266,14 @@ export function TemplateUseDialog({
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const useCustomDocument = form.watch('useCustomDocument');
|
||||
const documentNameSource = form.watch('documentNameSource');
|
||||
const canUseUploadedDocumentName = Boolean(lastUploadedFile);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(generateDefaultFormValues());
|
||||
setLastUploadedFile(undefined);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
@@ -238,9 +314,9 @@ export function TemplateUseDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||
<form className="min-w-0" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="flex h-full min-w-0 flex-col" disabled={form.formState.isSubmitting}>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] w-full min-w-0 max-w-full space-y-4 overflow-y-auto overflow-x-hidden p-1">
|
||||
{formRecipients.map((recipient, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
|
||||
@@ -401,7 +477,17 @@ export function TemplateUseDialog({
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
form.setValue('customDocumentData', undefined);
|
||||
const customDocumentData = form.getValues('customDocumentData');
|
||||
|
||||
form.setValue(
|
||||
'customDocumentData',
|
||||
customDocumentData?.map((item) => ({
|
||||
...item,
|
||||
data: undefined,
|
||||
})),
|
||||
);
|
||||
form.clearErrors('customDocumentData');
|
||||
updateLastUploadedFile(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -428,7 +514,7 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch('useCustomDocument') && (
|
||||
{useCustomDocument && (
|
||||
<div className="my-4 space-y-2">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<SpinnerBox className="py-16" />
|
||||
@@ -443,7 +529,7 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||
className="flex w-full min-w-0 items-center gap-4 overflow-hidden rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
@@ -451,13 +537,15 @@ export function TemplateUseDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<h4 className="truncate font-medium text-foreground text-sm">
|
||||
{field.value ? getUploadedDocumentTitle(field.value) : item.title}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
{field.value ? (
|
||||
<div>
|
||||
<span>
|
||||
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<Trans>Default file</Trans>
|
||||
)}
|
||||
@@ -475,6 +563,18 @@ export function TemplateUseDialog({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
field.onChange(undefined);
|
||||
|
||||
if (field.value === lastUploadedFile) {
|
||||
// Fall back to any other uploaded file so the option stays available.
|
||||
const remainingUploadedFile = form
|
||||
.getValues('customDocumentData')
|
||||
?.find(
|
||||
(item) =>
|
||||
item.data !== field.value && isUploadedFileNameUsable(item.data),
|
||||
)?.data;
|
||||
|
||||
updateLastUploadedFile(remainingUploadedFile);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
@@ -517,7 +617,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
form.setError('customDocumentData', {
|
||||
form.setError(`customDocumentData.${i}.data`, {
|
||||
type: 'manual',
|
||||
message: _(msg`Please select a PDF file`),
|
||||
});
|
||||
@@ -526,7 +626,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
||||
form.setError('customDocumentData', {
|
||||
form.setError(`customDocumentData.${i}.data`, {
|
||||
type: 'manual',
|
||||
message: _(
|
||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||
@@ -537,6 +637,8 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
form.clearErrors(`customDocumentData.${i}.data`);
|
||||
updateLastUploadedFile(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -550,6 +652,112 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentNameSource"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Document name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
aria-label={_(msg`Document name`)}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
|
||||
<label className="text-sm" htmlFor="document-name-source-template">
|
||||
<Trans>Use template name</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<RadioGroupItem
|
||||
id="document-name-source-upload"
|
||||
value={DOCUMENT_NAME_SOURCE.UPLOAD}
|
||||
disabled={!canUseUploadedDocumentName}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<label
|
||||
className={cn('text-sm', {
|
||||
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
|
||||
})}
|
||||
htmlFor="document-name-source-upload"
|
||||
>
|
||||
<Trans>Use uploaded file name</Trans>
|
||||
</label>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
aria-label={_(msg`About uploaded file naming`)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-[99999] max-w-xs">
|
||||
<Trans>
|
||||
The document name will use the most recently uploaded file name without its
|
||||
extension.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{lastUploadedFile && (
|
||||
<p
|
||||
className="max-w-sm truncate text-muted-foreground text-xs"
|
||||
title={lastUploadedFile.name}
|
||||
>
|
||||
{lastUploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canUseUploadedDocumentName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Upload a custom document to use its file name.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
|
||||
<label className="text-sm" htmlFor="document-name-source-custom">
|
||||
<Trans>Enter custom document name</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customDocumentName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="ml-6">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
aria-label={_(msg`Custom document name`)}
|
||||
placeholder={_(msg`Enter a document name`)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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,
|
||||
@@ -69,23 +68,15 @@ 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 {
|
||||
const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode });
|
||||
await authClient.twoFactor.disable({ totpCode, backupCode });
|
||||
|
||||
toast({
|
||||
title: _(msg`Two-factor authentication disabled`),
|
||||
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.`,
|
||||
),
|
||||
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.`,
|
||||
),
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
@@ -93,19 +84,7 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
});
|
||||
|
||||
await refreshSession();
|
||||
} 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;
|
||||
}
|
||||
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: _(msg`Unable to disable two-factor authentication`),
|
||||
description: _(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -70,18 +69,11 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
|
||||
setSetup2FAData(data);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Unable to setup two-factor authentication`),
|
||||
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.`,
|
||||
),
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,332 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { SearchIcon, UsersIcon } from 'lucide-react';
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import type { AnalyticsQueryResult } from '~/utils/analytics';
|
||||
import { formatRelativeDate } from '~/utils/analytics';
|
||||
|
||||
import { AnalyticsQueryError } from './analytics-query-error';
|
||||
|
||||
/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */
|
||||
export type AnalyticsActivityRow = {
|
||||
key: string | number;
|
||||
avatar: {
|
||||
imageId: string | null;
|
||||
fallback: string;
|
||||
};
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
sent: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
/** 0-100, null when nothing was sent. */
|
||||
completionRate: number | null;
|
||||
lastActiveAt: Date | null;
|
||||
/** When set the whole row navigates here and the title becomes a link. */
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type AnalyticsActivityTableCardProps = {
|
||||
query: AnalyticsQueryResult<unknown>;
|
||||
/** Rows derived from `query.data`; empty while loading. */
|
||||
rows: AnalyticsActivityRow[];
|
||||
/** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */
|
||||
rangeKey: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
/** Header of the first column, e.g. "Member". */
|
||||
columnLabel: ReactNode;
|
||||
/** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */
|
||||
renderSummary: (count: number, activeCount: number) => ReactNode;
|
||||
/** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */
|
||||
renderShowing: (visibleCount: number, totalCount: number) => ReactNode;
|
||||
emptyLabel: ReactNode;
|
||||
emptyIcon?: LucideIcon;
|
||||
/** Placeholder for the search input, e.g. "Search members". */
|
||||
searchPlaceholder: string;
|
||||
/** Rendered when the search matches nothing, e.g. "No members match your search". */
|
||||
noSearchResultsLabel: ReactNode;
|
||||
/** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */
|
||||
testIdPrefix: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsActivityTableCard = ({
|
||||
query,
|
||||
rows,
|
||||
rangeKey,
|
||||
title,
|
||||
description,
|
||||
columnLabel,
|
||||
renderSummary,
|
||||
renderShowing,
|
||||
emptyLabel,
|
||||
emptyIcon: EmptyIcon = UsersIcon,
|
||||
searchPlaceholder,
|
||||
noSearchResultsLabel,
|
||||
testIdPrefix,
|
||||
className,
|
||||
}: AnalyticsActivityTableCardProps) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
// Tracks which window "Show all" was pressed for, so it resets whenever the window changes.
|
||||
const [expandedRangeKey, setExpandedRangeKey] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const isExpanded = expandedRangeKey === rangeKey;
|
||||
|
||||
const { data, isLoading, isError, refetch } = query;
|
||||
|
||||
const activeCount = rows.filter((row) => row.sent > 0).length;
|
||||
|
||||
const normalisedSearchTerm = searchTerm.trim().toLowerCase();
|
||||
const isSearching = normalisedSearchTerm.length > 0;
|
||||
|
||||
// Search always shows every match; the preview limit only applies to the unfiltered list.
|
||||
const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows;
|
||||
const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT);
|
||||
const hasHiddenRows = filteredRows.length > visibleRows.length;
|
||||
|
||||
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
|
||||
|
||||
return (
|
||||
<Card className={className} data-testid={testId('activity')}>
|
||||
<CardHeader className="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</div>
|
||||
|
||||
{data !== undefined && rows.length > 0 && (
|
||||
<p className="shrink-0 text-muted-foreground text-sm tabular-nums" data-testid={testId('summary')}>
|
||||
{renderSummary(rows.length, activeCount)}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isError ? (
|
||||
<AnalyticsQueryError onRetry={refetch} />
|
||||
) : isLoading || data === undefined ? (
|
||||
<ul className="flex flex-col gap-y-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<li key={index} className="flex items-center gap-x-3">
|
||||
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-1.5">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="hidden h-4 w-10 md:block" />
|
||||
<Skeleton className="hidden h-4 w-10 md:block" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="hidden h-4 w-20 md:block" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<EmptyIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="max-w-sm text-muted-foreground text-sm">{emptyLabel}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="relative sm:max-w-xs">
|
||||
<SearchIcon
|
||||
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
className="pl-9"
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
data-testid={testId('search')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground text-sm" data-testid={testId('no-results')}>
|
||||
{noSearchResultsLabel}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */}
|
||||
<div className="-mx-6">
|
||||
<Table className="[&_td]:px-2 md:[&_td]:px-4 [&_th]:px-2 md:[&_th]:px-4">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className={FIRST_CELL_CLASS}>{columnLabel}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Trans>Sent</Trans>
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
<Trans>Completed</Trans>
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
<Trans>Pending</Trans>
|
||||
</TableHead>
|
||||
<TableHead className={cn('text-right', LAST_CELL_ON_MOBILE_CLASS)}>
|
||||
<Trans>Completion rate</Trans>
|
||||
</TableHead>
|
||||
<TableHead className={cn('hidden text-right md:table-cell', LAST_CELL_CLASS)}>
|
||||
<Trans>Last active</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
<ActivityRow key={row.key} row={row} locale={i18n.locale} testIdPrefix={testIdPrefix} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{hasHiddenRows && (
|
||||
<div className="flex items-center justify-between gap-x-4 border-border border-t pt-3">
|
||||
<p className="text-muted-foreground text-sm" data-testid={testId('showing')}>
|
||||
{renderShowing(visibleRows.length, filteredRows.length)}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-mr-2"
|
||||
onClick={() => setExpandedRangeKey(rangeKey)}
|
||||
data-testid={testId('show-all')}
|
||||
>
|
||||
<Trans>Show all</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type ActivityRowProps = {
|
||||
row: AnalyticsActivityRow;
|
||||
locale: string;
|
||||
testIdPrefix: string;
|
||||
};
|
||||
|
||||
const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => {
|
||||
const { _ } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isActive = row.sent > 0;
|
||||
|
||||
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
|
||||
|
||||
// Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout.
|
||||
const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground');
|
||||
const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums';
|
||||
|
||||
// role="img" so the aria-label is valid (a bare span has no role that supports it).
|
||||
const notAvailable = (
|
||||
<span role="img" aria-label={_(msg`Not available`)}>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* The title link is the accessible target; clicking anywhere else on the row
|
||||
* navigates too. Modifier clicks and clicks on the link itself are left to the
|
||||
* browser so open-in-new-tab keeps working, and drag-selecting text does not
|
||||
* navigate.
|
||||
*/
|
||||
const handleRowClick = (event: MouseEvent<HTMLTableRowElement>) => {
|
||||
if (!row.href || event.defaultPrevented || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target instanceof Element && event.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigate(row.href);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className={cn(row.href && 'cursor-pointer')}
|
||||
onClick={handleRowClick}
|
||||
data-testid={testId('row')}
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
>
|
||||
{/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */}
|
||||
<TableCell truncate={false} className={cn('w-full max-w-0', FIRST_CELL_CLASS)}>
|
||||
<div className="flex min-w-0 items-center gap-x-3">
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
{row.avatar.imageId && <AvatarImage src={formatAvatarUrl(row.avatar.imageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{row.avatar.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{row.href ? (
|
||||
<Link
|
||||
to={row.href}
|
||||
className={cn(
|
||||
'truncate font-medium text-sm hover:underline',
|
||||
isActive ? 'text-foreground' : 'text-foreground/80',
|
||||
)}
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cn('truncate font-medium text-sm', isActive ? 'text-foreground' : 'text-foreground/80')}>
|
||||
{row.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{row.subtitle && <span className="truncate text-muted-foreground text-xs">{row.subtitle}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={secondaryNumberClass} data-testid={testId('sent')}>
|
||||
{row.sent.toLocaleString(locale)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={cn('hidden md:table-cell', primaryNumberClass)} data-testid={testId('completed')}>
|
||||
{row.completed.toLocaleString(locale)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={cn('hidden md:table-cell', secondaryNumberClass)} data-testid={testId('pending')}>
|
||||
{row.pending.toLocaleString(locale)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={cn(primaryNumberClass, LAST_CELL_ON_MOBILE_CLASS)} data-testid={testId('completion-rate')}>
|
||||
{row.completionRate === null ? (
|
||||
notAvailable
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<div className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:block" aria-hidden="true">
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${row.completionRate}%` }} />
|
||||
</div>
|
||||
|
||||
<span className="w-9 text-right">{Math.round(row.completionRate)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
className={cn('hidden md:table-cell', secondaryNumberClass, LAST_CELL_CLASS)}
|
||||
data-testid={testId('last-active')}
|
||||
>
|
||||
{row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const ROW_PREVIEW_LIMIT = 8;
|
||||
|
||||
const matchesSearch = (row: AnalyticsActivityRow, term: string) => {
|
||||
return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term);
|
||||
};
|
||||
|
||||
/**
|
||||
* The table is pulled out to the card edge (-mx-6), so the outer cells get the
|
||||
* card's px-6 gutter to line up with the header. "Last active" is hidden below
|
||||
* md, so "Completion rate" takes the right gutter there.
|
||||
*/
|
||||
const FIRST_CELL_CLASS = '!pl-6';
|
||||
const LAST_CELL_CLASS = '!pr-6';
|
||||
const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4';
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { BarChart3Icon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics';
|
||||
import { getAnalyticsDateRangeDays } from '~/utils/analytics';
|
||||
|
||||
import { AnalyticsQueryError } from './analytics-query-error';
|
||||
|
||||
export type AnalyticsDocumentsOverTimeCardProps = {
|
||||
range: AnalyticsRangeValue;
|
||||
query: AnalyticsQueryResult<TGetTeamAnalyticsDocumentsOverTimeResponse>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket'];
|
||||
|
||||
export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const { data, isLoading, isError, refetch } = query;
|
||||
|
||||
// The backend decides the bucket, and it must match the points being rendered so
|
||||
// the tick and tooltip formatting line up. Before data arrives it is guessed from
|
||||
// the requested range.
|
||||
const bucket: Bucket = data ? data.range.bucket : guessBucket(range);
|
||||
|
||||
const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0;
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col', className)} data-testid="analytics-documents-over-time">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>
|
||||
<Trans>Documents created</Trans>
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>{bucket === 'month' ? <Trans>Monthly</Trans> : <Trans>Daily</Trans>}</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<p className="shrink-0 whitespace-nowrap" data-testid="analytics-documents-over-time-total">
|
||||
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
|
||||
{data.total.toLocaleString(i18n.locale)}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<Trans>total</Trans>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */}
|
||||
<CardContent className="flex flex-1 flex-col justify-center">
|
||||
{isError ? (
|
||||
<AnalyticsQueryError onRetry={refetch} />
|
||||
) : isLoading || !data ? (
|
||||
<Skeleton className="w-full" style={{ height: CHART_HEIGHT }} />
|
||||
) : data.total === 0 ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-y-3 text-center"
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<BarChart3Icon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>No documents created in this period</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
<BarChart data={data.points} margin={{ top: 8, right: 0, bottom: 0, left: 0 }} barCategoryGap="20%">
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
interval={tickInterval}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickFormatter={(value: string) => formatTickLabel(value, bucket, i18n.locale)}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={36}
|
||||
tickCount={4}
|
||||
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={<DocumentsOverTimeTooltip bucket={bucket} locale={i18n.locale} />}
|
||||
cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }}
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={28}
|
||||
background={{ fill: 'hsl(var(--muted) / 0.5)', radius: 4 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type DocumentsOverTimeTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: { date: string; count: number } }>;
|
||||
bucket: Bucket;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => {
|
||||
const point = payload?.[0]?.payload;
|
||||
|
||||
if (!active || !point) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = Number(point.count ?? 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-popover px-3 py-2 text-popover-foreground text-sm shadow-md">
|
||||
<p className="text-muted-foreground text-xs">{formatTooltipLabel(point.date, bucket, locale)}</p>
|
||||
|
||||
<p className="mt-0.5 font-medium tabular-nums">
|
||||
<Plural value={count} one="# document" other="# documents" />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
|
||||
const TARGET_DAILY_TICK_COUNT = 6;
|
||||
|
||||
/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */
|
||||
const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92;
|
||||
|
||||
const guessBucket = (range: AnalyticsRangeValue): Bucket => {
|
||||
if (range.range === '12m') {
|
||||
return 'month';
|
||||
}
|
||||
|
||||
if (range.range === 'custom') {
|
||||
return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day';
|
||||
}
|
||||
|
||||
return 'day';
|
||||
};
|
||||
|
||||
/**
|
||||
* Month buckets label every month (12 fit at the lg width) and let recharts drop
|
||||
* overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels.
|
||||
*/
|
||||
const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => {
|
||||
if (bucket === 'month') {
|
||||
return 'preserveStartEnd';
|
||||
}
|
||||
|
||||
if (pointCount <= TARGET_DAILY_TICK_COUNT) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1);
|
||||
};
|
||||
|
||||
const formatTickLabel = (date: string, bucket: Bucket, locale: string) => {
|
||||
const parsed = DateTime.fromISO(date).setLocale(locale);
|
||||
|
||||
if (bucket === 'month') {
|
||||
return parsed.toLocaleString({ month: 'short' });
|
||||
}
|
||||
|
||||
return parsed.toLocaleString({ month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => {
|
||||
const parsed = DateTime.fromISO(date).setLocale(locale);
|
||||
|
||||
if (bucket === 'month') {
|
||||
return parsed.toLocaleString({ month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
return parsed.toLocaleString(DateTime.DATE_FULL);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
/**
|
||||
* Shown while the analytics route's `clientLoader` resolves the browser timezone
|
||||
* during hydration.
|
||||
*/
|
||||
export const AnalyticsHydrateFallback = () => {
|
||||
return (
|
||||
<div role="status" aria-live="polite" data-testid="analytics-loading">
|
||||
<SpinnerBox />
|
||||
<span className="sr-only">
|
||||
<Trans>Loading analytics</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics';
|
||||
|
||||
export type AnalyticsNoActivityAlertProps = {
|
||||
range: TTeamAnalyticsRange;
|
||||
/** Invoked when the user asks to widen the range to the last 12 months. */
|
||||
onShowLastYear: () => void;
|
||||
};
|
||||
|
||||
export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const canWidenRange = range !== '12m';
|
||||
|
||||
return (
|
||||
<Alert variant="neutral" padding="tight" className="mt-6" data-testid="analytics-no-activity">
|
||||
<AlertDescription className="flex min-h-9 flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<span className="flex items-center gap-x-2">
|
||||
<InfoIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{_(ANALYTICS_NO_ACTIVITY_LABELS[range])}
|
||||
{canWidenRange && (
|
||||
<>
|
||||
{' '}
|
||||
<Trans>Try a longer range.</Trans>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{canWidenRange && (
|
||||
<Button variant="ghost" size="sm" className="-mr-2" onClick={onShowLastYear}>
|
||||
<Trans>Show last 12 months</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { AnalyticsQueryResult } from '~/utils/analytics';
|
||||
|
||||
import { AnalyticsStatCard } from './analytics-stat-card';
|
||||
|
||||
/** The part of the overview response shared by the team and organisation procedures. */
|
||||
export type AnalyticsOverviewData = Pick<TGetTeamAnalyticsOverviewResponse, 'sent' | 'completionRate'>;
|
||||
|
||||
/**
|
||||
* The third card counts the scope's "entities" (team members, organisation teams)
|
||||
* and how many of them were active in the period.
|
||||
*/
|
||||
export type AnalyticsOverviewEntityCard<TData> = {
|
||||
icon: LucideIcon;
|
||||
title: ReactNode;
|
||||
/** Applied to the value element, e.g. `analytics-members`. */
|
||||
testId: string;
|
||||
select: (data: TData) => { active: number; total: number };
|
||||
};
|
||||
|
||||
export type AnalyticsOverviewCardsProps<TData extends AnalyticsOverviewData> = {
|
||||
query: AnalyticsQueryResult<TData>;
|
||||
entity: AnalyticsOverviewEntityCard<TData>;
|
||||
};
|
||||
|
||||
export const AnalyticsOverviewCards = <TData extends AnalyticsOverviewData>({
|
||||
query,
|
||||
entity,
|
||||
}: AnalyticsOverviewCardsProps<TData>) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const { data, isLoading, isError, refetch } = query;
|
||||
|
||||
const entityCounts = data ? entity.select(data) : null;
|
||||
|
||||
const formatNumber = (value: number) => value.toLocaleString(i18n.locale);
|
||||
|
||||
const sharedProps = {
|
||||
isLoading: isLoading || !data,
|
||||
isError,
|
||||
onRetry: refetch,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<AnalyticsStatCard
|
||||
{...sharedProps}
|
||||
icon={SendIcon}
|
||||
title={<Trans>Documents sent</Trans>}
|
||||
value={data ? formatNumber(data.sent.current) : null}
|
||||
badge={data ? <SentDeltaBadge current={data.sent.current} previous={data.sent.previous} /> : null}
|
||||
description={<Trans>vs. previous period</Trans>}
|
||||
testId="analytics-sent"
|
||||
/>
|
||||
|
||||
<AnalyticsStatCard
|
||||
{...sharedProps}
|
||||
icon={CircleCheckIcon}
|
||||
title={<Trans>Completion rate</Trans>}
|
||||
value={data ? formatRate(data.completionRate.rate) : null}
|
||||
badge={
|
||||
data ? (
|
||||
<CompletionRateDeltaBadge rate={data.completionRate.rate} previousRate={data.completionRate.previousRate} />
|
||||
) : null
|
||||
}
|
||||
description={<Trans>of sent documents completed</Trans>}
|
||||
testId="analytics-completion-rate"
|
||||
/>
|
||||
|
||||
<AnalyticsStatCard
|
||||
{...sharedProps}
|
||||
icon={entity.icon}
|
||||
title={entity.title}
|
||||
value={entityCounts ? `${formatNumber(entityCounts.active)}/${formatNumber(entityCounts.total)}` : null}
|
||||
description={
|
||||
entityCounts ? (
|
||||
<Trans>
|
||||
{formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '}
|
||||
inactive
|
||||
</Trans>
|
||||
) : null
|
||||
}
|
||||
testId={entity.testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SentDeltaBadgeProps = {
|
||||
current: number;
|
||||
previous: number;
|
||||
};
|
||||
|
||||
const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => {
|
||||
if (previous === 0 && current === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (previous === 0) {
|
||||
return (
|
||||
<DeltaBadge tone="new" testId="analytics-sent-delta">
|
||||
<Trans>New</Trans>
|
||||
</DeltaBadge>
|
||||
);
|
||||
}
|
||||
|
||||
const delta = Math.round(((current - previous) / previous) * 100);
|
||||
|
||||
// Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display.
|
||||
const label =
|
||||
delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`;
|
||||
|
||||
return (
|
||||
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-sent-delta">
|
||||
{label}
|
||||
</DeltaBadge>
|
||||
);
|
||||
};
|
||||
|
||||
type CompletionRateDeltaBadgeProps = {
|
||||
rate: number | null;
|
||||
previousRate: number | null;
|
||||
};
|
||||
|
||||
const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => {
|
||||
if (rate === null || previousRate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compare the rounded values so the delta always agrees with the displayed rate.
|
||||
const delta = Math.round(rate) - Math.round(previousRate);
|
||||
|
||||
return (
|
||||
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-completion-rate-delta">
|
||||
{formatSignedNumber(delta)}%
|
||||
</DeltaBadge>
|
||||
);
|
||||
};
|
||||
|
||||
type DeltaTone = 'positive' | 'negative' | 'zero' | 'new';
|
||||
|
||||
type DeltaBadgeProps = {
|
||||
tone: DeltaTone;
|
||||
testId: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => {
|
||||
const DeltaIcon = DELTA_TONE_ICONS[tone];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-x-0.5 rounded-full px-1.5 py-0.5 font-medium text-xs tabular-nums leading-none',
|
||||
DELTA_TONE_CLASSES[tone],
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{DeltaIcon && <DeltaIcon className="-ml-0.5 h-3 w-3" aria-hidden="true" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const DELTA_TONE_CLASSES: Record<DeltaTone, string> = {
|
||||
positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
negative: 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||
zero: 'bg-muted text-muted-foreground',
|
||||
new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
};
|
||||
|
||||
const MAX_DISPLAYED_DELTA_PERCENT = 999;
|
||||
|
||||
const DELTA_TONE_ICONS: Record<DeltaTone, typeof ArrowUpRightIcon | null> = {
|
||||
positive: ArrowUpRightIcon,
|
||||
negative: ArrowDownRightIcon,
|
||||
zero: null,
|
||||
new: null,
|
||||
};
|
||||
|
||||
const formatRate = (rate: number | null) => {
|
||||
if (rate === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return `${Math.round(rate)}%`;
|
||||
};
|
||||
|
||||
const formatSignedNumber = (value: number) => {
|
||||
if (value > 0) {
|
||||
return `+${value}`;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getDeltaTone = (delta: number): DeltaTone => {
|
||||
if (delta > 0) {
|
||||
return 'positive';
|
||||
}
|
||||
|
||||
if (delta < 0) {
|
||||
return 'negative';
|
||||
}
|
||||
|
||||
return 'zero';
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { AnalyticsRangeValue } from '~/utils/analytics';
|
||||
import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics';
|
||||
|
||||
import { AnalyticsRangePicker } from './analytics-range-picker';
|
||||
|
||||
export type AnalyticsPageHeaderProps = {
|
||||
avatarImageId: string | null;
|
||||
/** The team or organisation name. */
|
||||
name: string;
|
||||
range: AnalyticsRangeValue;
|
||||
onRangeChange: (range: AnalyticsRangeValue) => void;
|
||||
/** Rendered before the range picker, e.g. a link to a related analytics page. */
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsPageHeader = ({
|
||||
avatarImageId,
|
||||
name,
|
||||
range,
|
||||
onRangeChange,
|
||||
actions,
|
||||
className,
|
||||
}: AnalyticsPageHeaderProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const rangeLabel =
|
||||
range.range === 'custom'
|
||||
? formatAnalyticsDateRange(range.from, range.to, i18n.locale)
|
||||
: _(ANALYTICS_RANGE_LABELS[range.range]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Analytics</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Usage overview for {name} · {rangeLabel}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{actions}
|
||||
|
||||
<AnalyticsRangePicker value={range} onValueChange={onRangeChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type AnalyticsQueryErrorProps = {
|
||||
onRetry: () => Promise<unknown>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => {
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
|
||||
const handleRetry = async () => {
|
||||
setIsRetrying(true);
|
||||
|
||||
try {
|
||||
await onRetry();
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert variant="neutral" padding="tight" className={className} data-testid="analytics-error">
|
||||
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span>
|
||||
<Trans>This data could not be loaded.</Trans>
|
||||
</span>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => void handleRetry()} loading={isRetrying}>
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
|
||||
import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { CalendarProps } from '@documenso/ui/primitives/calendar';
|
||||
import { Calendar } from '@documenso/ui/primitives/calendar';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics';
|
||||
import {
|
||||
ANALYTICS_PRESET_RANGES,
|
||||
ANALYTICS_RANGE_LABELS,
|
||||
formatAnalyticsDate,
|
||||
formatAnalyticsDateRange,
|
||||
getAnalyticsDateRangeDays,
|
||||
} from '~/utils/analytics';
|
||||
|
||||
export type AnalyticsRangePickerProps = {
|
||||
value: AnalyticsRangeValue;
|
||||
onValueChange: (value: AnalyticsRangeValue) => void;
|
||||
};
|
||||
|
||||
/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */
|
||||
type DraftRange = {
|
||||
from: Date | undefined;
|
||||
to: Date | undefined;
|
||||
};
|
||||
|
||||
/** A single react-day-picker matcher, e.g. `{ after: Date }`. */
|
||||
type DayMatcher = Exclude<CalendarProps['disabled'], undefined | unknown[]>;
|
||||
|
||||
/**
|
||||
* A preset select with a "Custom range…" item that opens a two month range
|
||||
* calendar anchored to the select. The custom window is only committed when
|
||||
* "Apply" is pressed.
|
||||
*/
|
||||
export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<DraftRange | undefined>();
|
||||
|
||||
const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1;
|
||||
|
||||
const today = DateTime.local().startOf('day');
|
||||
|
||||
const openPicker = () => {
|
||||
setDraft(
|
||||
value.range === 'custom'
|
||||
? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
setIsPickerOpen(true);
|
||||
};
|
||||
|
||||
const closePicker = () => {
|
||||
setIsPickerOpen(false);
|
||||
setDraft(undefined);
|
||||
};
|
||||
|
||||
const handleSelectValueChange = (nextValue: string) => {
|
||||
if (nextValue === CUSTOM_RANGE_VALUE) {
|
||||
openPicker();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue);
|
||||
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
onValueChange({ range: preset });
|
||||
};
|
||||
|
||||
/**
|
||||
* Picking a day starts a new window unless one end is already pending, in which
|
||||
* case it completes it. This replaces react-day-picker's default, which extends
|
||||
* a completed window instead of starting over.
|
||||
*/
|
||||
const handleDaySelect = (_nextRange: unknown, day: Date) => {
|
||||
if (draft?.from && !draft.to) {
|
||||
setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft({ from: day, to: undefined });
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!draft?.from || !draft.to) {
|
||||
return;
|
||||
}
|
||||
|
||||
onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) });
|
||||
|
||||
closePicker();
|
||||
};
|
||||
|
||||
// Only the last year (plus a day) up to today is selectable.
|
||||
const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
|
||||
const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }];
|
||||
|
||||
// Open on the month of the pending window (or today), keeping the current month
|
||||
// as the right-most one so no fully disabled future month is shown.
|
||||
const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month');
|
||||
const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 });
|
||||
const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate();
|
||||
|
||||
const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null;
|
||||
const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null;
|
||||
const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0;
|
||||
|
||||
const customLabel =
|
||||
value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isPickerOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closePicker();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
* The select never holds "custom" as its value so choosing "Custom range…" always
|
||||
* fires a change, letting an active custom window be adjusted. The trigger shows
|
||||
* the formatted window through the placeholder instead.
|
||||
*/}
|
||||
<Select value={value.range === 'custom' ? '' : value.range} onValueChange={handleSelectValueChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<SelectTrigger
|
||||
ref={triggerRef}
|
||||
className="w-full sm:w-auto sm:min-w-44"
|
||||
aria-label={_(msg`Date range`)}
|
||||
data-testid="analytics-range"
|
||||
>
|
||||
<SelectValue placeholder={customLabel} />
|
||||
</SelectTrigger>
|
||||
</PopoverAnchor>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{ANALYTICS_PRESET_OPTIONS.map(({ value: optionValue, label }) => (
|
||||
<SelectItem key={optionValue} value={optionValue}>
|
||||
{_(label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectSeparator />
|
||||
|
||||
<SelectItem value={CUSTOM_RANGE_VALUE} data-testid="analytics-range-custom">
|
||||
<Trans>Custom range…</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<PopoverContent
|
||||
ref={contentRef}
|
||||
align="end"
|
||||
className="w-auto p-0"
|
||||
// The select refocuses its trigger (asynchronously) as it closes, which
|
||||
// would otherwise dismiss the popover that has just opened and strand
|
||||
// keyboard focus outside it. Pointer interaction with the trigger still
|
||||
// dismisses the popover so the select can be reopened.
|
||||
onFocusOutside={(event) => {
|
||||
if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const content = contentRef.current;
|
||||
const firstTabbable = content?.querySelector<HTMLElement>(TABBABLE_SELECTOR);
|
||||
|
||||
(firstTabbable ?? content)?.focus();
|
||||
}}
|
||||
// There is no popover trigger element, so hand focus back to the select.
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div data-testid="analytics-range-calendar">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draft}
|
||||
onSelect={handleDaySelect}
|
||||
numberOfMonths={numberOfMonths}
|
||||
// Adjacent months would otherwise show the same days twice.
|
||||
showOutsideDays={false}
|
||||
defaultMonth={defaultMonth}
|
||||
fromDate={earliestDay.toJSDate()}
|
||||
toDate={today.toJSDate()}
|
||||
disabled={disabledDays}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2">
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{draftFrom && draftTo ? (
|
||||
<>
|
||||
{formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '}
|
||||
<Plural value={draftDays} one="# day" other="# days" />
|
||||
</>
|
||||
) : draftFrom ? (
|
||||
<Trans>Pick an end date</Trans>
|
||||
) : (
|
||||
<Trans>Pick a start date</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={closePicker}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
disabled={!draftFrom || !draftTo}
|
||||
data-testid="analytics-range-apply"
|
||||
>
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const CUSTOM_RANGE_VALUE = 'custom';
|
||||
|
||||
/** Tailwind `sm` breakpoint; two months are shown from here up. */
|
||||
const SM_BREAKPOINT = 640;
|
||||
|
||||
/** First element the popover should focus: the calendar's month navigation, then the days. */
|
||||
const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]';
|
||||
|
||||
const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({
|
||||
value,
|
||||
label: ANALYTICS_RANGE_LABELS[value],
|
||||
}));
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { AnalyticsQueryError } from './analytics-query-error';
|
||||
|
||||
export type AnalyticsStatCardProps = {
|
||||
icon: LucideIcon;
|
||||
title: ReactNode;
|
||||
value: ReactNode;
|
||||
description: ReactNode;
|
||||
badge?: ReactNode;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
onRetry: () => Promise<unknown>;
|
||||
testId: string;
|
||||
};
|
||||
|
||||
export const AnalyticsStatCard = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
badge,
|
||||
isLoading,
|
||||
isError,
|
||||
onRetry,
|
||||
testId,
|
||||
}: AnalyticsStatCardProps) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col p-5">
|
||||
<div className="flex items-center justify-between gap-x-3">
|
||||
<h3 className="font-medium text-muted-foreground text-sm">{title}</h3>
|
||||
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<AnalyticsQueryError onRetry={onRetry} className="mt-3" />
|
||||
) : isLoading ? (
|
||||
<div className="mt-3 flex flex-col gap-y-2">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<p className="font-semibold text-3xl text-foreground tabular-nums tracking-tight" data-testid={testId}>
|
||||
{value}
|
||||
</p>
|
||||
|
||||
{badge}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import type { AnalyticsQueryResult } from '~/utils/analytics';
|
||||
|
||||
import { AnalyticsQueryError } from './analytics-query-error';
|
||||
|
||||
export type AnalyticsStatusBreakdownCardProps = {
|
||||
query: AnalyticsQueryResult<TGetTeamAnalyticsStatusBreakdownResponse>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const { data, isLoading, isError, refetch } = query;
|
||||
|
||||
const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : [];
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col', className)} data-testid="analytics-status-breakdown">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>
|
||||
<Trans>Status breakdown</Trans>
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans>Documents created in this period</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<p className="shrink-0 whitespace-nowrap">
|
||||
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
|
||||
{data.total.toLocaleString(i18n.locale)}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<Trans>total</Trans>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
{isError ? (
|
||||
<AnalyticsQueryError onRetry={refetch} />
|
||||
) : isLoading || !data ? (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Skeleton className="h-2.5 w-full rounded-full" />
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{STATUS_ROWS.slice(0, 3).map((row) => (
|
||||
<Skeleton key={row.key} className="h-5 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : data.total === 0 ? (
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<StatusBar segments={[]} label={_(msg`No documents in this period`)} />
|
||||
|
||||
<p className="flex flex-1 items-center justify-center text-center text-muted-foreground text-sm">
|
||||
<Trans>No documents in this period</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<StatusBar segments={rows} label={_(msg`Document status distribution`)} />
|
||||
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{rows.map((row) => (
|
||||
<li key={row.key} className="flex items-center justify-between gap-x-3 py-2.5 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-x-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: row.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-foreground">{_(row.label)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-baseline gap-x-2 tabular-nums">
|
||||
<span className="font-medium text-foreground" data-testid={`analytics-status-${row.key}`}>
|
||||
{row.count.toLocaleString(i18n.locale)}
|
||||
</span>
|
||||
<span className="w-10 text-right text-muted-foreground">{row.percent}%</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type StatusBarProps = {
|
||||
segments: Array<{ key: string; percent: number; color: string }>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stacked horizontal bar. Segment widths come from the largest-remainder
|
||||
* percentages so they always add up to the full width; an empty list renders
|
||||
* the muted track on its own.
|
||||
*/
|
||||
const StatusBar = ({ segments, label }: StatusBarProps) => {
|
||||
return (
|
||||
<div className="flex h-2.5 w-full gap-px overflow-hidden rounded-full bg-muted" role="img" aria-label={label}>
|
||||
{segments.map((segment) => (
|
||||
<div
|
||||
key={segment.key}
|
||||
className="h-full"
|
||||
style={{ width: `${segment.percent}%`, backgroundColor: segment.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled';
|
||||
|
||||
type StatusRow = {
|
||||
key: StatusKey;
|
||||
label: MessageDescriptor;
|
||||
color: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single source of truth for status colours so the bar and the legend cannot drift.
|
||||
*/
|
||||
const STATUS_ROWS: StatusRow[] = [
|
||||
{ key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' },
|
||||
{ key: 'pending', label: msg`Pending`, color: '#f59e0b' },
|
||||
{ key: 'rejected', label: msg`Rejected`, color: '#ef4444' },
|
||||
{ key: 'cancelled', label: msg`Cancelled`, color: '#f97316' },
|
||||
{ key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Assign integer percentages to the non-zero rows using largest-remainder
|
||||
* allocation so the values always sum to exactly 100, with every non-zero row
|
||||
* shown as at least 1%.
|
||||
*/
|
||||
const allocatePercentages = <T extends { count: number }>(rows: T[]): Array<T & { percent: number }> => {
|
||||
const visibleRows = rows.filter((row) => row.count > 0);
|
||||
const total = visibleRows.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
if (total === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allocations = visibleRows.map((row, index) => {
|
||||
const exact = (row.count / total) * 100;
|
||||
const floored = Math.floor(exact);
|
||||
|
||||
return { index, percent: floored, remainder: exact - floored };
|
||||
});
|
||||
|
||||
let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0);
|
||||
|
||||
const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index);
|
||||
|
||||
for (const allocation of byRemainder) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allocation.percent += 1;
|
||||
remaining -= 1;
|
||||
}
|
||||
|
||||
// Every non-zero row must display at least 1%; take the difference from the largest rows.
|
||||
const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index);
|
||||
|
||||
for (const allocation of allocations) {
|
||||
if (allocation.percent > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allocation.percent = 1;
|
||||
|
||||
const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1);
|
||||
|
||||
if (donor) {
|
||||
donor.percent -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent }));
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import type { AnalyticsQueryResult } from '~/utils/analytics';
|
||||
import { formatRelativeDate } from '~/utils/analytics';
|
||||
|
||||
import { AnalyticsQueryError } from './analytics-query-error';
|
||||
|
||||
/** The template shape shared by the team and organisation procedures. */
|
||||
export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number];
|
||||
|
||||
export type AnalyticsTemplateUsageCardProps<TTemplate extends AnalyticsTemplate> = {
|
||||
query: AnalyticsQueryResult<{ templates: TTemplate[] }>;
|
||||
/** Where the template title links to. Return null to render a plain title. */
|
||||
getTemplateHref: (template: TTemplate) => string | null;
|
||||
/** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */
|
||||
renderTemplateMeta?: (template: TTemplate) => ReactNode;
|
||||
/** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */
|
||||
templatesHref?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsTemplateUsageCard = <TTemplate extends AnalyticsTemplate>({
|
||||
query,
|
||||
getTemplateHref,
|
||||
renderTemplateMeta,
|
||||
templatesHref,
|
||||
className,
|
||||
}: AnalyticsTemplateUsageCardProps<TTemplate>) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const { data, isLoading, isError, refetch } = query;
|
||||
|
||||
return (
|
||||
<Card className={className} data-testid="analytics-template-usage">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans>Template usage</Trans>
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans>Documents created from templates</Trans>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isError ? (
|
||||
<AnalyticsQueryError onRetry={refetch} />
|
||||
) : isLoading || !data ? (
|
||||
<ul className="flex flex-col gap-y-3">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<li key={index} className="flex items-center gap-x-3">
|
||||
<Skeleton className="h-4 w-5" />
|
||||
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-1.5">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : data.templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<FileTextIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="max-w-sm text-muted-foreground text-sm">
|
||||
<Trans>No documents were created from templates in this period</Trans>
|
||||
</p>
|
||||
|
||||
{templatesHref && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={templatesHref}>
|
||||
<Trans>View templates</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="flex flex-col divide-y divide-border">
|
||||
{data.templates.map((template, index) => {
|
||||
const href = template.title === null ? null : getTemplateHref(template);
|
||||
const meta = renderTemplateMeta?.(template);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={template.id}
|
||||
className="flex items-center gap-x-3 py-3 first:pt-0 last:pb-0"
|
||||
data-testid="analytics-template-row"
|
||||
>
|
||||
<span className="w-5 shrink-0 text-muted-foreground text-xs tabular-nums" aria-hidden="true">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<FileTextIcon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{template.title === null ? (
|
||||
<span className="truncate text-muted-foreground text-sm">
|
||||
<Trans>Unavailable template</Trans>
|
||||
</span>
|
||||
) : href !== null ? (
|
||||
<Link to={href} className="truncate font-medium text-foreground text-sm hover:underline">
|
||||
{template.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-medium text-foreground text-sm">{template.title}</span>
|
||||
)}
|
||||
|
||||
{(meta || template.updatedAt !== null) && (
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{meta}
|
||||
{meta && template.updatedAt !== null && ' · '}
|
||||
{template.updatedAt !== null && (
|
||||
<Trans>Updated {formatRelativeDate(template.updatedAt, i18n.locale)}</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 rounded-md border bg-muted px-2 py-0.5 font-medium text-foreground text-xs tabular-nums">
|
||||
<Plural value={template.count} one="# use" other="# uses" />
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -599,18 +599,10 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
|
||||
isVisibleCountCapped ? (
|
||||
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
|
||||
) : (
|
||||
<Plural
|
||||
value={totalVisibleCount}
|
||||
one="# result"
|
||||
other="# results"
|
||||
/>
|
||||
<Plural value={totalVisibleCount} one="# result" other="# results" />
|
||||
)
|
||||
) : (
|
||||
<Plural
|
||||
value={totalVisibleCount}
|
||||
one="# item"
|
||||
other="# items"
|
||||
/>
|
||||
<Plural value={totalVisibleCount} one="# item" other="# items" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
@@ -22,6 +25,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
const { organisations } = useSession();
|
||||
|
||||
const currentTeam = useOptionalCurrentTeam();
|
||||
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
|
||||
{
|
||||
@@ -37,18 +41,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
};
|
||||
|
||||
const menuNavigationLinks = useMemo(() => {
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
const navigationTeam =
|
||||
currentTeam ??
|
||||
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
|
||||
|
||||
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
|
||||
teamUrl = organisations[0].teams[0].url;
|
||||
}
|
||||
|
||||
if (!teamUrl) {
|
||||
if (!navigationTeam) {
|
||||
return [
|
||||
{
|
||||
href: '/inbox',
|
||||
text: t`Inbox`,
|
||||
},
|
||||
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
|
||||
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
|
||||
: []),
|
||||
{
|
||||
href: '/settings/profile',
|
||||
text: t`Settings`,
|
||||
@@ -56,6 +61,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
];
|
||||
}
|
||||
|
||||
const teamUrl = navigationTeam.url;
|
||||
|
||||
return [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
@@ -69,12 +76,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
href: '/inbox',
|
||||
text: t`Inbox`,
|
||||
},
|
||||
...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole)
|
||||
? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }]
|
||||
: []),
|
||||
{
|
||||
href: '/settings/profile',
|
||||
text: t`Settings`,
|
||||
},
|
||||
];
|
||||
}, [currentTeam, organisations]);
|
||||
}, [currentTeam, currentOrganisation, organisations, t]);
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: TRecipientLite;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
|
||||
|
||||
const onRecipientClick = () => {
|
||||
if (!signingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('my-1 flex items-center gap-2', {
|
||||
'cursor-pointer hover:underline': signingToken,
|
||||
})}
|
||||
role={signingToken ? 'button' : undefined}
|
||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
||||
onClick={onRecipientClick}
|
||||
>
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
||||
>
|
||||
<p>{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
@@ -10,16 +10,26 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
export const DocumentSearch = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
|
||||
const [{ query }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
query: documentsSearchParams.query,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setQuery(debouncedSearchTerm || null);
|
||||
// Reset pagination so a new search never lands on an empty page.
|
||||
void setSearchParams({
|
||||
query: debouncedSearchTerm || null,
|
||||
page: null,
|
||||
});
|
||||
}
|
||||
}, [debouncedSearchTerm, query, setQuery]);
|
||||
}, [debouncedSearchTerm, query, setSearchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ import {
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
|
||||
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
|
||||
import { canAccessTeamDocument, DocumentSignatureType, extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -97,7 +97,7 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
redirectUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
|
||||
.refine((value) => value === undefined || value === '' || isHttpUrl(value), {
|
||||
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
|
||||
}),
|
||||
language: z
|
||||
|
||||
@@ -31,6 +31,8 @@ type FilterPillCommonProps = {
|
||||
enableSearch?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
/** Whether the selection can be removed. Defaults to true. */
|
||||
clearable?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
@@ -61,7 +63,7 @@ export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
|
||||
* selections followed by a "+N more" chip.
|
||||
*/
|
||||
export const FilterPill = (props: FilterPillProps) => {
|
||||
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
|
||||
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, clearable = true, testId } = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -84,7 +86,7 @@ export const FilterPill = (props: FilterPillProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange(nextValue === props.value ? null : nextValue);
|
||||
props.onChange(nextValue === props.value && clearable ? null : nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -168,7 +170,7 @@ export const FilterPill = (props: FilterPillProps) => {
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{hasSelection && (
|
||||
{hasSelection && clearable && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
|
||||
@@ -7,9 +7,10 @@ export type CardMetricProps = {
|
||||
value?: string | number;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
|
||||
export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -29,7 +30,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
|
||||
</div>
|
||||
|
||||
{children || (
|
||||
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
|
||||
<p className="mt-auto font-semibold text-4xl text-foreground leading-8" data-testid={testId}>
|
||||
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -6,9 +6,13 @@ import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/
|
||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import {
|
||||
canAccessOrganisationAnalytics,
|
||||
canExecuteOrganisationAction,
|
||||
formatOrganisationAnalyticsPath,
|
||||
} from '@documenso/lib/utils/organisations';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -62,6 +66,13 @@ export const OrgMenuSwitcher = () => {
|
||||
|
||||
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
|
||||
|
||||
// Team analytics take precedence when in a team context, the team page links to organisation analytics.
|
||||
const analyticsPath = canAccessTeamSettings
|
||||
? formatAnalyticsPath(currentTeam.url)
|
||||
: currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
|
||||
? formatOrganisationAnalyticsPath(currentOrganisation.url)
|
||||
: null;
|
||||
|
||||
// Use hovered org for teams display if available,
|
||||
// otherwise use current team's org if in a team,
|
||||
// finally fallback to selected org
|
||||
@@ -271,6 +282,14 @@ export const OrgMenuSwitcher = () => {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{analyticsPath && (
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to={analyticsPath}>
|
||||
<Trans>Analytics</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link
|
||||
to={
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ChevronLeft, Loader } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export default function DocumentEditSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<Link to="/" className="flex grow-0 items-center text-documenso-700 hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 grow-0 truncate font-semibold text-2xl md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="flex h-10 items-center">
|
||||
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||
<div className="col-span-12 rounded-xl border-2 border-border bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7 dark:bg-background">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||
<Loader className="h-12 w-12 animate-spin text-documenso" />
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>Loading document...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 rounded-xl border-2 border-border bg-background before:rounded-xl lg:col-span-6 xl:col-span-5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
const ZIndexes: { [key: string]: string } = {
|
||||
@@ -14,9 +15,10 @@ export type StackAvatarProps = {
|
||||
zIndex?: string;
|
||||
fallbackText?: string;
|
||||
type: RecipientStatusType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
|
||||
let classes = '';
|
||||
let zIndexClass = '';
|
||||
const firstClass = first ? '' : '-ml-3';
|
||||
@@ -46,7 +48,14 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
|
||||
<Avatar
|
||||
className={cn(
|
||||
zIndexClass,
|
||||
firstClass,
|
||||
'h-10 w-10 border-2 border-white border-solid dark:border-border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import {
|
||||
getExtraRecipientsType,
|
||||
getRecipientType,
|
||||
RecipientStatusType,
|
||||
} from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
import { useMemo } from 'react';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
CheckIcon,
|
||||
CircleCheckIcon,
|
||||
CircleDashedIcon,
|
||||
CircleXIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
MailOpenIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
@@ -27,125 +44,238 @@ export const StackAvatarsWithTooltip = ({
|
||||
}: StackAvatarsWithTooltipProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
|
||||
);
|
||||
const sections = useMemo(() => {
|
||||
const groups = groupRecipientsByStatus(recipients);
|
||||
|
||||
const openedRecipients = recipients.filter((recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED);
|
||||
|
||||
const completedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
|
||||
);
|
||||
|
||||
const uncompletedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
|
||||
);
|
||||
|
||||
const rejectedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const otherRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
return [
|
||||
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
||||
...otherRecipients.sort((a, b) => {
|
||||
return a.id - b.id;
|
||||
}),
|
||||
];
|
||||
return RECIPIENT_STATUS_SECTIONS.map((section) => ({
|
||||
...section,
|
||||
recipients: groups[section.type],
|
||||
})).filter((section) => section.recipients.length > 0);
|
||||
}, [recipients]);
|
||||
|
||||
const canCopySigningLink = documentStatus === DocumentStatus.PENDING;
|
||||
|
||||
return (
|
||||
<PopoverHover
|
||||
trigger={children || <StackAvatars recipients={sortedRecipients} />}
|
||||
trigger={children || <RecipientAvatarStack recipients={recipients} />}
|
||||
contentProps={{
|
||||
className: 'flex flex-col gap-y-5 py-2',
|
||||
className:
|
||||
'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm',
|
||||
side: position,
|
||||
// Keep clear of the sticky app header (h-16, z-[60]) which paints above popovers.
|
||||
collisionPadding: { top: 72, bottom: 8, left: 8, right: 8 },
|
||||
// Opened via hover, so don't steal focus from wherever the user was.
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
}}
|
||||
>
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Completed</Trans>
|
||||
</h1>
|
||||
{completedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
{sections.map((section) => (
|
||||
<div key={section.type} className="px-3 py-2">
|
||||
<div className={cn('flex items-center gap-1.5 font-medium text-xs', section.className)}>
|
||||
<section.icon className="h-3.5 w-3.5 shrink-0" strokeWidth={2} />
|
||||
<span>{_(section.label)}</span>
|
||||
<span>{section.recipients.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
{section.recipients.map((recipient) => (
|
||||
<RecipientRow
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
recipient={recipient}
|
||||
signingToken={canCopySigningLink && section.hasSigningLink ? recipient.token : null}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rejectedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Waiting</Trans>
|
||||
</h1>
|
||||
{waitingRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Opened</Trans>
|
||||
</h1>
|
||||
{openedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Uncompleted</Trans>
|
||||
</h1>
|
||||
{uncompletedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</PopoverHover>
|
||||
);
|
||||
};
|
||||
|
||||
type RecipientRowProps = {
|
||||
recipient: TRecipientLite;
|
||||
signingToken: string | null;
|
||||
};
|
||||
|
||||
const RecipientRow = ({ recipient, signingToken }: RecipientRowProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => clearTimeout(copiedTimeoutRef.current ?? undefined), []);
|
||||
|
||||
const onCopySigningLink = () => {
|
||||
if (!signingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||
setIsCopied(true);
|
||||
|
||||
clearTimeout(copiedTimeoutRef.current ?? undefined);
|
||||
copiedTimeoutRef.current = setTimeout(() => setIsCopied(false), COPIED_INDICATOR_DURATION_MS);
|
||||
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StackAvatar
|
||||
first={true}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
className="h-6 w-6 shrink-0 border-0 text-[10px]"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1 text-xs leading-snug">
|
||||
<p className="truncate text-foreground">{recipient.email || recipient.name}</p>
|
||||
<p className="truncate text-muted-foreground">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!signingToken) {
|
||||
return <div className={recipientRowClassName}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
recipientRowClassName,
|
||||
'group w-[calc(100%+1rem)] cursor-pointer text-left transition-colors duration-300 hover:bg-muted',
|
||||
)}
|
||||
title={_(msg`Click to copy signing link for sending to recipient`)}
|
||||
onClick={onCopySigningLink}
|
||||
>
|
||||
{content}
|
||||
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-3 w-3 shrink-0 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const RecipientAvatarStack = ({ recipients }: { recipients: TRecipientLite[] }) => {
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const byId = (a: TRecipientLite, b: TRecipientLite) => a.id - b.id;
|
||||
|
||||
const rejected = recipients.filter((r) => getRecipientType(r) === RecipientStatusType.REJECTED);
|
||||
const others = recipients.filter((r) => getRecipientType(r) !== RecipientStatusType.REJECTED);
|
||||
|
||||
return [...rejected.sort(byId), ...others.sort(byId)];
|
||||
}, [recipients]);
|
||||
|
||||
const visibleRecipients = sortedRecipients.slice(0, MAX_VISIBLE_AVATARS);
|
||||
const hiddenRecipients = sortedRecipients.slice(MAX_VISIBLE_AVATARS);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleRecipients.map((recipient, index) => {
|
||||
const isOverflowSlot = index === MAX_VISIBLE_AVATARS - 1 && hiddenRecipients.length > 0;
|
||||
const zIndex = String(50 - index * 10);
|
||||
|
||||
if (isOverflowSlot) {
|
||||
return (
|
||||
<StackAvatar
|
||||
key="extra-recipients"
|
||||
first={index === 0}
|
||||
zIndex={zIndex}
|
||||
type={getExtraRecipientsType(sortedRecipients.slice(index))}
|
||||
fallbackText={`+${hiddenRecipients.length + 1}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
first={index === 0}
|
||||
zIndex={zIndex}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const groupRecipientsByStatus = (recipients: TRecipientLite[]) => {
|
||||
const groups: Record<RecipientStatusType, TRecipientLite[]> = {
|
||||
[RecipientStatusType.COMPLETED]: [],
|
||||
[RecipientStatusType.REJECTED]: [],
|
||||
[RecipientStatusType.WAITING]: [],
|
||||
[RecipientStatusType.OPENED]: [],
|
||||
[RecipientStatusType.UNSIGNED]: [],
|
||||
};
|
||||
|
||||
for (const recipient of recipients) {
|
||||
groups[getRecipientType(recipient)].push(recipient);
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
const COPIED_INDICATOR_DURATION_MS = 2000;
|
||||
|
||||
const recipientRowClassName = '-mx-2 flex items-center gap-2 rounded-md px-2 py-1';
|
||||
|
||||
type RecipientStatusSection = {
|
||||
type: RecipientStatusType;
|
||||
label: MessageDescriptor;
|
||||
icon: LucideIcon;
|
||||
className: string;
|
||||
/** Whether recipients in this section still need to sign, so a signing link can be copied. */
|
||||
hasSigningLink: boolean;
|
||||
};
|
||||
|
||||
const RECIPIENT_STATUS_SECTIONS: RecipientStatusSection[] = [
|
||||
{
|
||||
type: RecipientStatusType.COMPLETED,
|
||||
label: msg`Completed`,
|
||||
icon: CircleCheckIcon,
|
||||
className: 'text-green-600 dark:text-green-400',
|
||||
hasSigningLink: false,
|
||||
},
|
||||
{
|
||||
type: RecipientStatusType.REJECTED,
|
||||
label: msg`Rejected`,
|
||||
icon: CircleXIcon,
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
hasSigningLink: false,
|
||||
},
|
||||
{
|
||||
type: RecipientStatusType.WAITING,
|
||||
label: msg`Waiting`,
|
||||
icon: ClockIcon,
|
||||
className: 'text-blue-600 dark:text-blue-400',
|
||||
hasSigningLink: true,
|
||||
},
|
||||
{
|
||||
type: RecipientStatusType.OPENED,
|
||||
label: msg`Opened`,
|
||||
icon: MailOpenIcon,
|
||||
className: 'text-amber-600 dark:text-amber-400',
|
||||
hasSigningLink: true,
|
||||
},
|
||||
{
|
||||
type: RecipientStatusType.UNSIGNED,
|
||||
label: msg`Uncompleted`,
|
||||
icon: CircleDashedIcon,
|
||||
className: 'text-muted-foreground',
|
||||
hasSigningLink: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { getExtraRecipientsType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
|
||||
const renderStackAvatars = (recipients: TRecipientLite[]) => {
|
||||
const zIndex = 50;
|
||||
const itemsToRender = recipients.slice(0, 5);
|
||||
const remainingItems = recipients.length - itemsToRender.length;
|
||||
|
||||
return itemsToRender.map((recipient, index: number) => {
|
||||
const first = index === 0;
|
||||
|
||||
if (index === 4 && remainingItems > 0) {
|
||||
return (
|
||||
<StackAvatar
|
||||
key="extra-recipient"
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={getExtraRecipientsType(recipients.slice(4))}
|
||||
fallbackText={`+${remainingItems + 1}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return <>{renderStackAvatars(recipients)}</>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
export const TemplateSearch = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [{ query }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
query: templatesSearchParams.query,
|
||||
page: templatesSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setSearchParams({
|
||||
query: debouncedSearchTerm || null,
|
||||
page: null,
|
||||
});
|
||||
}
|
||||
}, [debouncedSearchTerm, query, setSearchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search templates...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
data-testid="templates-search-input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { VerifiedIcon } from '@documenso/ui/icons/verified';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { File, User2 } from 'lucide-react';
|
||||
|
||||
export type UserProfileSkeletonProps = {
|
||||
className?: string;
|
||||
user: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
|
||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background', className)}>
|
||||
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-muted-foreground text-sm lowercase">
|
||||
{baseUrl.host}/u/{user.url}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="rounded-full bg-primary/10 p-1.5">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
|
||||
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<h2 className="max-w-[12rem] truncate font-semibold text-2xl">{user.name}</h2>
|
||||
|
||||
<VerifiedIcon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
|
||||
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full">
|
||||
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
|
||||
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
|
||||
<Trans>Documents</Trans>
|
||||
</div>
|
||||
|
||||
{Array(rows)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-x-6 bg-background p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
|
||||
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Button type="button" size="sm" className="pointer-events-none w-32">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
@@ -14,13 +14,26 @@ import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
type DocumentsTableStatusFilterProps = {
|
||||
stats: TFindDocumentsInternalResponse['stats'];
|
||||
/**
|
||||
* Per-status document counts, shown next to each option. When omitted no
|
||||
* counts are rendered.
|
||||
*/
|
||||
stats?: TFindDocumentsInternalResponse['stats'];
|
||||
|
||||
/**
|
||||
* The statuses available for selection. Defaults to every status that
|
||||
* makes sense for the documents page.
|
||||
*/
|
||||
statuses?: ExtendedDocumentStatus[];
|
||||
};
|
||||
|
||||
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
|
||||
export const DocumentsTableStatusFilter = ({
|
||||
stats,
|
||||
statuses = SELECTABLE_STATUSES,
|
||||
}: DocumentsTableStatusFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const organisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const [{ status }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
@@ -32,14 +45,14 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
|
||||
|
||||
const selectableStatuses = useMemo(
|
||||
() =>
|
||||
SELECTABLE_STATUSES.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
statuses.filter((value) => {
|
||||
if (organisation?.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[organisation.type],
|
||||
[organisation?.type, statuses],
|
||||
);
|
||||
|
||||
const selectedStatus = useMemo(
|
||||
@@ -65,20 +78,22 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
|
||||
options={selectableStatuses.map((value) => ({
|
||||
value,
|
||||
label: <DocumentStatus status={value} />,
|
||||
trailing: formatStatsCount(stats[value]),
|
||||
trailing: stats ? formatStatsCount(stats[value]) : undefined,
|
||||
}))}
|
||||
testId="documents-table-status-filter"
|
||||
/>
|
||||
|
||||
{/* Visually hidden document counts, for screen readers and tests. */}
|
||||
<span className="sr-only" data-testid="documents-status-counts">
|
||||
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
|
||||
<span key={value}>
|
||||
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
|
||||
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{stats && (
|
||||
<span className="sr-only" data-testid="documents-status-counts">
|
||||
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
|
||||
<span key={value}>
|
||||
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
|
||||
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,22 +16,17 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo, useTransition } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||
|
||||
export type DocumentsTableProps = {
|
||||
data?: TFindInboxResponse;
|
||||
isLoading?: boolean;
|
||||
isLoadingError?: boolean;
|
||||
};
|
||||
|
||||
type DocumentsTableRow = TFindInboxResponse['data'][number];
|
||||
|
||||
export const InboxTable = () => {
|
||||
@@ -40,17 +35,24 @@ export const InboxTable = () => {
|
||||
const team = useOptionalCurrentTeam();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
const [findInboxSearchParams] = useQueryStates(inboxSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
|
||||
const status = resolveInboxStatus(findInboxSearchParams.status);
|
||||
const query = findInboxSearchParams.query ?? '';
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
|
||||
page: page || 1,
|
||||
perPage: perPage || 10,
|
||||
page: Math.max(findInboxSearchParams.page ?? 1, 1),
|
||||
perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100),
|
||||
query: query || undefined,
|
||||
status,
|
||||
});
|
||||
|
||||
const hasSearchQuery = query.trim().length > 0;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -123,7 +125,20 @@ export const InboxTable = () => {
|
||||
emptyState={
|
||||
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
<p>
|
||||
<Trans>Documents that require your attention will appear here</Trans>
|
||||
{match({ hasSearchQuery, status })
|
||||
.with({ hasSearchQuery: true }, () => <Trans>No documents match your search</Trans>)
|
||||
.with({ status: DocumentStatusEnum.COMPLETED }, () => (
|
||||
<Trans>Documents that you have completed will appear here</Trans>
|
||||
))
|
||||
.with({ status: DocumentStatusEnum.REJECTED }, () => (
|
||||
<Trans>Documents that have been rejected will appear here</Trans>
|
||||
))
|
||||
.with({ status: DocumentStatusEnum.CANCELLED }, () => (
|
||||
<Trans>Documents that have been cancelled will appear here</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Documents that require your attention will appear here</Trans>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -101,23 +100,6 @@ 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,7 +170,10 @@ export const TemplatesTableActionDropdown = ({
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
envelopeType="template"
|
||||
onSuccess={async () => {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
type TemplatesTableOwnerFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const TemplatesTableOwnerFilter = ({ teamId }: TemplatesTableOwnerFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [{ ownerIds }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
ownerIds: templatesSearchParams.ownerIds,
|
||||
page: templatesSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectedOwnerIds = (ownerIds ?? []).map((ownerId) => ownerId.toString());
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const options = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newOwnerIds: string[]) => {
|
||||
void setSearchParams({
|
||||
ownerIds: newOwnerIds.length > 0 ? newOwnerIds.map(Number) : null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
multiple
|
||||
icon={UserIcon}
|
||||
label={<Trans>Owner</Trans>}
|
||||
value={selectedOwnerIds}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
enableSearch
|
||||
searchPlaceholder={_(msg`Search members...`)}
|
||||
loading={!isMounted || isLoading}
|
||||
testId="templates-table-owner-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Building2Icon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { TEMPLATES_VIEW_VALUES, templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ value: 'team', label: <Trans>Team</Trans> },
|
||||
{ value: 'organisation', label: <Trans>Organisation</Trans> },
|
||||
];
|
||||
|
||||
export const TemplatesTableViewFilter = () => {
|
||||
const [{ view }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
view: templatesSearchParams.view,
|
||||
ownerIds: templatesSearchParams.ownerIds,
|
||||
page: templatesSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const onChange = (newView: string | null) => {
|
||||
// The owner filter only applies to the team view, so drop it on any view change.
|
||||
void setSearchParams({
|
||||
view: TEMPLATES_VIEW_VALUES.find((value) => value === newView) ?? null,
|
||||
ownerIds: null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
icon={Building2Icon}
|
||||
label={<Trans>View</Trans>}
|
||||
value={view}
|
||||
onChange={onChange}
|
||||
options={VIEW_OPTIONS}
|
||||
testId="templates-table-view-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-14
@@ -3,10 +3,8 @@ 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';
|
||||
@@ -65,19 +63,9 @@ 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, 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 }),
|
||||
]);
|
||||
organisations = await getOrganisationSession({ userId: session.user.id });
|
||||
}
|
||||
|
||||
return data(
|
||||
@@ -95,7 +83,6 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
organisations: organisations || [],
|
||||
twoFactorEnforcement,
|
||||
}
|
||||
: null,
|
||||
publicEnv: createPublicEnv(),
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
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 { useEffect } from 'react';
|
||||
import { Link, Outlet, redirect, useLocation, useNavigate } from 'react-router';
|
||||
import { Link, Outlet, redirect } 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';
|
||||
|
||||
/**
|
||||
* Builds the enrolment redirect for an instance-blocked user, carrying the
|
||||
* current path so they land back where they were after enrolling.
|
||||
* Don't revalidate (run the loader on sequential navigations)
|
||||
*
|
||||
* Update values via providers.
|
||||
*/
|
||||
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 const shouldRevalidate = () => false;
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const [session, banner] = await Promise.all([
|
||||
@@ -50,22 +37,6 @@ 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,
|
||||
};
|
||||
@@ -74,51 +45,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
|
||||
const { banner } = loaderData;
|
||||
|
||||
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 { user, organisations } = useSession();
|
||||
|
||||
const { layoutMode } = useChildRouteFlags();
|
||||
|
||||
@@ -153,65 +80,6 @@ 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
|
||||
@@ -244,8 +112,6 @@ 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,7 +6,6 @@ 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';
|
||||
|
||||
@@ -32,8 +31,6 @@ export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentPro
|
||||
<AdminSiteBannerSection banner={banner} />
|
||||
|
||||
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
|
||||
|
||||
<AdminTwoFactorEnforcementSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -76,11 +75,6 @@ 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();
|
||||
@@ -234,7 +228,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-16 flex flex-col gap-4">
|
||||
{canResetTwoFactor && <AdminUserResetTwoFactorDialog user={user} />}
|
||||
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
|
||||
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
|
||||
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
|
||||
{user && <AdminUserDeleteDialog user={user} />}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { InboxIcon } from 'lucide-react';
|
||||
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
|
||||
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
|
||||
import { InboxTable } from '~/components/tables/inbox-table';
|
||||
import { INBOX_SELECTABLE_STATUSES } from '~/utils/inbox-search-params';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
@@ -26,6 +29,14 @@ export default function InboxPage() {
|
||||
<OrganisationInvitations className="mt-4" />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||
<div className="w-56">
|
||||
<DocumentSearch />
|
||||
</div>
|
||||
|
||||
<DocumentsTableStatusFilter statuses={INBOX_SELECTABLE_STATUSES} />
|
||||
</div>
|
||||
|
||||
<InboxTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import {
|
||||
canAccessOrganisationAnalytics,
|
||||
canExecuteOrganisationAction,
|
||||
formatOrganisationAnalyticsPath,
|
||||
} from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteTeamAction, formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRight,
|
||||
BarChart3Icon,
|
||||
CalendarIcon,
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
@@ -114,11 +119,22 @@ export default function OrganisationSettingsTeamsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}/settings/general`}>
|
||||
<Trans>Manage Organisation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={formatOrganisationAnalyticsPath(organisation.url)}>
|
||||
<BarChart3Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Analytics</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}/settings/general`}>
|
||||
<Trans>Manage Organisation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { formatAnalyticsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import type { AnalyticsActivityRow } from '~/components/general/analytics/analytics-activity-table-card';
|
||||
import { AnalyticsActivityTableCard } from '~/components/general/analytics/analytics-activity-table-card';
|
||||
import { AnalyticsDocumentsOverTimeCard } from '~/components/general/analytics/analytics-documents-over-time-card';
|
||||
import { AnalyticsHydrateFallback } from '~/components/general/analytics/analytics-hydrate-fallback';
|
||||
import { AnalyticsNoActivityAlert } from '~/components/general/analytics/analytics-no-activity-alert';
|
||||
import { AnalyticsOverviewCards } from '~/components/general/analytics/analytics-overview-cards';
|
||||
import { AnalyticsPageHeader } from '~/components/general/analytics/analytics-page-header';
|
||||
import { AnalyticsStatusBreakdownCard } from '~/components/general/analytics/analytics-status-breakdown-card';
|
||||
import { AnalyticsTemplateUsageCard } from '~/components/general/analytics/analytics-template-usage-card';
|
||||
import { resolveBrowserTimezone, useAnalyticsRange } from '~/utils/analytics';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.analytics._index';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Analytics`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organisation analytics are restricted to organisation admins (not managers),
|
||||
* matching the tRPC procedures.
|
||||
*/
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const session = await getSession(request);
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
...buildOrganisationWhereQuery({
|
||||
organisationId: undefined,
|
||||
userId: session.user.id,
|
||||
roles: [OrganisationMemberRole.ADMIN],
|
||||
}),
|
||||
url: params.orgUrl,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw redirect(`/o/${params.orgUrl}`);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The timezone is read from the browser so the analytics queries only run on the
|
||||
* client, after hydration, with the correct day boundaries.
|
||||
*/
|
||||
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
|
||||
await serverLoader();
|
||||
|
||||
return { timezone: resolveBrowserTimezone() };
|
||||
}
|
||||
|
||||
clientLoader.hydrate = true as const;
|
||||
|
||||
export function HydrateFallback() {
|
||||
return <AnalyticsHydrateFallback />;
|
||||
}
|
||||
|
||||
export default function OrganisationAnalyticsPage({ loaderData }: Route.ComponentProps) {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const { _ } = useLingui();
|
||||
const { timezone } = loaderData;
|
||||
|
||||
const { value: range, rangeKey, setValue: setRange } = useAnalyticsRange();
|
||||
|
||||
// `from`/`to` are only present for custom ranges.
|
||||
const queryInput = { organisationId: organisation.id, timezone, ...range };
|
||||
|
||||
const overviewQuery = trpc.organisation.analytics.getOverview.useQuery(queryInput);
|
||||
const documentsOverTimeQuery = trpc.organisation.analytics.getDocumentsOverTime.useQuery(queryInput);
|
||||
const statusBreakdownQuery = trpc.organisation.analytics.getStatusBreakdown.useQuery(queryInput);
|
||||
const templateUsageQuery = trpc.organisation.analytics.getTemplateUsage.useQuery({
|
||||
...queryInput,
|
||||
limit: TEMPLATE_LIMIT,
|
||||
});
|
||||
const teamActivityQuery = trpc.organisation.analytics.getTeamActivity.useQuery(queryInput);
|
||||
|
||||
const hasNoActivity =
|
||||
overviewQuery.isSuccess &&
|
||||
documentsOverTimeQuery.isSuccess &&
|
||||
overviewQuery.data.sent.current === 0 &&
|
||||
overviewQuery.data.sent.previous === 0 &&
|
||||
documentsOverTimeQuery.data.total === 0;
|
||||
|
||||
const teamRows: AnalyticsActivityRow[] = (teamActivityQuery.data?.teams ?? []).map((team) => ({
|
||||
key: team.id,
|
||||
avatar: { imageId: team.avatarImageId, fallback: team.name.slice(0, 1).toUpperCase() },
|
||||
title: team.name,
|
||||
subtitle: `/t/${team.url}`,
|
||||
sent: team.sent,
|
||||
completed: team.completed,
|
||||
pending: team.pending,
|
||||
completionRate: team.completionRate,
|
||||
lastActiveAt: team.lastActiveAt,
|
||||
href: formatAnalyticsPath(team.url),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AnalyticsPageHeader
|
||||
avatarImageId={organisation.avatarImageId}
|
||||
name={organisation.name}
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
/>
|
||||
|
||||
{hasNoActivity && (
|
||||
<AnalyticsNoActivityAlert range={range.range} onShowLastYear={() => setRange({ range: '12m' })} />
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<AnalyticsOverviewCards
|
||||
query={overviewQuery}
|
||||
entity={{
|
||||
icon: UsersIcon,
|
||||
title: <Trans>Teams</Trans>,
|
||||
testId: 'analytics-teams',
|
||||
select: (data) => data.teams,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<AnalyticsDocumentsOverTimeCard range={range} query={documentsOverTimeQuery} className="lg:col-span-2" />
|
||||
<AnalyticsStatusBreakdownCard query={statusBreakdownQuery} />
|
||||
</div>
|
||||
|
||||
<AnalyticsTemplateUsageCard
|
||||
query={templateUsageQuery}
|
||||
getTemplateHref={(template) =>
|
||||
template.team !== null && template.envelopeId !== null
|
||||
? `${formatTemplatesPath(template.team.url)}/${template.envelopeId}`
|
||||
: null
|
||||
}
|
||||
renderTemplateMeta={(template) => template.team?.name}
|
||||
/>
|
||||
|
||||
<AnalyticsActivityTableCard
|
||||
query={teamActivityQuery}
|
||||
rows={teamRows}
|
||||
rangeKey={rangeKey}
|
||||
title={<Trans>Team activity</Trans>}
|
||||
description={<Trans>Documents sent by each team in this period</Trans>}
|
||||
columnLabel={<Trans>Team</Trans>}
|
||||
renderSummary={(count, activeCount) => (
|
||||
<>
|
||||
<Plural value={count} one="# team" other="# teams" /> · <Trans>{activeCount} active this period</Trans>
|
||||
</>
|
||||
)}
|
||||
renderShowing={(visibleCount, totalCount) => (
|
||||
<Trans>
|
||||
Showing {visibleCount} of {totalCount} teams
|
||||
</Trans>
|
||||
)}
|
||||
emptyLabel={<Trans>No teams</Trans>}
|
||||
searchPlaceholder={_(msg`Search teams`)}
|
||||
noSearchResultsLabel={<Trans>No teams match your search</Trans>}
|
||||
testIdPrefix="team"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TEMPLATE_LIMIT = 5;
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -30,19 +29,6 @@ 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">
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction, formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { ArrowRightIcon, UsersIcon } from 'lucide-react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import type { AnalyticsActivityRow } from '~/components/general/analytics/analytics-activity-table-card';
|
||||
import { AnalyticsActivityTableCard } from '~/components/general/analytics/analytics-activity-table-card';
|
||||
import { AnalyticsDocumentsOverTimeCard } from '~/components/general/analytics/analytics-documents-over-time-card';
|
||||
import { AnalyticsHydrateFallback } from '~/components/general/analytics/analytics-hydrate-fallback';
|
||||
import { AnalyticsNoActivityAlert } from '~/components/general/analytics/analytics-no-activity-alert';
|
||||
import { AnalyticsOverviewCards } from '~/components/general/analytics/analytics-overview-cards';
|
||||
import { AnalyticsPageHeader } from '~/components/general/analytics/analytics-page-header';
|
||||
import { AnalyticsStatusBreakdownCard } from '~/components/general/analytics/analytics-status-breakdown-card';
|
||||
import { AnalyticsTemplateUsageCard } from '~/components/general/analytics/analytics-template-usage-card';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { resolveBrowserTimezone, useAnalyticsRange } from '~/utils/analytics';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/analytics._index';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Analytics`);
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const session = await getSession(request);
|
||||
// `getTeamByUrl` throws when the user isn't a member; treat that like any other
|
||||
// denial so the documents route renders its "Team not found" state instead of a 500.
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl }).catch(() => null);
|
||||
|
||||
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
throw redirect(formatDocumentsPath(params.teamUrl));
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The timezone is read from the browser so the analytics queries only run on the
|
||||
* client, after hydration, with the correct day boundaries.
|
||||
*/
|
||||
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
|
||||
await serverLoader();
|
||||
|
||||
return { timezone: resolveBrowserTimezone() };
|
||||
}
|
||||
|
||||
clientLoader.hydrate = true as const;
|
||||
|
||||
export function HydrateFallback() {
|
||||
return <AnalyticsHydrateFallback />;
|
||||
}
|
||||
|
||||
export default function TeamAnalyticsPage({ loaderData }: Route.ComponentProps) {
|
||||
const team = useCurrentTeam();
|
||||
const { _ } = useLingui();
|
||||
const organisation = useCurrentOrganisation();
|
||||
const { timezone } = loaderData;
|
||||
|
||||
const { value: range, rangeKey, setValue: setRange } = useAnalyticsRange();
|
||||
|
||||
// `from`/`to` are only present for custom ranges.
|
||||
const queryInput = { teamId: team.id, timezone, ...range };
|
||||
|
||||
const overviewQuery = trpc.team.analytics.getOverview.useQuery(queryInput);
|
||||
const documentsOverTimeQuery = trpc.team.analytics.getDocumentsOverTime.useQuery(queryInput);
|
||||
const statusBreakdownQuery = trpc.team.analytics.getStatusBreakdown.useQuery(queryInput);
|
||||
const templateUsageQuery = trpc.team.analytics.getTemplateUsage.useQuery({ ...queryInput, limit: TEMPLATE_LIMIT });
|
||||
const memberActivityQuery = trpc.team.analytics.getMemberActivity.useQuery(queryInput);
|
||||
|
||||
const hasNoActivity =
|
||||
overviewQuery.isSuccess &&
|
||||
documentsOverTimeQuery.isSuccess &&
|
||||
overviewQuery.data.sent.current === 0 &&
|
||||
overviewQuery.data.sent.previous === 0 &&
|
||||
documentsOverTimeQuery.data.total === 0;
|
||||
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
const memberRows: AnalyticsActivityRow[] = (memberActivityQuery.data?.members ?? []).map((member) => ({
|
||||
key: member.userId,
|
||||
avatar: { imageId: member.avatarImageId, fallback: formatMemberInitials(member.name, member.email) },
|
||||
title: member.name || member.email,
|
||||
subtitle: member.name ? member.email : null,
|
||||
sent: member.sent,
|
||||
completed: member.completed,
|
||||
pending: member.pending,
|
||||
completionRate: member.completionRate,
|
||||
lastActiveAt: member.lastActiveAt,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<AnalyticsPageHeader
|
||||
className="mt-8"
|
||||
avatarImageId={team.avatarImageId}
|
||||
name={team.name}
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
actions={
|
||||
canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && (
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground" asChild>
|
||||
<Link to={formatOrganisationAnalyticsPath(organisation.url)}>
|
||||
<Trans>View organisation analytics</Trans>
|
||||
<ArrowRightIcon className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{hasNoActivity && (
|
||||
<AnalyticsNoActivityAlert range={range.range} onShowLastYear={() => setRange({ range: '12m' })} />
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<AnalyticsOverviewCards
|
||||
query={overviewQuery}
|
||||
entity={{
|
||||
icon: UsersIcon,
|
||||
title: <Trans>Members</Trans>,
|
||||
testId: 'analytics-members',
|
||||
select: (data) => data.members,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<AnalyticsDocumentsOverTimeCard range={range} query={documentsOverTimeQuery} className="lg:col-span-2" />
|
||||
<AnalyticsStatusBreakdownCard query={statusBreakdownQuery} />
|
||||
</div>
|
||||
|
||||
<AnalyticsTemplateUsageCard
|
||||
query={templateUsageQuery}
|
||||
templatesHref={templatesPath}
|
||||
getTemplateHref={(template) =>
|
||||
template.envelopeId !== null ? `${templatesPath}/${template.envelopeId}` : null
|
||||
}
|
||||
/>
|
||||
|
||||
<AnalyticsActivityTableCard
|
||||
query={memberActivityQuery}
|
||||
rows={memberRows}
|
||||
rangeKey={rangeKey}
|
||||
title={<Trans>Member activity</Trans>}
|
||||
description={<Trans>Documents sent by each member in this period</Trans>}
|
||||
columnLabel={<Trans>Member</Trans>}
|
||||
renderSummary={(count, activeCount) => (
|
||||
<>
|
||||
<Plural value={count} one="# member" other="# members" /> ·{' '}
|
||||
<Trans>{activeCount} active this period</Trans>
|
||||
</>
|
||||
)}
|
||||
renderShowing={(visibleCount, totalCount) => (
|
||||
<Trans>
|
||||
Showing {visibleCount} of {totalCount} members
|
||||
</Trans>
|
||||
)}
|
||||
emptyLabel={<Trans>No members</Trans>}
|
||||
searchPlaceholder={_(msg`Search members`)}
|
||||
noSearchResultsLabel={<Trans>No members match your search</Trans>}
|
||||
testIdPrefix="member"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TEMPLATE_LIMIT = 5;
|
||||
|
||||
const formatMemberInitials = (name: string | null, email: string) => {
|
||||
const initials = name ? extractInitials(name) : '';
|
||||
|
||||
return initials || email.slice(0, 1).toUpperCase();
|
||||
};
|
||||
@@ -5,26 +5,28 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { Bird, XIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { TemplateSearch } from '~/components/general/template/template-search';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { TemplatesTableOwnerFilter } from '~/components/tables/templates-table-owner-filter';
|
||||
import { TemplatesTableViewFilter } from '~/components/tables/templates-table-view-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
||||
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Templates`);
|
||||
@@ -39,15 +41,26 @@ export default function TemplatesPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [findTemplateSearchParams, setFindTemplateSearchParams] = useQueryStates(templatesSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
const page = findTemplateSearchParams.page || undefined;
|
||||
const perPage = findTemplateSearchParams.perPage || undefined;
|
||||
const query = findTemplateSearchParams.query || undefined;
|
||||
const ownerIds = findTemplateSearchParams.ownerIds ?? undefined;
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
const isOrgView = findTemplateSearchParams.view === 'organisation';
|
||||
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
|
||||
const hasActiveFilters = Boolean(ownerIds?.length);
|
||||
const isSearchingOrFiltering = hasActiveFilters || Boolean(query);
|
||||
|
||||
const isOrgView = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
const onResetFilters = () => {
|
||||
void setFindTemplateSearchParams({
|
||||
ownerIds: null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
// Scoped by team so selections made in one team never leak into another.
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
@@ -64,11 +77,13 @@ export default function TemplatesPage() {
|
||||
const documentRootPath = formatDocumentsPath(team.url);
|
||||
const templateRootPath = formatTemplatesPath(team.url);
|
||||
|
||||
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
||||
const teamTemplatesQuery = trpc.template.findTemplatesInternal.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
query,
|
||||
ownerIds,
|
||||
},
|
||||
{
|
||||
enabled: !isOrgView,
|
||||
@@ -79,6 +94,7 @@ export default function TemplatesPage() {
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
query,
|
||||
},
|
||||
{
|
||||
enabled: isOrgView,
|
||||
@@ -87,14 +103,6 @@ export default function TemplatesPage() {
|
||||
|
||||
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
|
||||
|
||||
const handleViewChange = (newView: string) => {
|
||||
if (newView !== 'team' && newView !== 'organisation') {
|
||||
return;
|
||||
}
|
||||
|
||||
void setView(newView === 'team' ? null : newView);
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
@@ -112,31 +120,25 @@ export default function TemplatesPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{showOrgTab && (
|
||||
<div className="mt-6">
|
||||
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
className="min-w-[60px] hover:text-foreground"
|
||||
value="team"
|
||||
data-testid="template-tab-team"
|
||||
>
|
||||
<Trans>Team</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="min-w-[60px] hover:text-foreground"
|
||||
value="organisation"
|
||||
data-testid="template-tab-organisation"
|
||||
>
|
||||
<Trans>Organisation</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||
<div className="w-56">
|
||||
<TemplateSearch />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOrgFilter && <TemplatesTableViewFilter />}
|
||||
|
||||
{!isOrgView && <TemplatesTableOwnerFilter teamId={team.id} />}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{activeQuery.data && activeQuery.data.count === 0 ? (
|
||||
{activeQuery.data && activeQuery.data.count === 0 && !isSearchingOrFiltering ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
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,12 +32,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
organisation: {
|
||||
select: {
|
||||
name: true,
|
||||
organisationGlobalSettings: {
|
||||
select: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -77,10 +71,6 @@ 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,
|
||||
@@ -88,8 +78,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -153,8 +141,6 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
organisationName={data.organisationName}
|
||||
userExists={data.userExists}
|
||||
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
|
||||
organisationTwoFactorRequired={data.organisationTwoFactorRequired}
|
||||
organisationTwoFactorGracePeriodDays={data.organisationTwoFactorGracePeriodDays}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -165,8 +151,6 @@ type PendingInvitationProps = {
|
||||
organisationName: string;
|
||||
userExists: boolean;
|
||||
isSessionUserTheInvitedUser: boolean;
|
||||
organisationTwoFactorRequired: boolean;
|
||||
organisationTwoFactorGracePeriodDays: number;
|
||||
};
|
||||
|
||||
type InvitationResult = 'idle' | 'accepted' | 'declined';
|
||||
@@ -179,8 +163,6 @@ const PendingInvitation = ({
|
||||
organisationName,
|
||||
userExists,
|
||||
isSessionUserTheInvitedUser,
|
||||
organisationTwoFactorRequired,
|
||||
organisationTwoFactorGracePeriodDays,
|
||||
}: PendingInvitationProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -307,24 +289,6 @@ 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,12 +86,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
organisationGlobalSettings: {
|
||||
select: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,10 +107,6 @@ 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;
|
||||
}
|
||||
@@ -276,26 +266,6 @@ 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,9 +137,6 @@ 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 (
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import {
|
||||
ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK,
|
||||
ZAnalyticsDateSchema,
|
||||
ZTeamAnalyticsRangeSchema,
|
||||
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { DateTime, IANAZone, Interval } from 'luxon';
|
||||
import { createParser, parseAsStringEnum, useQueryStates } from 'nuqs';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* The subset of a tRPC query result the analytics cards need. The queries live on
|
||||
* the page (team or organisation) so the cards stay scope-agnostic.
|
||||
*/
|
||||
export type AnalyticsQueryResult<TData> = {
|
||||
data: TData | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type TAnalyticsPresetRange = Exclude<TTeamAnalyticsRange, 'custom'>;
|
||||
|
||||
/**
|
||||
* A fully specified analytics range: either a preset, or a custom inclusive
|
||||
* [from, to] window of yyyy-MM-dd calendar dates in the browser timezone.
|
||||
*/
|
||||
export type AnalyticsRangeValue =
|
||||
| { range: TAnalyticsPresetRange; from?: undefined; to?: undefined }
|
||||
| { range: 'custom'; from: string; to: string };
|
||||
|
||||
export const ANALYTICS_PRESET_RANGES: TAnalyticsPresetRange[] = ZTeamAnalyticsRangeSchema.options.filter(
|
||||
(range): range is TAnalyticsPresetRange => range !== 'custom',
|
||||
);
|
||||
|
||||
export const ANALYTICS_RANGE_LABELS: Record<TTeamAnalyticsRange, MessageDescriptor> = {
|
||||
'7d': msg`Last 7 days`,
|
||||
'30d': msg`Last 30 days`,
|
||||
'90d': msg`Last 90 days`,
|
||||
'12m': msg`Last 12 months`,
|
||||
custom: msg`Custom range`,
|
||||
};
|
||||
|
||||
export const ANALYTICS_NO_ACTIVITY_LABELS: Record<TTeamAnalyticsRange, MessageDescriptor> = {
|
||||
'7d': msg`No activity in the last 7 days.`,
|
||||
'30d': msg`No activity in the last 30 days.`,
|
||||
'90d': msg`No activity in the last 90 days.`,
|
||||
'12m': msg`No activity in the last 12 months.`,
|
||||
custom: msg`No activity in the selected range.`,
|
||||
};
|
||||
|
||||
const DEFAULT_ANALYTICS_RANGE: TAnalyticsPresetRange = '30d';
|
||||
|
||||
/**
|
||||
* The browser's IANA timezone, falling back to UTC when it cannot be resolved or
|
||||
* is not a zone luxon recognises. Client-only.
|
||||
*/
|
||||
export const resolveBrowserTimezone = () => {
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
return IANAZone.isValidZone(browserTimezone) ? browserTimezone : 'UTC';
|
||||
};
|
||||
|
||||
export const formatRelativeDate = (date: Date, locale: string) => {
|
||||
return DateTime.fromJSDate(date).setLocale(locale).toRelative() ?? '';
|
||||
};
|
||||
|
||||
/** Parse a strict yyyy-MM-dd calendar date at local midnight, null when invalid. */
|
||||
export const parseAnalyticsDate = (value: string): DateTime | null => {
|
||||
if (!ZAnalyticsDateSchema.safeParse(value).success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = DateTime.fromISO(value);
|
||||
|
||||
// Round-trip guard so overflowing dates such as 2023-02-30 are rejected.
|
||||
if (!parsed.isValid || parsed.toISODate() !== value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.startOf('day');
|
||||
};
|
||||
|
||||
/** Format a local `Date` (e.g. one picked in the calendar) as yyyy-MM-dd. */
|
||||
export const formatAnalyticsDate = (date: Date): string => {
|
||||
return DateTime.fromJSDate(date).toISODate() ?? '';
|
||||
};
|
||||
|
||||
/** Human readable inclusive span, e.g. "Feb 1 – 29, 2024". */
|
||||
export const formatAnalyticsDateRange = (from: string, to: string, locale: string): string => {
|
||||
const start = DateTime.fromISO(from);
|
||||
const end = DateTime.fromISO(to);
|
||||
|
||||
if (!start.isValid || !end.isValid) {
|
||||
return `${from} – ${to}`;
|
||||
}
|
||||
|
||||
return Interval.fromDateTimes(start, end).toLocaleString(DateTime.DATE_MED, { locale });
|
||||
};
|
||||
|
||||
/** Number of calendar days in the inclusive [from, to] window, 0 when invalid. */
|
||||
export const getAnalyticsDateRangeDays = (from: string, to: string): number => {
|
||||
const start = parseAnalyticsDate(from);
|
||||
const end = parseAnalyticsDate(to);
|
||||
|
||||
if (!start || !end || start > end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(end.diff(start, 'days').days) + 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a custom window client-side, mirroring the backend resolver: both
|
||||
* dates present and valid, ordered, not in the future and within the maximum span.
|
||||
*/
|
||||
export const isValidAnalyticsCustomRange = (from: string | null, to: string | null): boolean => {
|
||||
if (!from || !to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const start = parseAnalyticsDate(from);
|
||||
const end = parseAnalyticsDate(to);
|
||||
|
||||
if (!start || !end || start > end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const today = DateTime.local().startOf('day');
|
||||
|
||||
if (end > today) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return start >= today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
|
||||
};
|
||||
|
||||
const parseAsAnalyticsDate = createParser<string>({
|
||||
parse: (value) => (parseAnalyticsDate(value) ? value : null),
|
||||
serialize: (value) => value,
|
||||
});
|
||||
|
||||
export const analyticsRangeSearchParams = {
|
||||
range: parseAsStringEnum(ZTeamAnalyticsRangeSchema.options).withDefault(DEFAULT_ANALYTICS_RANGE),
|
||||
from: parseAsAnalyticsDate,
|
||||
to: parseAsAnalyticsDate,
|
||||
};
|
||||
|
||||
/**
|
||||
* The analytics range held in the URL (`?range=`, plus `?from=&to=` for custom
|
||||
* ranges). A custom range with missing or invalid bounds falls back to the default
|
||||
* preset and the stray params are cleared from the URL.
|
||||
*/
|
||||
export const useAnalyticsRange = () => {
|
||||
const [{ range, from, to }, setSearchParams] = useQueryStates(analyticsRangeSearchParams);
|
||||
|
||||
const value = useMemo((): AnalyticsRangeValue => {
|
||||
if (range !== 'custom') {
|
||||
return { range };
|
||||
}
|
||||
|
||||
if (from && to && isValidAnalyticsCustomRange(from, to)) {
|
||||
return { range, from, to };
|
||||
}
|
||||
|
||||
return { range: DEFAULT_ANALYTICS_RANGE };
|
||||
}, [range, from, to]);
|
||||
|
||||
const hasInvalidCustomRange = range === 'custom' && value.range !== 'custom';
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInvalidCustomRange) {
|
||||
void setSearchParams({ range: null, from: null, to: null });
|
||||
}
|
||||
}, [hasInvalidCustomRange, setSearchParams]);
|
||||
|
||||
const setValue = (next: AnalyticsRangeValue) => {
|
||||
if (next.range === 'custom') {
|
||||
void setSearchParams({ range: next.range, from: next.from, to: next.to });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void setSearchParams({ range: next.range, from: null, to: null });
|
||||
};
|
||||
|
||||
/** Changes whenever the effective window changes, including a custom span being adjusted. */
|
||||
const rangeKey = `${value.range}:${value.from ?? ''}:${value.to ?? ''}`;
|
||||
|
||||
return { value, rangeKey, setValue };
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { INBOX_STATUSES, type TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types';
|
||||
|
||||
import { documentsSearchParams } from './documents-search-params';
|
||||
|
||||
/**
|
||||
* The statuses that can be selected from the inbox status filter.
|
||||
*/
|
||||
export const INBOX_SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [...INBOX_STATUSES];
|
||||
|
||||
/**
|
||||
* Shared nuqs parsers for the inbox page URL state.
|
||||
*
|
||||
* Reuses the documents parsers so the shared filter components
|
||||
* (`DocumentSearch`, `DocumentsTableStatusFilter`) read and write the same
|
||||
* params on both pages.
|
||||
*/
|
||||
export const inboxSearchParams = {
|
||||
status: documentsSearchParams.status,
|
||||
page: documentsSearchParams.page,
|
||||
perPage: documentsSearchParams.perPage,
|
||||
query: documentsSearchParams.query,
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrows the URL `status` param to a status supported by the inbox.
|
||||
*
|
||||
* Returns `undefined` when it is missing or not selectable, which shows every
|
||||
* non-draft document.
|
||||
*/
|
||||
export const resolveInboxStatus = (status: ExtendedDocumentStatus | null): TInboxStatus | undefined => {
|
||||
return INBOX_STATUSES.find((value) => value === status);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
|
||||
|
||||
export const TEMPLATES_VIEW_VALUES = ['team', 'organisation'] as const;
|
||||
|
||||
/**
|
||||
* Shared nuqs parsers for the templates page URL state.
|
||||
*
|
||||
* Used by the templates page and its filter components so every consumer
|
||||
* parses and serialises the params identically.
|
||||
*/
|
||||
export const templatesSearchParams = {
|
||||
view: parseAsStringLiteral(TEMPLATES_VIEW_VALUES),
|
||||
ownerIds: parseAsArrayOf(parseAsInteger),
|
||||
page: parseAsInteger,
|
||||
perPage: parseAsInteger,
|
||||
query: parseAsString,
|
||||
};
|
||||
@@ -92,7 +92,7 @@ export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.with('INVALID_DOCUMENT_FILE', () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`You cannot upload encrypted PDFs.`,
|
||||
description: msg`The file is not a valid PDF or is password protected.`,
|
||||
}))
|
||||
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
|
||||
title: msg`Error`,
|
||||
|
||||
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.18.0"
|
||||
"version": "2.19.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -42,14 +41,6 @@ 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,7 +1,6 @@
|
||||
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';
|
||||
@@ -42,14 +41,6 @@ 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;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -29,17 +28,6 @@ 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) {
|
||||
@@ -66,13 +54,6 @@ 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);
|
||||
}
|
||||
})
|
||||
@@ -88,10 +69,6 @@ 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,
|
||||
@@ -109,11 +86,6 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
@@ -129,26 +101,6 @@ 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) {
|
||||
@@ -200,11 +152,6 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
@@ -226,15 +173,6 @@ 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,6 +1,4 @@
|
||||
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';
|
||||
@@ -43,10 +41,6 @@ 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({
|
||||
@@ -75,11 +69,6 @@ route.get(
|
||||
type: true,
|
||||
teamId: true,
|
||||
templateType: true,
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -89,26 +78,6 @@ 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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { extractSessionCookieFromHeaders } from '@documenso/auth/server/lib/session/session-cookies';
|
||||
import { extractRequestMetadata, type RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { Context, Next } from 'hono';
|
||||
|
||||
@@ -14,49 +13,13 @@ export type AppContext = {
|
||||
*/
|
||||
export const appContext = async (c: Context, next: Next) => {
|
||||
const request = c.req.raw;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const noSessionCookie = extractSessionCookieFromHeaders(request.headers) === null;
|
||||
|
||||
setAppContext(c, {
|
||||
requestMetadata: extractRequestMetadata(request),
|
||||
});
|
||||
|
||||
// These are non page paths like API.
|
||||
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
// Add context to any pages you want here.
|
||||
return await next();
|
||||
return next();
|
||||
};
|
||||
|
||||
const setAppContext = (c: Context, context: AppContext) => {
|
||||
c.set('context', context);
|
||||
};
|
||||
|
||||
const isPageRequest = (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it ends with .data it's the loader which we need to pass context for.
|
||||
if (url.pathname.endsWith('.data')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.headers.get('Accept')?.includes('text/html')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* List of paths to reject
|
||||
* - Urls that start with /api
|
||||
* - Urls that start with _
|
||||
*/
|
||||
const blacklistedPathsRegex = /^\/api\/|^\/__/;
|
||||
|
||||
Generated
+8
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.18.0",
|
||||
"version": "2.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.18.0",
|
||||
"version": "2.19.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.4.2",
|
||||
"@libpdf/core": "^0.5.1",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.18.0",
|
||||
"version": "2.19.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -4480,9 +4480,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@libpdf/core": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
|
||||
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.5.1.tgz",
|
||||
"integrity": "sha512-q+y4AEk9ngqyC1pdX/hNScnfh0ROr4vSiqX4C9CmuBzhtuVukbfTesXCaGvHZ3pksi8AJYDPGQ/0HzSeHZfi3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
@@ -17662,7 +17662,6 @@
|
||||
"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",
|
||||
@@ -30844,6 +30843,7 @@
|
||||
"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
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.18.0",
|
||||
"version": "2.19.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.4.2",
|
||||
"@libpdf/core": "^0.5.1",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedCancelledDocument,
|
||||
seedCompletedDocument,
|
||||
seedDraftDocument,
|
||||
seedPendingDocument,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
type InboxFindInput = {
|
||||
query?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
|
||||
type InboxFindDocument = {
|
||||
envelopeId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
recipients: Array<{ email: string; token: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls `document.inbox.find` directly, bypassing any UI level restrictions so
|
||||
* we can assert the server rejects or ignores hostile input on its own.
|
||||
*/
|
||||
const trpcInboxFind = async (page: Page, input: InboxFindInput) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/document.inbox.find?input=${inputParam}`;
|
||||
|
||||
const res = await page.context().request.get(url);
|
||||
|
||||
return {
|
||||
res,
|
||||
data: res.ok()
|
||||
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
((await res.json()).result.data.json as { data: InboxFindDocument[]; count: number })
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
const titlesOf = (data: { data: InboxFindDocument[] } | null) => (data?.data ?? []).map((doc) => doc.title);
|
||||
|
||||
// ─── Recipient scoping ───────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Inbox Find - Recipient Scoping', () => {
|
||||
test('should not return documents the user is not a recipient of, even when searched by title', async ({ page }) => {
|
||||
const { user: sender, team: senderTeam } = await seedUser();
|
||||
const { user: victim } = await seedUser();
|
||||
const { user: attacker } = await seedUser();
|
||||
|
||||
await seedPendingDocument(sender, senderTeam.id, [victim], {
|
||||
createDocumentOptions: { title: 'Confidential Merger Agreement' },
|
||||
});
|
||||
|
||||
await seedCompletedDocument(sender, senderTeam.id, [victim], {
|
||||
createDocumentOptions: { title: 'Confidential Severance Package' },
|
||||
});
|
||||
|
||||
// Positive control: the actual recipient can find them.
|
||||
await apiSignin({ page, email: victim.email });
|
||||
|
||||
const victimPending = await trpcInboxFind(page, { query: 'Confidential', status: 'PENDING' });
|
||||
expect(titlesOf(victimPending.data)).toEqual(['Confidential Merger Agreement']);
|
||||
|
||||
const victimCompleted = await trpcInboxFind(page, { query: 'Confidential', status: 'COMPLETED' });
|
||||
expect(titlesOf(victimCompleted.data)).toEqual(['Confidential Severance Package']);
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The attacker knows the exact title but is not a recipient.
|
||||
await apiSignin({ page, email: attacker.email });
|
||||
|
||||
for (const status of ['PENDING', 'COMPLETED', undefined]) {
|
||||
const { res, data } = await trpcInboxFind(page, { query: 'Confidential', status });
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(data?.count).toBe(0);
|
||||
expect(titlesOf(data)).toEqual([]);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should not return documents where the user is only a CC recipient', async ({ page }) => {
|
||||
const { user: sender, team: senderTeam } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'CC Only Contract' },
|
||||
});
|
||||
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: ccDocument.id, email: recipient.email },
|
||||
data: { role: RecipientRole.CC },
|
||||
});
|
||||
|
||||
// Positive control on the same account.
|
||||
await seedPendingDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Signer Contract' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: recipient.email });
|
||||
|
||||
const unfiltered = await trpcInboxFind(page, {});
|
||||
expect(titlesOf(unfiltered.data)).toEqual(['Signer Contract']);
|
||||
|
||||
const searched = await trpcInboxFind(page, { query: 'CC Only' });
|
||||
expect(titlesOf(searched.data)).toEqual([]);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should not expose team documents to team members who are not recipients', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { user: outsideRecipient } = await seedUser();
|
||||
|
||||
// A team admin can see this document on the team documents page, but the
|
||||
// inbox is strictly recipient scoped.
|
||||
const teamAdmin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
||||
|
||||
await seedPendingDocument(owner, team.id, [outsideRecipient], {
|
||||
createDocumentOptions: { title: 'Team Payroll Summary' },
|
||||
});
|
||||
|
||||
await seedCompletedDocument(owner, team.id, [outsideRecipient], {
|
||||
createDocumentOptions: { title: 'Team Board Minutes' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: teamAdmin.email });
|
||||
|
||||
const pending = await trpcInboxFind(page, { query: 'Team', status: 'PENDING' });
|
||||
expect(titlesOf(pending.data)).toEqual([]);
|
||||
|
||||
const completed = await trpcInboxFind(page, { query: 'Team', status: 'COMPLETED' });
|
||||
expect(titlesOf(completed.data)).toEqual([]);
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The document owner is also not a recipient, so it should not be in their inbox either.
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const ownerResult = await trpcInboxFind(page, { query: 'Team' });
|
||||
expect(titlesOf(ownerResult.data)).toEqual([]);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should mask signing tokens of other recipients', async ({ page }) => {
|
||||
const { user: sender, team: senderTeam } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const { user: otherRecipient } = await seedUser();
|
||||
|
||||
await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], {
|
||||
createDocumentOptions: { title: 'Shared Token Document' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: recipient.email });
|
||||
|
||||
const { data } = await trpcInboxFind(page, { query: 'Shared Token', status: 'PENDING' });
|
||||
|
||||
expect(data?.data).toHaveLength(1);
|
||||
|
||||
const document = data?.data[0];
|
||||
|
||||
const ownRecipient = document?.recipients.find((r) => r.email === recipient.email);
|
||||
const foreignRecipient = document?.recipients.find((r) => r.email === otherRecipient.email);
|
||||
|
||||
expect(ownRecipient?.token).toBeTruthy();
|
||||
expect(foreignRecipient?.token).toBe('');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search hardening ────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Inbox Find - Search Hardening', () => {
|
||||
test('should keep wildcard searches scoped to the recipient inbox', async ({ page }) => {
|
||||
// SQL LIKE wildcards ("%" and "_") are intentionally passed through so
|
||||
// users can do advanced searches. That must only ever widen the title
|
||||
// match, never the recipient scoping.
|
||||
const { user: sender, team: senderTeam } = await seedUser();
|
||||
const { user: victim } = await seedUser();
|
||||
const { user: attacker } = await seedUser();
|
||||
|
||||
await seedPendingDocument(sender, senderTeam.id, [victim], {
|
||||
createDocumentOptions: { title: 'Victim Alpha Report' },
|
||||
});
|
||||
|
||||
await seedCompletedDocument(sender, senderTeam.id, [victim], {
|
||||
createDocumentOptions: { title: 'Victim Beta Report' },
|
||||
});
|
||||
|
||||
// The attacker has one document of their own so we can prove wildcards
|
||||
// return their inbox and nothing more.
|
||||
await seedPendingDocument(sender, senderTeam.id, [attacker], {
|
||||
createDocumentOptions: { title: 'Attacker Own Report' },
|
||||
});
|
||||
|
||||
const wildcardQueries = ['%', '_', '%%%', '%Report%', 'Victim%', 'Victim _lpha%', '\\', '%victim%'];
|
||||
|
||||
// Positive control: wildcards work for the actual recipient.
|
||||
await apiSignin({ page, email: victim.email });
|
||||
|
||||
const victimAll = await trpcInboxFind(page, { query: '%' });
|
||||
expect(titlesOf(victimAll.data).sort()).toEqual(['Victim Alpha Report', 'Victim Beta Report']);
|
||||
|
||||
const victimPattern = await trpcInboxFind(page, { query: 'Victim _lpha%' });
|
||||
expect(titlesOf(victimPattern.data)).toEqual(['Victim Alpha Report']);
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The attacker gets exactly their own inbox for every wildcard, never the victim's.
|
||||
await apiSignin({ page, email: attacker.email });
|
||||
|
||||
for (const query of wildcardQueries) {
|
||||
const { res, data } = await trpcInboxFind(page, { query });
|
||||
|
||||
expect(res.ok(), `query "${query}"`).toBeTruthy();
|
||||
|
||||
const titles = titlesOf(data);
|
||||
|
||||
expect(titles, `query "${query}"`).not.toContain('Victim Alpha Report');
|
||||
expect(titles, `query "${query}"`).not.toContain('Victim Beta Report');
|
||||
expect(
|
||||
titles.every((title) => title === 'Attacker Own Report'),
|
||||
`query "${query}"`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// Wildcards combined with the status filter still cannot escape the scope.
|
||||
for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED']) {
|
||||
const { data } = await trpcInboxFind(page, { query: '%', status });
|
||||
|
||||
expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Alpha Report');
|
||||
expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Beta Report');
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should only match against the document title', async ({ page }) => {
|
||||
// Explicit names so the negative queries below are deterministic.
|
||||
const { user: sender, team: senderTeam } = await seedUser({ name: 'Sender Person' });
|
||||
const { user: recipient } = await seedUser({ name: 'Recipient Person' });
|
||||
|
||||
await seedPendingDocument(sender, senderTeam.id, ['zebra-person@test.documenso.com', recipient], {
|
||||
createDocumentOptions: {
|
||||
title: 'Plain Title',
|
||||
externalId: 'ext-hidden-identifier',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: recipient.email });
|
||||
|
||||
// Positive control.
|
||||
const byTitle = await trpcInboxFind(page, { query: 'Plain' });
|
||||
expect(titlesOf(byTitle.data)).toEqual(['Plain Title']);
|
||||
|
||||
// External IDs, sender details and other recipients must not be probeable
|
||||
// through the inbox search.
|
||||
for (const query of ['ext-hidden', sender.email, 'Sender Person', 'zebra-person']) {
|
||||
const { data } = await trpcInboxFind(page, { query });
|
||||
|
||||
expect(titlesOf(data), `query "${query}"`).toEqual([]);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should not surface deleted, draft or template envelopes through search', async ({ page }) => {
|
||||
const { user: sender, team: senderTeam } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Hidden Deleted Document' },
|
||||
});
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: { id: deletedDocument.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await seedDraftDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Hidden Draft Document' },
|
||||
});
|
||||
|
||||
await seedPendingDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Hidden Template Envelope', type: EnvelopeType.TEMPLATE },
|
||||
});
|
||||
|
||||
// Positive control.
|
||||
await seedPendingDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Hidden Visible Document' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: recipient.email });
|
||||
|
||||
const unfiltered = await trpcInboxFind(page, { query: 'Hidden' });
|
||||
expect(titlesOf(unfiltered.data)).toEqual(['Hidden Visible Document']);
|
||||
|
||||
for (const query of ['Hidden Deleted', 'Hidden Draft', 'Hidden Template']) {
|
||||
const { data } = await trpcInboxFind(page, { query });
|
||||
expect(titlesOf(data)).toEqual([]);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Status filter hardening ─────────────────────────────────────────────────
|
||||
|
||||
test.describe('Inbox Find - Status Filter Hardening', () => {
|
||||
test('should reject draft and virtual status values', async ({ page }) => {
|
||||
const { user: sender, team: senderTeam } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// A recipient on a draft must never be able to pull it out via the status filter.
|
||||
await seedDraftDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Unsent Draft Document' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: recipient.email });
|
||||
|
||||
for (const status of ['DRAFT', 'EXPIRED', 'INBOX', 'ALL', 'pending', 'draft', '']) {
|
||||
const { res, data } = await trpcInboxFind(page, { status });
|
||||
|
||||
expect(res.status(), `status "${status}" should be rejected`).toBe(400);
|
||||
expect(data).toBeNull();
|
||||
}
|
||||
|
||||
// Sanity check that the valid filters, and the unfiltered view, never include the draft.
|
||||
for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) {
|
||||
const { res, data } = await trpcInboxFind(page, { status });
|
||||
|
||||
expect(res.ok(), `status "${status}" should be accepted`).toBeTruthy();
|
||||
expect(titlesOf(data)).toEqual([]);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should scope each status filter to exactly that status', async ({ page }) => {
|
||||
const { user: sender, team: senderTeam } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
await seedPendingDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Scoped Pending Document' },
|
||||
});
|
||||
|
||||
await seedCompletedDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Scoped Completed Document' },
|
||||
});
|
||||
|
||||
await seedCancelledDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Scoped Cancelled Document' },
|
||||
});
|
||||
|
||||
await seedPendingDocument(sender, senderTeam.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Scoped Rejected Document', status: DocumentStatus.REJECTED },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: recipient.email });
|
||||
|
||||
const expectations = [
|
||||
{ status: 'PENDING', expected: ['Scoped Pending Document'] },
|
||||
{ status: 'COMPLETED', expected: ['Scoped Completed Document'] },
|
||||
{ status: 'CANCELLED', expected: ['Scoped Cancelled Document'] },
|
||||
{ status: 'REJECTED', expected: ['Scoped Rejected Document'] },
|
||||
];
|
||||
|
||||
for (const { status, expected } of expectations) {
|
||||
const { data } = await trpcInboxFind(page, { query: 'Scoped', status });
|
||||
|
||||
expect(titlesOf(data), `status "${status}"`).toEqual(expected);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('should reject pagination values outside of the allowed range', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const tooManyPerPage = await trpcInboxFind(page, { perPage: 101 });
|
||||
expect(tooManyPerPage.res.status()).toBe(400);
|
||||
|
||||
const zeroPerPage = await trpcInboxFind(page, { perPage: 0 });
|
||||
expect(zeroPerPage.res.status()).toBe(400);
|
||||
|
||||
const zeroPage = await trpcInboxFind(page, { page: 0 });
|
||||
expect(zeroPage.res.status()).toBe(400);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Inbox Find - Authentication', () => {
|
||||
test('should reject unauthenticated requests', async ({ page }) => {
|
||||
const { res } = await trpcInboxFind(page, { query: 'anything', status: 'PENDING' });
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const TEAM_ANALYTICS_PROCEDURES = [
|
||||
'team.analytics.getOverview',
|
||||
'team.analytics.getDocumentsOverTime',
|
||||
'team.analytics.getStatusBreakdown',
|
||||
'team.analytics.getTemplateUsage',
|
||||
'team.analytics.getMemberActivity',
|
||||
] as const;
|
||||
|
||||
const ORGANISATION_ANALYTICS_PROCEDURES = [
|
||||
'organisation.analytics.getOverview',
|
||||
'organisation.analytics.getDocumentsOverTime',
|
||||
'organisation.analytics.getStatusBreakdown',
|
||||
'organisation.analytics.getTemplateUsage',
|
||||
'organisation.analytics.getTeamActivity',
|
||||
] as const;
|
||||
|
||||
const seedScenario = async () => {
|
||||
const suffix = nanoid();
|
||||
|
||||
const { user: owner, organisation, team: siblingTeam } = await seedUser();
|
||||
|
||||
const targetTeamUrl = `analytics-target-${suffix}`;
|
||||
|
||||
await createTeam({
|
||||
userId: owner.id,
|
||||
teamName: `Analytics Target Team ${suffix}`,
|
||||
teamUrl: targetTeamUrl,
|
||||
organisationId: organisation.id,
|
||||
// Keeps plain organisation members out of the target team.
|
||||
inheritMembers: false,
|
||||
});
|
||||
|
||||
const targetTeam = await prisma.team.findFirstOrThrow({ where: { url: targetTeamUrl } });
|
||||
|
||||
const targetManagerName = `Analytics Target Manager ${suffix}`;
|
||||
|
||||
const targetManager = await seedTeamMember({
|
||||
teamId: targetTeam.id,
|
||||
name: targetManagerName,
|
||||
role: TeamMemberRole.MANAGER,
|
||||
});
|
||||
|
||||
const targetMember = await seedTeamMember({ teamId: targetTeam.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const siblingTeamAdmin = await seedTeamMember({ teamId: siblingTeam.id, role: TeamMemberRole.ADMIN });
|
||||
|
||||
const [organisationMember, organisationManager] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [
|
||||
{ organisationRole: OrganisationMemberRole.MEMBER },
|
||||
{ organisationRole: OrganisationMemberRole.MANAGER },
|
||||
],
|
||||
});
|
||||
|
||||
const { user: outsider, team: outsiderTeam, organisation: outsiderOrganisation } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(owner, targetTeam.id, {
|
||||
createTemplateOptions: { title: `Analytics Target Template ${suffix}` },
|
||||
});
|
||||
|
||||
const document = await seedBlankDocument(targetManager, targetTeam.id, {
|
||||
createDocumentOptions: {
|
||||
title: `Analytics Target Document ${suffix}`,
|
||||
status: DocumentStatus.PENDING,
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: {
|
||||
envelopeId: document.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
createdAt: new Date(),
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
envelopeId: document.id,
|
||||
email: recipient.email,
|
||||
name: 'Analytics Recipient',
|
||||
token: nanoid(),
|
||||
},
|
||||
});
|
||||
|
||||
const markers = {
|
||||
teamName: targetTeam.name,
|
||||
templateTitle: template.title,
|
||||
managerName: targetManagerName,
|
||||
managerEmail: targetManager.email,
|
||||
};
|
||||
|
||||
return {
|
||||
owner,
|
||||
organisation,
|
||||
targetTeam,
|
||||
targetManager,
|
||||
targetMember,
|
||||
siblingTeamAdmin,
|
||||
organisationMember,
|
||||
organisationManager,
|
||||
outsider,
|
||||
outsiderTeam,
|
||||
outsiderOrganisation,
|
||||
recipient,
|
||||
markers: Object.values(markers),
|
||||
namedMarkers: markers,
|
||||
};
|
||||
};
|
||||
|
||||
type Scenario = Awaited<ReturnType<typeof seedScenario>>;
|
||||
|
||||
type DeniedCaller = {
|
||||
name: string;
|
||||
caller: (scenario: Scenario) => { email: string };
|
||||
};
|
||||
|
||||
const TEAM_DENIED_CALLERS: DeniedCaller[] = [
|
||||
{ name: 'a user from another organisation', caller: (s) => s.outsider },
|
||||
{ name: 'a recipient of a team document who is not a member', caller: (s) => s.recipient },
|
||||
{ name: 'an organisation member who is not in the team', caller: (s) => s.organisationMember },
|
||||
{ name: 'an admin of a sibling team', caller: (s) => s.siblingTeamAdmin },
|
||||
{ name: 'a team member below manager', caller: (s) => s.targetMember },
|
||||
];
|
||||
|
||||
const ORGANISATION_DENIED_CALLERS: DeniedCaller[] = [
|
||||
{ name: 'a user from another organisation', caller: (s) => s.outsider },
|
||||
{ name: 'a recipient of an organisation document', caller: (s) => s.recipient },
|
||||
{ name: 'an organisation member', caller: (s) => s.organisationMember },
|
||||
{ name: 'an organisation manager', caller: (s) => s.organisationManager },
|
||||
{ name: 'a team admin who is an organisation member', caller: (s) => s.siblingTeamAdmin },
|
||||
{ name: 'a team manager who is an organisation member', caller: (s) => s.targetManager },
|
||||
];
|
||||
|
||||
test.describe('Team Analytics API - Adversarial: Access', () => {
|
||||
test('should reject unauthenticated requests on every procedure', async ({ request }) => {
|
||||
const { targetTeam, markers } = await seedScenario();
|
||||
|
||||
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(request, procedure, { teamId: targetTeam.id });
|
||||
|
||||
expectDenied(res, markers);
|
||||
}
|
||||
});
|
||||
|
||||
for (const { name, caller } of TEAM_DENIED_CALLERS) {
|
||||
test(`should reject ${name} on every procedure`, async ({ page }) => {
|
||||
const scenario = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: caller(scenario).email });
|
||||
|
||||
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(page.context().request, procedure, { teamId: scenario.targetTeam.id });
|
||||
|
||||
expectDenied(res, scenario.markers);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('should allow team admins and managers (control)', async ({ page }) => {
|
||||
const { owner, targetManager, organisationManager, targetTeam, namedMarkers } = await seedScenario();
|
||||
|
||||
for (const caller of [owner, targetManager, organisationManager]) {
|
||||
await apiSignin({ page, email: caller.email });
|
||||
|
||||
const bodies: string[] = [];
|
||||
|
||||
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(page.context().request, procedure, { teamId: targetTeam.id });
|
||||
|
||||
expectAllowed(res);
|
||||
bodies.push(res.body);
|
||||
}
|
||||
|
||||
const combined = bodies.join('\n');
|
||||
|
||||
expect(combined).toContain(namedMarkers.templateTitle);
|
||||
expect(combined).toContain(namedMarkers.managerName);
|
||||
expect(combined).toContain(namedMarkers.managerEmail);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Team Analytics API - Adversarial: Tampering', () => {
|
||||
test('should ignore identity fields injected into the input', async ({ page }) => {
|
||||
const { owner, outsider, targetTeam, markers } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(page.context().request, procedure, {
|
||||
teamId: targetTeam.id,
|
||||
userId: owner.id,
|
||||
userEmail: owner.email,
|
||||
});
|
||||
|
||||
expectDenied(res, markers);
|
||||
}
|
||||
});
|
||||
|
||||
test('should not grant access through the x-team-id header', async ({ page }) => {
|
||||
const { outsider, outsiderTeam, targetTeam, markers } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
|
||||
const targetHeader = await trpcQuery(
|
||||
page.context().request,
|
||||
procedure,
|
||||
{ teamId: targetTeam.id },
|
||||
{ 'x-team-id': targetTeam.id.toString() },
|
||||
);
|
||||
|
||||
expectDenied(targetHeader, markers);
|
||||
|
||||
const ownHeader = await trpcQuery(
|
||||
page.context().request,
|
||||
procedure,
|
||||
{ teamId: targetTeam.id },
|
||||
{ 'x-team-id': outsiderTeam.id.toString() },
|
||||
);
|
||||
|
||||
expectDenied(ownHeader, markers);
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject target team calls batched with an allowed call', async ({ page }) => {
|
||||
const { outsider, outsiderTeam, targetTeam, markers } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const { response, body, results } = await trpcBatchQuery(page.context().request, [
|
||||
{ procedure: 'team.analytics.getOverview', input: { teamId: outsiderTeam.id } },
|
||||
...TEAM_ANALYTICS_PROCEDURES.map((procedure) => ({ procedure, input: { teamId: targetTeam.id } })),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBe(207);
|
||||
expect(results).toHaveLength(TEAM_ANALYTICS_PROCEDURES.length + 1);
|
||||
|
||||
const [allowed, ...denied] = results;
|
||||
|
||||
expect(allowed.result).toBeDefined();
|
||||
expect(allowed.error).toBeUndefined();
|
||||
|
||||
for (const item of denied) {
|
||||
expect(item.result).toBeUndefined();
|
||||
expect(item.error?.json.data.httpStatus).toBe(401);
|
||||
expect(item.error?.json.data.code).toBe('UNAUTHORIZED');
|
||||
}
|
||||
|
||||
expectNoMarkers(body, markers);
|
||||
});
|
||||
|
||||
test('should reject API tokens, even one scoped to the target team', async ({ request }) => {
|
||||
const { owner, targetTeam, markers } = await seedScenario();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: owner.id,
|
||||
teamId: targetTeam.id,
|
||||
tokenName: 'analytics-adversarial',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
|
||||
for (const authorization of [`Bearer ${token}`, token]) {
|
||||
const res = await trpcQuery(request, procedure, { teamId: targetTeam.id }, { Authorization: authorization });
|
||||
|
||||
expectDenied(res, markers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should not reveal whether a team exists', async ({ page }) => {
|
||||
const { outsider, targetTeam } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
|
||||
const existing = await trpcQuery(page.context().request, procedure, { teamId: targetTeam.id });
|
||||
const missing = await trpcQuery(page.context().request, procedure, { teamId: 2_147_483_647 });
|
||||
|
||||
expect(existing.response.status()).toBe(401);
|
||||
expect(missing.response.status()).toBe(401);
|
||||
expect(errorMessageOf(missing)).toBe(errorMessageOf(existing));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Organisation Analytics API - Adversarial: Access', () => {
|
||||
test('should reject unauthenticated requests on every procedure', async ({ request }) => {
|
||||
const { organisation, markers } = await seedScenario();
|
||||
|
||||
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(request, procedure, { organisationId: organisation.id });
|
||||
|
||||
expectDenied(res, markers);
|
||||
}
|
||||
});
|
||||
|
||||
for (const { name, caller } of ORGANISATION_DENIED_CALLERS) {
|
||||
test(`should reject ${name} on every procedure`, async ({ page }) => {
|
||||
const scenario = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: caller(scenario).email });
|
||||
|
||||
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(page.context().request, procedure, {
|
||||
organisationId: scenario.organisation.id,
|
||||
});
|
||||
|
||||
expectDenied(res, scenario.markers);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('should allow organisation admins (control)', async ({ page }) => {
|
||||
const { owner, organisation, namedMarkers } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const bodies: string[] = [];
|
||||
|
||||
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(page.context().request, procedure, { organisationId: organisation.id });
|
||||
|
||||
expectAllowed(res);
|
||||
bodies.push(res.body);
|
||||
}
|
||||
|
||||
const combined = bodies.join('\n');
|
||||
|
||||
expect(combined).toContain(namedMarkers.teamName);
|
||||
expect(combined).toContain(namedMarkers.templateTitle);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Organisation Analytics API - Adversarial: Tampering', () => {
|
||||
test('should ignore identity fields injected into the input', async ({ page }) => {
|
||||
const { owner, outsider, organisation, markers } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(page.context().request, procedure, {
|
||||
organisationId: organisation.id,
|
||||
userId: owner.id,
|
||||
userEmail: owner.email,
|
||||
});
|
||||
|
||||
expectDenied(res, markers);
|
||||
}
|
||||
});
|
||||
|
||||
test('should not grant access through the x-team-id header', async ({ page }) => {
|
||||
const { organisationMember, organisation, targetTeam, markers } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: organisationMember.email });
|
||||
|
||||
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
|
||||
const res = await trpcQuery(
|
||||
page.context().request,
|
||||
procedure,
|
||||
{ organisationId: organisation.id },
|
||||
{ 'x-team-id': targetTeam.id.toString() },
|
||||
);
|
||||
|
||||
expectDenied(res, markers);
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject target organisation calls batched with an allowed call', async ({ page }) => {
|
||||
const { outsider, outsiderOrganisation, organisation, markers } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const { response, body, results } = await trpcBatchQuery(page.context().request, [
|
||||
{ procedure: 'organisation.analytics.getOverview', input: { organisationId: outsiderOrganisation.id } },
|
||||
...ORGANISATION_ANALYTICS_PROCEDURES.map((procedure) => ({
|
||||
procedure,
|
||||
input: { organisationId: organisation.id },
|
||||
})),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBe(207);
|
||||
expect(results).toHaveLength(ORGANISATION_ANALYTICS_PROCEDURES.length + 1);
|
||||
|
||||
const [allowed, ...denied] = results;
|
||||
|
||||
expect(allowed.result).toBeDefined();
|
||||
expect(allowed.error).toBeUndefined();
|
||||
|
||||
for (const item of denied) {
|
||||
expect(item.result).toBeUndefined();
|
||||
expect(item.error?.json.data.httpStatus).toBe(401);
|
||||
expect(item.error?.json.data.code).toBe('UNAUTHORIZED');
|
||||
}
|
||||
|
||||
expectNoMarkers(body, markers);
|
||||
});
|
||||
|
||||
test('should reject API tokens, even one belonging to an organisation admin', async ({ request }) => {
|
||||
const { owner, organisation, targetTeam, markers } = await seedScenario();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: owner.id,
|
||||
teamId: targetTeam.id,
|
||||
tokenName: 'analytics-adversarial',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
|
||||
for (const authorization of [`Bearer ${token}`, token]) {
|
||||
const res = await trpcQuery(
|
||||
request,
|
||||
procedure,
|
||||
{ organisationId: organisation.id },
|
||||
{ Authorization: authorization },
|
||||
);
|
||||
|
||||
expectDenied(res, markers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should not reveal whether an organisation exists', async ({ page }) => {
|
||||
const { outsider, organisation } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
|
||||
const existing = await trpcQuery(page.context().request, procedure, { organisationId: organisation.id });
|
||||
const missing = await trpcQuery(page.context().request, procedure, {
|
||||
organisationId: `org_does_not_exist_${nanoid()}`,
|
||||
});
|
||||
|
||||
expect(existing.response.status()).toBe(401);
|
||||
expect(missing.response.status()).toBe(401);
|
||||
expect(errorMessageOf(missing)).toBe(errorMessageOf(existing));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
type TrpcResponseItem = {
|
||||
result?: { data: { json: unknown } };
|
||||
error?: { json: { message: string; data: { code: string; httpStatus: number } } };
|
||||
};
|
||||
|
||||
type TrpcResult = {
|
||||
response: APIResponse;
|
||||
body: string;
|
||||
json: TrpcResponseItem | null;
|
||||
};
|
||||
|
||||
const DEFAULT_ANALYTICS_INPUT = { range: '30d', timezone: 'UTC' };
|
||||
|
||||
const trpcQuery = async (
|
||||
request: APIRequestContext,
|
||||
procedure: string,
|
||||
input: Record<string, unknown>,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<TrpcResult> => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: { ...DEFAULT_ANALYTICS_INPUT, ...input } }));
|
||||
|
||||
const response = await request.get(`${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`, { headers });
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
return { response, body, json: parseJson<TrpcResponseItem>(body) };
|
||||
};
|
||||
|
||||
const trpcBatchQuery = async (
|
||||
request: APIRequestContext,
|
||||
calls: Array<{ procedure: string; input: Record<string, unknown> }>,
|
||||
) => {
|
||||
const procedures = calls.map((call) => call.procedure).join(',');
|
||||
|
||||
const input = Object.fromEntries(
|
||||
calls.map((call, index) => [index, { json: { ...DEFAULT_ANALYTICS_INPUT, ...call.input } }]),
|
||||
);
|
||||
|
||||
const response = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/trpc/${procedures}?batch=1&input=${encodeURIComponent(JSON.stringify(input))}`,
|
||||
);
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
return { response, body, results: parseJson<TrpcResponseItem[]>(body) ?? [] };
|
||||
};
|
||||
|
||||
const parseJson = <T>(body: string): T | null => {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessageOf = (res: TrpcResult) => res.json?.error?.json.message;
|
||||
|
||||
const expectNoMarkers = (body: string, markers: string[]) => {
|
||||
for (const marker of markers) {
|
||||
expect(body).not.toContain(marker);
|
||||
}
|
||||
};
|
||||
|
||||
const expectDenied = (res: TrpcResult, markers: string[]) => {
|
||||
expect(res.response.status()).toBe(401);
|
||||
expect(res.json?.result).toBeUndefined();
|
||||
expect(res.json?.error?.json.data.code).toBe('UNAUTHORIZED');
|
||||
|
||||
expectNoMarkers(res.body, markers);
|
||||
};
|
||||
|
||||
const expectAllowed = (res: TrpcResult) => {
|
||||
expect(res.response.status()).toBe(200);
|
||||
expect(res.json?.result?.data.json).toBeDefined();
|
||||
};
|
||||
@@ -167,6 +167,29 @@ 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();
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
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,12 +6,6 @@ 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.
|
||||
*/
|
||||
@@ -22,7 +16,6 @@ export const apiSignin = async ({
|
||||
page,
|
||||
email = 'example@documenso.com',
|
||||
password = 'password',
|
||||
totpCode,
|
||||
redirectPath = '/',
|
||||
}: LoginOptions) => {
|
||||
const { request } = page.context();
|
||||
@@ -33,7 +26,6 @@ export const apiSignin = async ({
|
||||
data: {
|
||||
email,
|
||||
password,
|
||||
totpCode,
|
||||
csrfToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,272 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
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,6 +40,88 @@ 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,
|
||||
@@ -156,8 +238,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.getByLabel('Name').click();
|
||||
await page.getByLabel('Name').fill('name');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).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: Tab Visibility ──────────────────────────────────────────────────────
|
||||
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - UI Tabs', () => {
|
||||
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
|
||||
test.describe('Organisation Templates - UI View Filter', () => {
|
||||
test('should show the view filter for non-personal orgs', async ({ page }) => {
|
||||
const { ownerA, teamA } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
@@ -110,11 +110,10 @@ test.describe('Organisation Templates - UI Tabs', () => {
|
||||
redirectPath: `/t/${teamA.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
|
||||
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show tabs for personal organisations', async ({ page }) => {
|
||||
test('should not show the view filter for personal organisations', async ({ page }) => {
|
||||
const { user, team } = await seedUser({ isPersonalOrganisation: true });
|
||||
|
||||
await apiSignin({
|
||||
@@ -123,15 +122,14 @@ test.describe('Organisation Templates - UI Tabs', () => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
|
||||
await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Listing', () => {
|
||||
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
|
||||
test('should list org templates from other teams under the organisation view', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
@@ -140,17 +138,30 @@ test.describe('Organisation Templates - Listing', () => {
|
||||
redirectPath: `/t/${teamB.url}/templates`,
|
||||
});
|
||||
|
||||
// Team tab should show 0 (memberB has no templates on teamB).
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
// Team view is active by default (memberB has no templates on teamB).
|
||||
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
|
||||
|
||||
// Switch to Organisation tab.
|
||||
await page.getByTestId('template-tab-organisation').click();
|
||||
// Switch to the organisation view.
|
||||
await page.getByTestId('templates-table-view-filter').click();
|
||||
await page.getByRole('option', { name: 'Organization' }).click();
|
||||
|
||||
// Should see the org template from teamA.
|
||||
await expect(page.getByText(orgTemplate.title)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
|
||||
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 }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
// Create a private template on teamA — should NOT appear in org tab.
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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.
|
||||
});
|
||||
@@ -5,14 +5,13 @@ 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 {
|
||||
@@ -30,8 +29,9 @@ type TEmailPasswordSignin = InferRequestType<AuthClientType['email-password']['a
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
// `redirectPath` is part of the request schema and validated server-side.
|
||||
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'];
|
||||
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
export class AuthClient {
|
||||
public client: AuthClientType;
|
||||
@@ -71,7 +71,7 @@ export class AuthClient {
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return superjson.deserialize<TSessionJsonResponse>(result);
|
||||
return superjson.deserialize<SessionValidationResult>(result);
|
||||
}
|
||||
|
||||
public async getSessions() {
|
||||
@@ -146,20 +146,6 @@ 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);
|
||||
},
|
||||
|
||||
@@ -259,8 +245,6 @@ 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 });
|
||||
@@ -273,51 +257,6 @@ 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 = {
|
||||
@@ -330,11 +269,7 @@ export class AuthClient {
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
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);
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,9 +17,6 @@ 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');
|
||||
|
||||
export const getAuthSecret = () => {
|
||||
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
Reference in New Issue
Block a user