mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 11:01:47 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddd0da1473 | ||
|
|
1e1e15a7dc | ||
|
|
4e87121bef | ||
|
|
f353706e24 | ||
|
|
eeae0e1e02 | ||
|
|
3c9c490505 | ||
|
|
905e68fdea | ||
|
|
ced5af4d5a | ||
|
|
b076a70d98 | ||
|
|
6ace46fefd | ||
|
|
478229aa90 | ||
|
|
d3eb0c7999 | ||
|
|
918e42b992 | ||
|
|
f21eddef19 |
@@ -1,3 +1,3 @@
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
min-release-age = 7
|
||||
# min-release-age = 7
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ A document object contains the following properties:
|
||||
| --------------- | -------------- | -------------------------------------------------------------- |
|
||||
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
||||
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
|
||||
| `title` | string | Document title |
|
||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
|
||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
|
||||
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
||||
| `externalId` | string \| null | Your custom identifier for the document |
|
||||
| `createdAt` | string | ISO 8601 timestamp |
|
||||
@@ -53,7 +53,7 @@ A document object contains the following properties:
|
||||
"id": "envelope_abc123xyz",
|
||||
"type": "DOCUMENT",
|
||||
"status": "PENDING",
|
||||
"source": "API",
|
||||
"source": "DOCUMENT",
|
||||
"visibility": "EVERYONE",
|
||||
"title": "Service Agreement",
|
||||
"externalId": "contract-2025-001",
|
||||
@@ -73,13 +73,13 @@ A document object contains the following properties:
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"id": "field_123",
|
||||
"id": 123,
|
||||
"type": "SIGNATURE",
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 80,
|
||||
"width": 30,
|
||||
"height": 5,
|
||||
"positionX": "10",
|
||||
"positionY": "80",
|
||||
"width": "30",
|
||||
"height": "5",
|
||||
"recipientId": 1
|
||||
}
|
||||
],
|
||||
@@ -99,6 +99,8 @@ A document object contains the following properties:
|
||||
}
|
||||
```
|
||||
|
||||
Field position and size values are stored as decimals and serialized as strings in API responses.
|
||||
|
||||
## List Documents
|
||||
|
||||
Retrieve a paginated list of documents.
|
||||
@@ -114,7 +116,7 @@ GET /envelope
|
||||
| `page` | integer | Page number (default: 1) |
|
||||
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
||||
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
|
||||
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` |
|
||||
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
|
||||
| `source` | string | Filter by creation source |
|
||||
| `folderId` | string | Filter by folder ID |
|
||||
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
||||
@@ -154,8 +156,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
|
||||
},
|
||||
});
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
console.log(`Found ${pagination.totalItems} documents`);
|
||||
const { data, count } = await response.json();
|
||||
console.log(`Found ${count} documents`);
|
||||
|
||||
// Filter by status
|
||||
const pendingResponse = await fetch(
|
||||
@@ -197,12 +199,10 @@ const pendingDocs = await pendingResponse.json();
|
||||
]
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 5,
|
||||
"totalItems": 42
|
||||
}
|
||||
"count": 42,
|
||||
"currentPage": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 5
|
||||
}
|
||||
```
|
||||
|
||||
@@ -628,6 +628,72 @@ The response includes signing URLs for each recipient:
|
||||
|
||||
---
|
||||
|
||||
## Cancel Document
|
||||
|
||||
Cancel a pending document. This changes its status from `PENDING` to `CANCELLED`.
|
||||
|
||||
```
|
||||
POST /envelope/cancel
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ----------------------------------- |
|
||||
| `envelopeId` | string | Yes | Document ID |
|
||||
| `reason` | string | No | Reason for cancelling the document |
|
||||
|
||||
### Code Examples
|
||||
|
||||
<Tabs items={['curl', 'TypeScript']}>
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "envelope_abc123",
|
||||
"reason": "The agreement is no longer needed."
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abc123',
|
||||
reason: 'The agreement is no longer needed.',
|
||||
}),
|
||||
});
|
||||
|
||||
const { success } = await response.json();
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- Only documents in `PENDING` status can be cancelled. Other statuses return `400`.
|
||||
- Cancellation is not idempotent. Cancelling the same document again returns `400`.
|
||||
- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`.
|
||||
- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook.
|
||||
- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document.
|
||||
|
||||
---
|
||||
|
||||
## Delete Document
|
||||
|
||||
Delete a document. Completed documents cannot be deleted.
|
||||
@@ -670,7 +736,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
|
||||
|
||||
const { success } = await response.json();
|
||||
|
||||
````
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -680,7 +746,7 @@ const { success } = await response.json();
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
````
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -694,9 +760,11 @@ POST /envelope/get-many
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | ----- | -------- | --------------------- |
|
||||
| `envelopeIds` | array | Yes | Array of document IDs |
|
||||
| Field | Type | Required | Description |
|
||||
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
|
||||
| `ids` | object | Yes | ID selector containing `type` and `ids` |
|
||||
| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` |
|
||||
| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` |
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -707,12 +775,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||
"ids": {
|
||||
"type": "envelopeId",
|
||||
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
|
||||
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -720,16 +793,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: requestedIds,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const documents = await response.json();
|
||||
const { data } = await response.json();
|
||||
|
||||
````
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "envelope_abc123",
|
||||
"type": "DOCUMENT",
|
||||
"status": "PENDING",
|
||||
"title": "Service Agreement"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions.
|
||||
|
||||
---
|
||||
|
||||
## Document Statuses
|
||||
@@ -740,6 +833,7 @@ const documents = await response.json();
|
||||
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
|
||||
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
||||
| `REJECTED` | A recipient rejected the document. |
|
||||
| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. |
|
||||
|
||||
### Status Transitions
|
||||
|
||||
@@ -747,11 +841,13 @@ const documents = await response.json();
|
||||
flowchart LR
|
||||
DRAFT --> PENDING --> COMPLETED
|
||||
PENDING --> REJECTED
|
||||
PENDING --> CANCELLED
|
||||
```
|
||||
|
||||
- **DRAFT to PENDING**: Call the distribute endpoint
|
||||
- **PENDING to COMPLETED**: All recipients complete their signing
|
||||
- **PENDING to REJECTED**: A recipient rejects the document
|
||||
- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document
|
||||
|
||||
<Callout type="warn">
|
||||
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
||||
@@ -773,8 +869,8 @@ flowchart LR
|
||||
| Parameter | Values | Description |
|
||||
| ---------- | ------------------------------------------- | ------------------------- |
|
||||
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
|
||||
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
|
||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
|
||||
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
|
||||
| `folderId` | string | Filter by folder |
|
||||
|
||||
### Sorting
|
||||
@@ -800,10 +896,10 @@ async function getAllPendingDocuments() {
|
||||
},
|
||||
);
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
const { data, currentPage, totalPages } = await response.json();
|
||||
documents.push(...data);
|
||||
|
||||
hasMore = page < pagination.totalPages;
|
||||
hasMore = currentPage < totalPages;
|
||||
page++;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
| `secondaryId` | string | Secondary identifier for audit logs |
|
||||
| `type` | string | Field type (see [Field Types](#field-types)) |
|
||||
| `recipientId` | number | ID of the recipient assigned to this field |
|
||||
| `envelopeId` | 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 +38,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"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -134,10 +135,10 @@ 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 |
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -148,32 +149,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 +200,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 +240,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 +251,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 +332,8 @@ 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 |
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -311,17 +344,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 +371,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 +390,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 +515,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 +529,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
|
||||
};
|
||||
@@ -643,15 +715,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 +735,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 +749,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 +763,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 +782,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>
|
||||
|
||||
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
||||
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
|
||||
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) |
|
||||
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
||||
@@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||
| ------------------------------------- | ------------------------------------------------ |
|
||||
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
|
||||
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) |
|
||||
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
||||
|
||||
@@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
|
||||
## HTTP Rate Limits
|
||||
|
||||
The rate limit applies to:
|
||||
|
||||
- `/api/v1/*`
|
||||
- `/api/v2/*`
|
||||
- `/api/v2-beta/*`
|
||||
|
||||
**Limit:** 1000 requests per minute per IP address
|
||||
**Response:** 429 Too Many Requests
|
||||
|
||||
@@ -19,7 +25,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
this value, in which case you can be rate-limited before reaching the global limit.
|
||||
</Callout>
|
||||
|
||||
### Rate Limit Response
|
||||
### Global per-IP 429 Response
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
|
||||
seconds before retrying.
|
||||
</Callout>
|
||||
### Rate Limit Headers
|
||||
|
||||
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
|
||||
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
|
||||
and carry no rate limit headers:
|
||||
|
||||
| Header | Description |
|
||||
| ----------------------- | ---------------------------------------------------------------------- |
|
||||
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
|
||||
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
|
||||
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
|
||||
|
||||
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
|
||||
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
|
||||
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
|
||||
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
|
||||
|
||||
## Resource Limits
|
||||
|
||||
@@ -44,24 +62,55 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
|
||||
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
||||
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
|
||||
|
||||
### Error Response
|
||||
### Organisation Limit 429 Responses
|
||||
|
||||
When you exceed a resource limit:
|
||||
Organisation windowed limits and organisation monthly quotas produce 429 responses whose body
|
||||
shape depends on the API version, and neither matches the global per-IP limiter's
|
||||
`{ "error": "..." }` body.
|
||||
|
||||
On `/api/v1/*`, the body contains only a message:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "You have reached your document limit for this month. Please upgrade your plan.",
|
||||
"code": "LIMIT_EXCEEDED",
|
||||
"statusCode": 400
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||
}
|
||||
```
|
||||
|
||||
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"data": {
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"httpStatus": 429,
|
||||
"appError": {
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
|
||||
their own window. Monthly quota responses carry no quota-specific rate limit headers or
|
||||
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Status | Description |
|
||||
| ------------------- | ------ | ----------------------------- |
|
||||
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
|
||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
||||
| Code | Status | Description |
|
||||
| ------------------- | ------ | ------------------------------------------------------------------ |
|
||||
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
|
||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
||||
|
||||
There are three sources of `TOO_MANY_REQUESTS` responses:
|
||||
|
||||
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
|
||||
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
|
||||
3. Organisation monthly quotas for the same three counters. Every authenticated API request
|
||||
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
|
||||
exhausted — not just envelope-related ones.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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_team_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_team_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_team_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_team_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);
|
||||
````
|
||||
|
||||
|
||||
@@ -256,8 +256,11 @@ Use templates for repeatable document workflows. This example creates an employm
|
||||
Map template fields by label and build a <code>prefillFields</code> array
|
||||
</Step>
|
||||
<Step>
|
||||
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
|
||||
<code>distributeDocument: true</code>
|
||||
Call <code>POST /template/use</code> with recipients and prefill data
|
||||
</Step>
|
||||
<Step>
|
||||
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
|
||||
links
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -291,7 +294,7 @@ type TemplateRecipient = {
|
||||
async function sendEmploymentContract(
|
||||
templateId: number,
|
||||
employee: EmployeeData,
|
||||
): Promise<{ documentId: string; signingUrl: string }> {
|
||||
): Promise<{ documentId: number; signingUrl: string }> {
|
||||
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
|
||||
headers: { Authorization: API_TOKEN },
|
||||
});
|
||||
@@ -368,7 +371,6 @@ async function sendEmploymentContract(
|
||||
subject: `Your Employment Contract at ${employee.department}`,
|
||||
message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`,
|
||||
},
|
||||
distributeDocument: true,
|
||||
externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`,
|
||||
}),
|
||||
});
|
||||
@@ -380,9 +382,25 @@ async function sendEmploymentContract(
|
||||
|
||||
const document = await createResponse.json();
|
||||
|
||||
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId: document.envelopeId }),
|
||||
});
|
||||
|
||||
if (!distributeResponse.ok) {
|
||||
const error = await distributeResponse.json();
|
||||
throw new Error(`Failed to send document: ${error.message}`);
|
||||
}
|
||||
|
||||
const distributeResult = await distributeResponse.json();
|
||||
|
||||
return {
|
||||
documentId: document.id,
|
||||
signingUrl: document.recipients[0].signingUrl,
|
||||
signingUrl: distributeResult.recipients[0].signingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -447,12 +465,17 @@ RESPONSE=$(curl -s -X POST "${BASE_URL}/template/use" \
|
||||
\"subject\": \"Your Employment Contract\",
|
||||
\"message\": \"Please review and sign your employment contract.\"
|
||||
},
|
||||
\"distributeDocument\": true,
|
||||
\"externalId\": \"emp-$(date +%s)-alice\"
|
||||
}")
|
||||
|
||||
echo "Document created:"
|
||||
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
|
||||
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
|
||||
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||
-H "Authorization: ${API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
|
||||
|
||||
echo "Document created: $(echo $RESPONSE | jq -r '.id')"
|
||||
echo "Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
@@ -470,8 +493,10 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
|
||||
Fetch the template and get the signer recipient slot ID
|
||||
</Step>
|
||||
<Step>
|
||||
For each recipient, call <code>POST /template/use</code> with{' '}
|
||||
<code>distributeDocument: true</code>
|
||||
For each recipient, call <code>POST /template/use</code>
|
||||
</Step>
|
||||
<Step>
|
||||
Distribute each returned envelope via <code>POST /envelope/distribute</code>
|
||||
</Step>
|
||||
<Step>
|
||||
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
||||
@@ -534,7 +559,6 @@ async function bulkSendFromTemplate(
|
||||
recipients: [
|
||||
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
|
||||
],
|
||||
distributeDocument: true,
|
||||
externalId: `bulk-${Date.now()}-${recipient.email}`,
|
||||
}),
|
||||
});
|
||||
@@ -545,10 +569,26 @@ async function bulkSendFromTemplate(
|
||||
}
|
||||
|
||||
const document = await response.json();
|
||||
|
||||
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId: document.envelopeId }),
|
||||
});
|
||||
|
||||
if (!distributeResponse.ok) {
|
||||
const error = await distributeResponse.json();
|
||||
throw new Error(error.message || 'Failed to distribute document');
|
||||
}
|
||||
|
||||
const distributeResult = await distributeResponse.json();
|
||||
return {
|
||||
email: recipient.email,
|
||||
envelopeId: document.id,
|
||||
signingUrl: document.recipients[0].signingUrl,
|
||||
envelopeId: document.envelopeId,
|
||||
signingUrl: distributeResult.recipients[0].signingUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -621,14 +661,24 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
|
||||
\"email\": \"${EMAIL}\",
|
||||
\"name\": \"${NAME}\"
|
||||
}],
|
||||
\"distributeDocument\": true,
|
||||
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
|
||||
}")
|
||||
|
||||
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
|
||||
echo " Success: $(echo $RESPONSE | jq -r '.id')"
|
||||
if echo $RESPONSE | jq -e '.envelopeId' > /dev/null 2>&1; then
|
||||
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
|
||||
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||
-H "Authorization: ${API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
|
||||
|
||||
if echo $DISTRIBUTE_RESPONSE | jq -e '.success' > /dev/null 2>&1; then
|
||||
echo " Success: ${ENVELOPE_ID}"
|
||||
echo " Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
|
||||
else
|
||||
echo " Failed to distribute: $(echo $DISTRIBUTE_RESPONSE | jq -r '.message')"
|
||||
fi
|
||||
else
|
||||
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
|
||||
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
|
||||
fi
|
||||
|
||||
# Rate limiting delay
|
||||
@@ -831,13 +881,15 @@ After a document is completed, download the signed PDF with all signatures embed
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The `version` query parameter accepts `original`, `pending`, or `signed`.
|
||||
|
||||
<Tabs items={['TypeScript', 'curl']}>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const API_TOKEN = process.env.DOCUMENSO_API_TOKEN;
|
||||
const BASE_URL = 'https://app.documenso.com/api/v2';
|
||||
|
||||
type DownloadVersion = 'signed' | 'original';
|
||||
type DownloadVersion = 'original' | 'pending' | 'signed';
|
||||
|
||||
async function downloadDocument(
|
||||
envelopeId: string,
|
||||
@@ -891,7 +943,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
|
||||
{ headers: { Authorization: API_TOKEN } },
|
||||
);
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
const { data, count, currentPage, perPage, totalPages } = await response.json();
|
||||
|
||||
for (const envelope of data) {
|
||||
try {
|
||||
@@ -905,8 +957,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,9 +1052,12 @@ async function fetchWithRetry(
|
||||
// Retry on rate limit
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
|
||||
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter) * 1000
|
||||
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
||||
console.log(`Rate limited, waiting ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 limited - wait for the duration in the `Retry-After` header |
|
||||
| `500` | Server error - retry or contact support |
|
||||
|
||||
### Error Response Format
|
||||
@@ -485,7 +485,7 @@ 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 allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. Every response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). When you receive a `429` response, read the `Retry-After` header and wait for that many seconds before retrying. See [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for a more complete retry strategy.
|
||||
|
||||
```javascript
|
||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||
@@ -493,8 +493,9 @@ 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 retryAfterSeconds = Number.parseInt(response.headers.get('Retry-After') ?? '1', 10);
|
||||
console.log(`Rate limited, waiting ${retryAfterSeconds} seconds...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events.
|
||||
2. When an event occurs, Documenso sends an HTTP POST to your URL
|
||||
3. Your application processes the event and responds with 200 OK
|
||||
|
||||
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used).
|
||||
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -304,7 +304,6 @@ Each webhook call shows the following details:
|
||||
- Timestamp
|
||||
- Response code
|
||||
- Request and response bodies
|
||||
- Response headers
|
||||
|
||||
Click any call to see full details including headers and response data.
|
||||
</Step>
|
||||
|
||||
@@ -18,6 +18,7 @@ export type DocumentPreferencesResetDialogProps = {
|
||||
onReset: () => Promise<void>;
|
||||
showAiFeatures?: boolean;
|
||||
showDocumentVisibility?: boolean;
|
||||
showIncludeSenderDetails?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentPreferencesResetDialog = ({
|
||||
@@ -25,6 +26,7 @@ export const DocumentPreferencesResetDialog = ({
|
||||
onReset,
|
||||
showAiFeatures = false,
|
||||
showDocumentVisibility = false,
|
||||
showIncludeSenderDetails = false,
|
||||
}: DocumentPreferencesResetDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
@@ -90,12 +92,29 @@ export const DocumentPreferencesResetDialog = ({
|
||||
<li>
|
||||
<Trans>Default signature settings</Trans>
|
||||
</li>
|
||||
{showIncludeSenderDetails && (
|
||||
<li>
|
||||
<Trans>Send on behalf of team</Trans>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Trans>Include the signing certificate in the document</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Include the audit logs in the document</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default recipients</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Delegate document ownership</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default envelope expiration</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default signing reminders</Trans>
|
||||
</li>
|
||||
{showAiFeatures && (
|
||||
<li>
|
||||
<Trans>AI features</Trans>
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
import {
|
||||
createZipWriter,
|
||||
sanitizeZipPathSegment,
|
||||
type ZipFileEntry,
|
||||
} from '@documenso/lib/client-only/create-zip-writer';
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { fetchPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* The maximum number of documents that can be downloaded in a single bulk
|
||||
* download. Each document requires fetching its full PDFs into the browser,
|
||||
* so this bounds both request volume and blob storage usage. Matches the
|
||||
* spirit of the server-side 100 cap on bulk move/delete/cancel.
|
||||
*/
|
||||
export const MAX_BULK_DOWNLOAD_ENVELOPES = 50;
|
||||
|
||||
type BulkDownloadVersion = 'signed' | 'original' | 'pending';
|
||||
|
||||
export type EnvelopeBulkDownloadItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: DocumentStatus;
|
||||
|
||||
/**
|
||||
* Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a
|
||||
* different field-rendering pipeline that the partial PDF helper does not
|
||||
* implement, so the Partial option is hidden for them.
|
||||
*/
|
||||
isLegacy: boolean;
|
||||
};
|
||||
|
||||
const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
|
||||
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original';
|
||||
|
||||
export type EnvelopesBulkDownloadDialogProps = {
|
||||
envelopes: EnvelopeBulkDownloadItem[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (successfulEnvelopeIds: string[]) => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopesBulkDownloadDialog = ({
|
||||
envelopes,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkDownloadDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [versionMap, setVersionMap] = useState<Record<string, BulkDownloadVersion>>({});
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const abortRef = useRef(false);
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)])));
|
||||
setProgress(0);
|
||||
}, [open]);
|
||||
|
||||
const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
|
||||
versionMap[envelope.id] ?? getDefaultVersion(envelope);
|
||||
|
||||
/**
|
||||
* The version options selectable for an envelope, mirroring the gating used
|
||||
* by the single envelope download dialog:
|
||||
* - COMPLETED: signed or original.
|
||||
* - PENDING (non-legacy): partial or original. Legacy envelopes use a
|
||||
* field-rendering pipeline the partial PDF helper does not implement.
|
||||
* - Anything else: original only, so no choice is shown.
|
||||
*/
|
||||
const getVersionOptions = (
|
||||
envelope: EnvelopeBulkDownloadItem,
|
||||
): { value: BulkDownloadVersion; label: string }[] | null => {
|
||||
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||
return [
|
||||
{ value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) },
|
||||
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
|
||||
];
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) {
|
||||
return [
|
||||
{ value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) },
|
||||
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: DocumentStatus) =>
|
||||
match(status)
|
||||
.with(DocumentStatus.COMPLETED, () => t`Completed`)
|
||||
.with(DocumentStatus.PENDING, () => t`Pending`)
|
||||
.with(DocumentStatus.DRAFT, () => t`Draft`)
|
||||
.with(DocumentStatus.REJECTED, () => t`Rejected`)
|
||||
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
|
||||
.exhaustive();
|
||||
|
||||
const onDownload = async () => {
|
||||
if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortRef.current = false;
|
||||
setIsDownloading(true);
|
||||
setProgress(0);
|
||||
|
||||
const zipWriter = createZipWriter();
|
||||
|
||||
const successfulEnvelopeIds: string[] = [];
|
||||
let failedDownloads = 0;
|
||||
|
||||
try {
|
||||
for (const envelope of envelopes) {
|
||||
if (abortRef.current) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadVersion = getDownloadVersion(envelope);
|
||||
|
||||
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
|
||||
envelopeId: envelope.id,
|
||||
access: {
|
||||
type: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
// Each envelope's items are grouped in their own folder. The id
|
||||
// prefix guarantees uniqueness, the truncated title keeps it
|
||||
// readable without risking overly long extraction paths.
|
||||
const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96));
|
||||
|
||||
// Buffer this envelope's files before writing so a failed envelope
|
||||
// is either fully in the zip or not at all. Files from previous
|
||||
// envelopes have already been written to the zip stream and freed.
|
||||
const envelopeFiles: ZipFileEntry[] = [];
|
||||
|
||||
for (const envelopeItem of envelopeItems) {
|
||||
const { filename, blob } = await fetchPDF({
|
||||
envelopeItem,
|
||||
token: undefined,
|
||||
fileName: envelopeItem.title,
|
||||
version: downloadVersion,
|
||||
});
|
||||
|
||||
envelopeFiles.push({
|
||||
filename: `${folderName}/${sanitizeZipPathSegment(filename)}`,
|
||||
data: blob,
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of envelopeFiles) {
|
||||
await zipWriter.addFile(file);
|
||||
}
|
||||
|
||||
successfulEnvelopeIds.push(envelope.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
failedDownloads++;
|
||||
}
|
||||
|
||||
setProgress((p) => p + 1);
|
||||
}
|
||||
|
||||
// The user intentionally stopped the download, discard anything fetched
|
||||
// so far without toasting an error.
|
||||
if (abortRef.current) {
|
||||
zipWriter.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
if (successfulEnvelopeIds.length === 0) {
|
||||
zipWriter.abort();
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while downloading the documents.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
downloadFile({
|
||||
filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`,
|
||||
data: zipWriter.finalize(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
zipWriter.abort();
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while downloading the documents.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedDownloads > 0) {
|
||||
toast({
|
||||
title: t`Documents partially downloaded`,
|
||||
description: t`${plural(successfulEnvelopeIds.length, {
|
||||
one: '# document downloaded.',
|
||||
other: '# documents downloaded.',
|
||||
})} ${plural(failedDownloads, {
|
||||
one: '# document could not be downloaded.',
|
||||
other: '# documents could not be downloaded.',
|
||||
})}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
onSuccess?.(successfulEnvelopeIds);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Documents downloaded`,
|
||||
description: plural(successfulEnvelopeIds.length, {
|
||||
one: '# document has been downloaded.',
|
||||
other: '# documents have been downloaded.',
|
||||
}),
|
||||
});
|
||||
|
||||
onSuccess?.(successfulEnvelopeIds);
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (!isDownloading) {
|
||||
onOpenChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Download Documents</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Plural
|
||||
value={envelopes.length}
|
||||
one="Select the version to download for the selected document."
|
||||
other="Select the version to download for each of the # selected documents."
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isOverDownloadLimit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
|
||||
continue.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset disabled={isDownloading} className="space-y-4">
|
||||
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
|
||||
<div className="divide-y divide-border rounded-lg border border-border">
|
||||
{envelopes.map((envelope) => {
|
||||
const versionOptions = getVersionOptions(envelope);
|
||||
|
||||
return (
|
||||
<div key={envelope.id} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground text-sm" title={envelope.title}>
|
||||
{envelope.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{getStatusLabel(envelope.status)}</p>
|
||||
</div>
|
||||
|
||||
{versionOptions && (
|
||||
<RadioGroupSegmented
|
||||
className="shrink-0"
|
||||
value={getDownloadVersion(envelope)}
|
||||
onValueChange={(value) =>
|
||||
setVersionMap((prev) => ({
|
||||
...prev,
|
||||
[envelope.id]: value as BulkDownloadVersion,
|
||||
}))
|
||||
}
|
||||
aria-label={t`Download version for ${envelope.title}`}
|
||||
>
|
||||
{versionOptions.map((option) => (
|
||||
<RadioGroupSegmentedItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</RadioGroupSegmentedItem>
|
||||
))}
|
||||
</RadioGroupSegmented>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Downloading {progress} / {envelopes.length}...
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (isDownloading) {
|
||||
abortRef.current = true;
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onDownload()}
|
||||
loading={isDownloading}
|
||||
disabled={envelopes.length === 0 || isOverDownloadLimit}
|
||||
>
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
||||
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
||||
))}
|
||||
|
||||
<Link
|
||||
to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
to="https://documen.so/enterprise-cta"
|
||||
target="_blank"
|
||||
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
|
||||
>
|
||||
|
||||
@@ -17,25 +17,28 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
export type TeamEmailDeleteDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
teamName: string;
|
||||
team: Pick<Team, 'id' | 'avatarImageId' | 'name'>;
|
||||
teamEmail: Pick<TeamEmail, 'email' | 'name'> | null;
|
||||
emailVerification: Pick<TeamEmailVerification, 'email' | 'name' | 'expiresAt'> | null;
|
||||
team: Prisma.TeamGetPayload<{
|
||||
include: {
|
||||
teamEmail: true;
|
||||
emailVerification: {
|
||||
select: {
|
||||
expiresAt: true;
|
||||
name: true;
|
||||
email: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export const TeamEmailDeleteDialog = ({
|
||||
trigger,
|
||||
teamName,
|
||||
team,
|
||||
teamEmail,
|
||||
emailVerification,
|
||||
}: TeamEmailDeleteDialogProps) => {
|
||||
export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { _ } = useLingui();
|
||||
@@ -80,11 +83,11 @@ export const TeamEmailDeleteDialog = ({
|
||||
});
|
||||
|
||||
const onRemove = async () => {
|
||||
if (teamEmail) {
|
||||
if (team.teamEmail) {
|
||||
await deleteTeamEmail({ teamId: team.id });
|
||||
}
|
||||
|
||||
if (emailVerification) {
|
||||
if (team.emailVerification) {
|
||||
await deleteTeamEmailVerification({ teamId: team.id });
|
||||
}
|
||||
|
||||
@@ -118,13 +121,13 @@ export const TeamEmailDeleteDialog = ({
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||
avatarFallback={extractInitials((teamEmail?.name || emailVerification?.name) ?? '')}
|
||||
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
|
||||
primaryText={
|
||||
<span className="font-semibold text-foreground/80 text-sm">
|
||||
{teamEmail?.name || emailVerification?.name}
|
||||
{team.teamEmail?.name || team.emailVerification?.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={<span className="text-sm">{teamEmail?.email || emailVerification?.email}</span>}
|
||||
secondaryText={<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>}
|
||||
/>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -23,8 +23,7 @@ import { useRevalidator } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type TeamEmailUpdateDialogProps = {
|
||||
teamId: number;
|
||||
teamEmail: Pick<TeamEmail, 'email' | 'name'>;
|
||||
teamEmail: TeamEmail;
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
@@ -34,7 +33,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
|
||||
|
||||
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||
|
||||
export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
|
||||
export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -54,7 +53,7 @@ export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }:
|
||||
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
||||
try {
|
||||
await updateTeamEmail({
|
||||
teamId,
|
||||
teamId: teamEmail.teamId,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
import { InheritableField } from './inheritable-field';
|
||||
|
||||
const ZBrandingPreferencesFormSchema = z.object({
|
||||
brandingEnabled: z.boolean().nullable(),
|
||||
@@ -211,13 +210,11 @@ export function BrandingPreferencesForm({
|
||||
control={form.control}
|
||||
name="brandingEnabled"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Enable Custom Branding</Trans>}
|
||||
testId="branding-enabled"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Enable Custom Branding</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
@@ -255,7 +252,7 @@ export function BrandingPreferencesForm({
|
||||
<Trans>Enable custom branding for all documents in this organisation</Trans>
|
||||
)}
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -266,13 +263,11 @@ export function BrandingPreferencesForm({
|
||||
control={form.control}
|
||||
name="brandingLogo"
|
||||
render={({ field: { value: _value, onChange, ...field } }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={!previewUrl}
|
||||
label={<Trans>Branding Logo</Trans>}
|
||||
testId="branding-logo"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Branding Logo</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
|
||||
{previewUrl ? (
|
||||
@@ -350,7 +345,7 @@ export function BrandingPreferencesForm({
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -358,13 +353,11 @@ export function BrandingPreferencesForm({
|
||||
control={form.control}
|
||||
name="brandingUrl"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={!field.value}
|
||||
label={<Trans>Brand Website</Trans>}
|
||||
testId="branding-url"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Brand Website</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="url" placeholder="https://example.com" disabled={!isBrandingEnabled} {...field} />
|
||||
</FormControl>
|
||||
@@ -379,7 +372,7 @@ export function BrandingPreferencesForm({
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -387,13 +380,11 @@ export function BrandingPreferencesForm({
|
||||
control={form.control}
|
||||
name="brandingCompanyDetails"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={!field.value}
|
||||
label={<Trans>Brand Details</Trans>}
|
||||
testId="branding-company-details"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Brand Details</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t`Enter your brand details`}
|
||||
@@ -413,7 +404,7 @@ export function BrandingPreferencesForm({
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Form, FormControl, FormDescription, FormField } from '@documenso/ui/primitives/form/form';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
import { InheritableField } from './inheritable-field';
|
||||
|
||||
const ZCertificatePreferencesFormSchema = z.object({
|
||||
includeSigningCertificate: z.boolean().nullable(),
|
||||
includeAuditLog: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
export type TCertificatePreferencesFormSchema = z.infer<typeof ZCertificatePreferencesFormSchema>;
|
||||
|
||||
type SettingsSubset = Pick<TeamGlobalSettings, 'includeSigningCertificate' | 'includeAuditLog'>;
|
||||
|
||||
export type CertificatePreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
onFormSubmit: (data: TCertificatePreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
export const CertificatePreferencesForm = ({ settings, canInherit, onFormSubmit }: CertificatePreferencesFormProps) => {
|
||||
const form = useForm<TCertificatePreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
},
|
||||
resolver: zodResolver(ZCertificatePreferencesFormSchema),
|
||||
});
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await onFormSubmit(data);
|
||||
} catch {
|
||||
// The page handler surfaces its own error toast. Keep the form dirty so
|
||||
// the save bar stays visible and the user can retry.
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(data);
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSigningCertificate"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Include the Signing Certificate in the Document</Trans>}
|
||||
testId="include-signing-certificate"
|
||||
>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-signing-certificate-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the signing certificate will be included in the document when it is downloaded. The
|
||||
signing certificate can still be downloaded from the logs page separately.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeAuditLog"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Include the Audit Logs in the Document</Trans>}
|
||||
testId="include-audit-log"
|
||||
>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-audit-log-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the audit logs will be included in the document when it is downloaded. The audit
|
||||
logs can still be downloaded from the logs page separately.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormStickySaveBar
|
||||
isDirty={form.formState.isDirty}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={() => form.reset()}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import {
|
||||
type TEnvelopeExpirationPeriod,
|
||||
ZEnvelopeExpirationPeriod,
|
||||
} from '@documenso/lib/constants/envelope-expiration';
|
||||
import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
||||
@@ -12,24 +16,36 @@ import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documens
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentVisibility, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
import { InheritableField } from './inheritable-field';
|
||||
|
||||
/**
|
||||
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
||||
@@ -40,10 +56,15 @@ export type TDocumentPreferencesFormSchema = {
|
||||
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
|
||||
documentTimezone: string | null;
|
||||
documentDateFormat: TDocumentMetaDateFormat | null;
|
||||
includeSenderDetails: boolean | null;
|
||||
includeSigningCertificate: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
defaultRecipients: TDefaultRecipients | null;
|
||||
delegateDocumentOwnership: boolean | null;
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
||||
reminderSettings: TEnvelopeReminderSettings | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
@@ -52,17 +73,23 @@ type SettingsSubset = Pick<
|
||||
| 'documentLanguage'
|
||||
| 'documentTimezone'
|
||||
| 'documentDateFormat'
|
||||
| 'includeSenderDetails'
|
||||
| 'includeSigningCertificate'
|
||||
| 'includeAuditLog'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
| 'envelopeExpirationPeriod'
|
||||
| 'reminderSettings'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
isAiFeaturesConfigured?: boolean;
|
||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -74,34 +101,50 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
|
||||
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
||||
documentTimezone: settings.documentTimezone,
|
||||
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: settings.reminderSettings ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }: DocumentPreferencesFormProps) => {
|
||||
export const DocumentPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
canInherit,
|
||||
isAiFeaturesConfigured = false,
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
const { user, organisations } = useSession();
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
const optionalTeam = useOptionalCurrentTeam();
|
||||
|
||||
const isAiFeaturesConfigured = IS_AI_FEATURES_CONFIGURED();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
||||
|
||||
const placeholderEmail = user.email ?? 'user@example.com';
|
||||
|
||||
const ZDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||
documentTimezone: z.string().nullable(),
|
||||
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
|
||||
includeSenderDetails: z.boolean().nullable(),
|
||||
includeSigningCertificate: z.boolean().nullable(),
|
||||
includeAuditLog: z.boolean().nullable(),
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
||||
delegateDocumentOwnership: z.boolean().nullable(),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||
});
|
||||
|
||||
const defaultValues = getDocumentPreferencesFormValues(settings);
|
||||
@@ -146,19 +189,17 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||
{!isPersonalLayoutMode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentVisibility"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Default Document Visibility</Trans>}
|
||||
testId="document-visibility"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Document Visibility</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
@@ -195,7 +236,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
<FormDescription>
|
||||
<Trans>Controls the default visibility of an uploaded document.</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -204,13 +245,11 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
control={form.control}
|
||||
name="documentLanguage"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Default Document Language</Trans>}
|
||||
testId="document-language"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Document Language</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
@@ -244,7 +283,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
communications with the recipients.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -252,12 +291,11 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
control={form.control}
|
||||
name="documentDateFormat"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Default Date Format</Trans>}
|
||||
testId="document-date-format"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Default Date Format</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
@@ -284,7 +322,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -292,12 +330,11 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
control={form.control}
|
||||
name="documentTimezone"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Default Time Zone</Trans>}
|
||||
testId="document-timezone"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Default Time Zone</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Combobox
|
||||
triggerPlaceholder={canInherit ? t`Inherit from organisation` : t`Local timezone`}
|
||||
@@ -310,7 +347,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -318,18 +355,12 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
control={form.control}
|
||||
name="signatureTypes"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={canInherit && (field.value === null || field.value.length === 0)}
|
||||
label={
|
||||
<span className="flex flex-row items-center">
|
||||
<Trans>Default Signature Settings</Trans>
|
||||
<DocumentSignatureSettingsTooltip />
|
||||
</span>
|
||||
}
|
||||
testId="signature-types"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Default Signature Settings</Trans>
|
||||
<DocumentSignatureSettingsTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||
@@ -352,7 +383,179 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
<Trans>Controls which signatures are allowed to be used when signing a document.</Trans>
|
||||
</FormDescription>
|
||||
)}
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPersonalLayoutMode && !isPersonalOrganisation && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-sender-details-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">
|
||||
<Trans>Preview</Trans>
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
||||
{field.value ? (
|
||||
<Trans>
|
||||
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>"Team Name" has invited you to sign "example document".</Trans>
|
||||
)}
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the formatting of the message that will be sent when inviting a recipient to sign a
|
||||
document. If a custom message has been provided while configuring the document, it will be used
|
||||
instead.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSigningCertificate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Include the Signing Certificate in the Document</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-signing-certificate-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the signing certificate will be included in the document when it is downloaded. The
|
||||
signing certificate can still be downloaded from the logs page separately.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeAuditLog"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Include the Audit Logs in the Document</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the audit logs will be included in the document when it is downloaded. The audit
|
||||
logs can still be downloaded from the logs page separately.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -363,13 +566,11 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
const recipients = field.value ?? [];
|
||||
|
||||
return (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Default Recipients</Trans>}
|
||||
testId="default-recipients"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Recipients</Trans>
|
||||
</FormLabel>
|
||||
|
||||
{canInherit && (
|
||||
<Select
|
||||
value={field.value === null ? '-1' : '0'}
|
||||
@@ -437,7 +638,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
<FormDescription>
|
||||
<Trans>Recipients that will be automatically added to new documents.</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -446,13 +647,11 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
control={form.control}
|
||||
name="delegateDocumentOwnership"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Delegate Document Ownership</Trans>}
|
||||
testId="delegate-document-ownership"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Delegate Document Ownership</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
@@ -482,7 +681,65 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
<FormDescription>
|
||||
<Trans>Enable team API tokens to delegate document ownership to another team member.</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="envelopeExpirationPeriod"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Envelope Expiration</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ExpirationPeriodPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls how long recipients have to complete signing before the document expires. After expiration,
|
||||
recipients can no longer sign the document.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reminderSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Signing Reminders</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ReminderSettingsPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls when and how often reminder emails are sent to recipients who have not yet completed
|
||||
signing.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -491,13 +748,11 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
control={form.control}
|
||||
name="aiFeaturesEnabled"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>AI Features</Trans>}
|
||||
testId="ai-features-enabled"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>AI Features</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
@@ -535,7 +790,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
prefer European regions where available.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -551,6 +806,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
onReset={handleResetToDefaults}
|
||||
showAiFeatures={isAiFeaturesConfigured}
|
||||
showDocumentVisibility={!isPersonalLayoutMode}
|
||||
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
|
||||
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -19,27 +17,22 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationType, type TeamGlobalSettings } from '@prisma/client';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
import { InheritableField } from './inheritable-field';
|
||||
|
||||
const ZEmailPreferencesFormSchema = z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: zEmail().nullable(),
|
||||
// emailReplyToName: z.string(),
|
||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
|
||||
includeSenderDetails: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
export type TEmailPreferencesFormSchema = z.infer<typeof ZEmailPreferencesFormSchema>;
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
TeamGlobalSettings,
|
||||
'emailId' | 'emailReplyTo' | 'emailDocumentSettings' | 'includeSenderDetails'
|
||||
>;
|
||||
type SettingsSubset = Pick<TeamGlobalSettings, 'emailId' | 'emailReplyTo' | 'emailDocumentSettings'>;
|
||||
|
||||
export type EmailPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
@@ -48,20 +41,14 @@ export type EmailPreferencesFormProps = {
|
||||
};
|
||||
|
||||
export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: EmailPreferencesFormProps) => {
|
||||
const { user } = useSession();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
|
||||
|
||||
const placeholderEmail = user.email ?? 'user@example.com';
|
||||
|
||||
const form = useForm<TEmailPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
emailId: settings.emailId,
|
||||
emailReplyTo: settings.emailReplyTo,
|
||||
// emailReplyToName: settings.emailReplyToName,
|
||||
emailDocumentSettings: settings.emailDocumentSettings,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
},
|
||||
resolver: zodResolver(ZEmailPreferencesFormSchema),
|
||||
});
|
||||
@@ -88,7 +75,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -135,12 +122,10 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
||||
control={form.control}
|
||||
name="emailReplyTo"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Reply to email</Trans>}
|
||||
testId="email-reply-to"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply to email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
@@ -161,7 +146,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -185,13 +170,10 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
||||
control={form.control}
|
||||
name="emailDocumentSettings"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Default Email Settings</Trans>}
|
||||
testId="email-document-settings"
|
||||
>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Email Settings</Trans>
|
||||
</FormLabel>
|
||||
{canInherit && (
|
||||
<Select
|
||||
value={field.value === null ? 'INHERIT' : 'CONTROLLED'}
|
||||
@@ -230,83 +212,10 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
||||
settings will not affect existing documents or templates.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPersonalOrganisation && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Send on Behalf of Team</Trans>}
|
||||
testId="include-sender-details"
|
||||
>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-sender-details-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">
|
||||
<Trans>Preview</Trans>
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
||||
{field.value ? (
|
||||
<Trans>
|
||||
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>"Team Name" has invited you to sign "example document".</Trans>
|
||||
)}
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the formatting of the message that will be sent when inviting a recipient to sign a
|
||||
document. If a custom message has been provided while configuring the document, it will be used
|
||||
instead.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormStickySaveBar
|
||||
isDirty={form.formState.isDirty}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
|
||||
@@ -5,22 +5,6 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const getScrollParent = (node: HTMLElement): HTMLElement | null => {
|
||||
let current = node.parentElement;
|
||||
|
||||
while (current) {
|
||||
const { overflowY } = getComputedStyle(current);
|
||||
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export type FormStickySaveBarProps = {
|
||||
isDirty: boolean;
|
||||
isSubmitting: boolean;
|
||||
@@ -59,18 +43,14 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefau
|
||||
}
|
||||
|
||||
// The sentinel sits at the bar's resting position (the end of the form). While the
|
||||
// bar is stuck to the bottom of the scroll container the sentinel is scrolled past
|
||||
// (out of view); once you reach the form's end it comes into view and the bar settles.
|
||||
//
|
||||
// Observe relative to the actual scroll container (not always the viewport) so a
|
||||
// banner shifting the page can't desync the detection from the sticky bar — both
|
||||
// then share the same reference box.
|
||||
// bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of
|
||||
// view); once you reach the form's end it comes into view and the bar settles.
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsStuck(!entry.isIntersecting);
|
||||
},
|
||||
{
|
||||
root: getScrollParent(sentinel),
|
||||
root: null,
|
||||
rootMargin: '0px 0px -24px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type InheritableFieldProps = {
|
||||
isInherited: boolean;
|
||||
canInherit: boolean;
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export const InheritableField = ({
|
||||
isInherited,
|
||||
canInherit,
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: InheritableFieldProps) => {
|
||||
if (!canInherit) {
|
||||
return (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
{children}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem className={className} data-testid={testId ? `inheritable-${testId}` : undefined}>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
{label}
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 font-bold text-[9px] uppercase tracking-wide',
|
||||
isInherited
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
|
||||
)}
|
||||
data-testid={testId ? `${testId}-status` : undefined}
|
||||
>
|
||||
{isInherited ? <Trans>Inherited</Trans> : <Trans>Override</Trans>}
|
||||
</span>
|
||||
</FormLabel>
|
||||
{children}
|
||||
</FormItem>
|
||||
);
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export const OrganisationUpdateForm = () => {
|
||||
await refreshSession();
|
||||
|
||||
if (url !== organisation.url) {
|
||||
await navigate(`/o/${url}/settings/general`);
|
||||
await navigate(`/o/${url}/settings`);
|
||||
}
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import {
|
||||
type TEnvelopeExpirationPeriod,
|
||||
ZEnvelopeExpirationPeriod,
|
||||
} from '@documenso/lib/constants/envelope-expiration';
|
||||
import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
import { InheritableField } from './inheritable-field';
|
||||
|
||||
const ZReminderPreferencesFormSchema = z.object({
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||
});
|
||||
|
||||
export type TReminderPreferencesFormSchema = {
|
||||
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
||||
reminderSettings: TEnvelopeReminderSettings | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<TeamGlobalSettings, 'envelopeExpirationPeriod' | 'reminderSettings'>;
|
||||
|
||||
export type ReminderPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
onFormSubmit: (data: TReminderPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ReminderPreferencesForm = ({ settings, canInherit, onFormSubmit }: ReminderPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TReminderPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: settings.reminderSettings ?? null,
|
||||
},
|
||||
resolver: zodResolver(ZReminderPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await onFormSubmit(data);
|
||||
} catch {
|
||||
// The page handler surfaces its own error toast. Keep the form dirty so
|
||||
// the save bar stays visible and the user can retry.
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(data);
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="envelopeExpirationPeriod"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Default Envelope Expiration</Trans>}
|
||||
testId="envelope-expiration-period"
|
||||
>
|
||||
<FormControl>
|
||||
<ExpirationPeriodPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls how long recipients have to complete signing before the document expires. After expiration,
|
||||
recipients can no longer sign the document.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reminderSettings"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Default Signing Reminders</Trans>}
|
||||
testId="reminder-settings"
|
||||
>
|
||||
<FormControl>
|
||||
<ReminderSettingsPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls when and how often reminder emails are sent to recipients who have not yet completed
|
||||
signing.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormStickySaveBar
|
||||
isDirty={form.formState.isDirty}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={() => form.reset()}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
@@ -45,7 +44,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
||||
return message;
|
||||
};
|
||||
|
||||
const LOGIN_REDIRECT_PATH = formatPath('/');
|
||||
const LOGIN_REDIRECT_PATH = '/';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: zEmail().min(1),
|
||||
|
||||
@@ -65,7 +65,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
});
|
||||
|
||||
if (url !== teamUrl) {
|
||||
await navigate(`/t/${url}/settings/general`);
|
||||
await navigate(`/t/${url}/settings`);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
DOCUMENTS_PAGE_SHORTCUT,
|
||||
@@ -288,7 +287,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
|
||||
{
|
||||
id: 'settings-main',
|
||||
label: msg`Settings`,
|
||||
path: '/settings/profile',
|
||||
path: '/settings',
|
||||
icon: SettingsIcon,
|
||||
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
@@ -390,7 +389,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
|
||||
|
||||
const formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `≥${count}` : `${count}`);
|
||||
|
||||
const goToSettings = useCallback(() => push('/settings/profile'), [push]);
|
||||
const goToSettings = useCallback(() => push('/settings'), [push]);
|
||||
const goToDocuments = useCallback(() => {
|
||||
if (teamUrl) {
|
||||
push(`/t/${teamUrl}/documents?status=ALL`);
|
||||
@@ -863,7 +862,7 @@ const PromptLanguageCommands = ({
|
||||
|
||||
formData.append('lang', lang);
|
||||
|
||||
const response = await fetch(formatPath('/api/locale'), {
|
||||
const response = await fetch('/api/locale', {
|
||||
method: 'post',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -12,16 +14,16 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { AppCommandMenu } from './app-command-menu';
|
||||
import { AppNavDesktop } from './app-nav-desktop';
|
||||
import { AppNavMobile } from './app-nav-mobile';
|
||||
import { MenuSwitcher } from './menu-switcher';
|
||||
import { OrgMenuSwitcher } from './org-menu-switcher';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
/** Span the full viewport width instead of the centered max-w-screen-xl container. */
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Header = ({ className, fullWidth = false, ...props }: HeaderProps) => {
|
||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
const params = useParams();
|
||||
|
||||
const { organisations } = useSession();
|
||||
|
||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
@@ -49,18 +51,12 @@ export const Header = ({ className, fullWidth = false, ...props }: HeaderProps)
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent bg-background/95 backdrop-blur duration-200 supports-backdrop-blur:bg-background/60',
|
||||
(scrollY > 5 || fullWidth) && 'border-b-border',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full items-center justify-between gap-x-4 px-4 md:justify-normal',
|
||||
fullWidth ? 'md:px-6' : 'max-w-screen-xl md:px-8',
|
||||
)}
|
||||
data-testid="app-header-container"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
to={getRootHref(params)}
|
||||
className="hidden rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:inline"
|
||||
@@ -82,9 +78,7 @@ export const Header = ({ className, fullWidth = false, ...props }: HeaderProps)
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="md:ml-4">
|
||||
<OrgMenuSwitcher />
|
||||
</div>
|
||||
<div className="md:ml-4">{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -36,8 +37,8 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
|
||||
const menuNavigationLinks = useMemo(() => {
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
|
||||
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
|
||||
teamUrl = organisations[0].teams[0].url;
|
||||
if (!teamUrl && isPersonalLayout(organisations)) {
|
||||
teamUrl = organisations[0].teams[0]?.url || null;
|
||||
}
|
||||
|
||||
if (!teamUrl) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
@@ -39,8 +40,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
const menuNavigationLinks = useMemo(() => {
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
|
||||
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
|
||||
teamUrl = organisations[0].teams[0].url;
|
||||
if (!teamUrl && isPersonalLayout(organisations)) {
|
||||
teamUrl = organisations[0].teams[0]?.url || null;
|
||||
}
|
||||
|
||||
if (!teamUrl) {
|
||||
|
||||
@@ -317,6 +317,7 @@ export const IndividualPersonalLayoutCheckoutButton = ({
|
||||
const createSubscriptionResponse = await createSubscription({
|
||||
organisationId: organisations[0].id,
|
||||
priceId,
|
||||
isPersonalLayoutMode: true,
|
||||
});
|
||||
|
||||
window.location.href = createSubscriptionResponse.redirectUrl;
|
||||
|
||||
+1
-4
@@ -1,5 +1,4 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
@@ -35,9 +34,7 @@ export const DocumentSigningAuthAccount = ({
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
await authClient.signOut({
|
||||
redirectPath: formatPath(
|
||||
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||
),
|
||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||
});
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -22,10 +21,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
let redirectPath = formatPath('/signin');
|
||||
let redirectPath = '/signin';
|
||||
|
||||
if (email) {
|
||||
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
|
||||
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
||||
}
|
||||
|
||||
await authClient.signOut({
|
||||
|
||||
@@ -2,24 +2,38 @@ 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 { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
export const DocumentSearch = () => {
|
||||
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
} else {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setQuery(debouncedSearchTerm || null);
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, query, setQuery]);
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
+7
-21
@@ -42,11 +42,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
|
||||
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocument,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
const { mutateAsync: completeDocument, isPending } = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const { mutateAsync: createDocumentFromDirectTemplate } =
|
||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||
@@ -110,21 +106,11 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// The document was already completed by an earlier request (retry,
|
||||
// stale tab or concurrent submission). Let the user know this click
|
||||
// didn't complete the document, then continue to the completed page.
|
||||
if (result.status === 'ALREADY_SIGNED') {
|
||||
toast({
|
||||
title: t`Document already signed`,
|
||||
description: t`This document was already signed and no further action was taken.`,
|
||||
});
|
||||
} else {
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: envelope.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: envelope.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (onDocumentCompleted) {
|
||||
onDocumentCompleted({
|
||||
@@ -260,7 +246,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
|
||||
return (
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isPending || isSuccess}
|
||||
isSubmitting={isPending}
|
||||
recipientPayload={recipientPayload}
|
||||
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
|
||||
documentTitle={envelope.title}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type FilterPillOption = {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
trailing?: string;
|
||||
};
|
||||
|
||||
type FilterPillCommonProps = {
|
||||
icon: LucideIcon;
|
||||
label: ReactNode;
|
||||
options: FilterPillOption[];
|
||||
enableSearch?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export type FilterPillSingleProps = FilterPillCommonProps & {
|
||||
multiple?: false;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
selectedLabel?: ReactNode;
|
||||
};
|
||||
|
||||
export type FilterPillMultipleProps = FilterPillCommonProps & {
|
||||
multiple: true;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
|
||||
|
||||
/**
|
||||
* A faceted filter pill.
|
||||
*
|
||||
* Renders as a dashed "add a filter" pill at rest, and shows the current
|
||||
* selection inline once a value is picked. Selecting the active option
|
||||
* again (or the Clear row) removes it.
|
||||
*
|
||||
* Single select by default, closing on pick. When `multiple` is set the
|
||||
* popover stays open for toggling, and the trigger shows the first two
|
||||
* selections followed by a "+N more" chip.
|
||||
*/
|
||||
export const FilterPill = (props: FilterPillProps) => {
|
||||
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value];
|
||||
|
||||
const selectedOptions = selectedValues
|
||||
.map((value) => options.find((option) => option.value === value))
|
||||
.filter((option): option is FilterPillOption => option !== undefined);
|
||||
|
||||
const hasSelection = selectedOptions.length > 0;
|
||||
const extraCount = selectedOptions.length - 2;
|
||||
|
||||
const onSelect = (nextValue: string) => {
|
||||
if (props.multiple) {
|
||||
const newValues = selectedValues.includes(nextValue)
|
||||
? selectedValues.filter((value) => value !== nextValue)
|
||||
: [...selectedValues, nextValue];
|
||||
|
||||
props.onChange(newValues);
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange(nextValue === props.value ? null : nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
if (props.multiple) {
|
||||
props.onChange([]);
|
||||
} else {
|
||||
props.onChange(null);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
className={cn('border-dashed text-muted-foreground', {
|
||||
'border-solid text-foreground': hasSelection,
|
||||
})}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
|
||||
{props.multiple ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
{selectedOptions.slice(0, 2).map((option) => (
|
||||
<Badge key={option.value} variant="neutral" size="small">
|
||||
{option.label}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{extraCount > 0 && (
|
||||
<Badge variant="neutral" size="small">
|
||||
<Trans>+{extraCount} more</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{props.selectedLabel ?? selectedOptions[0].label}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-52 p-0" align="start">
|
||||
<Command>
|
||||
{enableSearch && <CommandInput placeholder={searchPlaceholder} />}
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4 shrink-0',
|
||||
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
{option.label}
|
||||
|
||||
{option.trailing !== undefined && (
|
||||
<span className="ml-auto pl-4 text-muted-foreground text-xs">{option.trailing}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem className="justify-center text-center text-muted-foreground" onSelect={onClear}>
|
||||
<Trans>Clear</Trans>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ChevronsUpDown, Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export const MenuSwitcher = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { user } = useSession();
|
||||
|
||||
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
||||
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const formatAvatarFallback = (name?: string) => {
|
||||
if (name !== undefined) {
|
||||
return name.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="menu-switcher"
|
||||
variant="none"
|
||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(user.avatarImageId)}
|
||||
avatarFallback={formatAvatarFallback(user.name || user.email)}
|
||||
primaryText={user.name}
|
||||
secondaryText={_(msg`Personal Account`)}
|
||||
rightSideComponent={<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />}
|
||||
textSectionClassName="hidden lg:flex"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')} align="end" forceMount>
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to="/settings/organisations?action=add-organisation" className="flex items-center justify-between">
|
||||
<Trans>Create Organisation</Trans>
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isUserAdmin && (
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to="/admin">
|
||||
<Trans>Admin panel</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to="/inbox">
|
||||
<Trans>Personal Inbox</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to="/settings/profile">
|
||||
<Trans>User settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" onClick={() => setLanguageSwitcherOpen(true)}>
|
||||
<Trans>Language</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="hover:!text-destructive px-4 py-2 text-destructive/90"
|
||||
onSelect={async () => authClient.signOut()}
|
||||
>
|
||||
<Trans>Sign Out</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
@@ -56,12 +55,6 @@ export const OrgMenuSwitcher = () => {
|
||||
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||
const currentTeam = useOptionalCurrentTeam();
|
||||
|
||||
const canAccessOrganisationSettings =
|
||||
currentOrganisation &&
|
||||
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole);
|
||||
|
||||
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
|
||||
|
||||
// Use hovered org for teams display if available,
|
||||
// otherwise use current team's org if in a team,
|
||||
// finally fallback to selected org
|
||||
@@ -168,7 +161,7 @@ export const OrgMenuSwitcher = () => {
|
||||
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
|
||||
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
|
||||
<Link
|
||||
to={`/o/${org.url}/settings/general`}
|
||||
to={`/o/${org.url}/settings`}
|
||||
className="mr-2 rounded-sm border p-1 text-muted-foreground transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
|
||||
>
|
||||
<Settings2Icon className="h-3.5 w-3.5" />
|
||||
@@ -221,7 +214,7 @@ export const OrgMenuSwitcher = () => {
|
||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
|
||||
<Link
|
||||
to={`/t/${team.url}/settings/general`}
|
||||
to={`/t/${team.url}/settings`}
|
||||
className="mr-2 rounded-sm border p-1 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||
>
|
||||
<Settings2Icon className="h-3.5 w-3.5" />
|
||||
@@ -265,23 +258,26 @@ export const OrgMenuSwitcher = () => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to="/inbox">
|
||||
<Trans>Inbox</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{currentOrganisation &&
|
||||
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole) && (
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to={`/o/${currentOrganisation.url}/settings`}>
|
||||
<Trans>Organisation settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||
<Trans>Team settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link
|
||||
to={
|
||||
canAccessOrganisationSettings
|
||||
? `/o/${currentOrganisation?.url}/settings/general`
|
||||
: canAccessTeamSettings
|
||||
? `/t/${currentTeam?.url}/settings/general`
|
||||
: '/settings/profile'
|
||||
}
|
||||
>
|
||||
<Trans>Settings</Trans>
|
||||
<Link to="/inbox">
|
||||
<Trans>Personal Inbox</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -291,14 +287,6 @@ export const OrgMenuSwitcher = () => {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||
<Link to="/settings/billing">
|
||||
<Trans>Billing</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="px-4 py-2 text-muted-foreground"
|
||||
onClick={() => setLanguageSwitcherOpen(true)}
|
||||
|
||||
+6
-2
@@ -1,5 +1,6 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -12,6 +13,8 @@ export type OrganisationBillingPortalButtonProps = {
|
||||
};
|
||||
|
||||
export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBillingPortalButtonProps) => {
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { _ } = useLingui();
|
||||
@@ -25,10 +28,11 @@ export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBil
|
||||
try {
|
||||
const { redirectUrl } = await manageSubscription({
|
||||
organisationId: organisation.id,
|
||||
isPersonalLayoutMode: isPersonalLayout(organisations),
|
||||
});
|
||||
|
||||
window.open(redirectUrl, '_blank');
|
||||
} catch (_err) {
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
|
||||
@@ -12,9 +12,9 @@ export type SettingsHeaderProps = {
|
||||
export const SettingsHeader = ({ children, title, subtitle, className, hideDivider }: SettingsHeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('mb-4 flex flex-row items-center justify-between', className)}>
|
||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
||||
<div>
|
||||
<h2 className="font-bold text-xl">{title}</h2>
|
||||
<h3 className="font-medium text-lg">{title}</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@ export const SettingsHeader = ({ children, title, subtitle, className, hideDivid
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{!hideDivider && <hr className="mb-4" />}
|
||||
{!hideDivider && <hr className="my-4" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { BracesIcon, CreditCardIcon, Globe2Icon, Lock, Settings2Icon, User, Users, WebhookIcon } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const { organisations } = useSession();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const hasManageableBillingOrgs = organisations.some((org) =>
|
||||
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
<Link to="/settings/profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
<Trans>Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPersonalLayoutMode && (
|
||||
<>
|
||||
<Link to="/settings/document">
|
||||
<Button variant="ghost" className={cn('w-full justify-start')}>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/document">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
|
||||
>
|
||||
<Trans>Document</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/branding">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
|
||||
>
|
||||
<Trans>Branding</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/email">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
|
||||
>
|
||||
<Trans>Email</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/tokens">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
|
||||
>
|
||||
<BracesIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/webhooks">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
|
||||
>
|
||||
<WebhookIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link to="/settings/organisations">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Organisations</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
|
||||
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
|
||||
>
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
<Trans>Security</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
BracesIcon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
Lock,
|
||||
MailIcon,
|
||||
PaletteIcon,
|
||||
Settings2Icon,
|
||||
User,
|
||||
Users,
|
||||
WebhookIcon,
|
||||
} from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const { organisations } = useSession();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const hasManageableBillingOrgs = organisations.some((org) =>
|
||||
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)} {...props}>
|
||||
<Link to="/settings/profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
<Trans>Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPersonalLayoutMode && (
|
||||
<>
|
||||
<Link to="/settings/document">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
|
||||
>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Document Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/branding">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
|
||||
>
|
||||
<PaletteIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Branding Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/email">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
|
||||
>
|
||||
<MailIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Email Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/tokens">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
|
||||
>
|
||||
<BracesIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/webhooks">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
|
||||
>
|
||||
<WebhookIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link to="/settings/organisations">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Organisations</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
|
||||
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
|
||||
>
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
<Trans>Security</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { type INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
|
||||
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
const SEARCH_THRESHOLD = 5;
|
||||
|
||||
export type SettingsOrgSwitcherProps = {
|
||||
currentOrgUrl: string;
|
||||
};
|
||||
|
||||
export const SettingsOrgSwitcher = ({ currentOrgUrl }: SettingsOrgSwitcherProps) => {
|
||||
const { t } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const manageableOrgs = useMemo(
|
||||
() =>
|
||||
organisations.filter(
|
||||
(org) =>
|
||||
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
|
||||
org.teams.some((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)),
|
||||
),
|
||||
[organisations],
|
||||
);
|
||||
|
||||
const currentOrg = manageableOrgs.find((org) => org.url === currentOrgUrl);
|
||||
|
||||
const hasManageableBillingOrgs = useMemo(
|
||||
() => organisations.some((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole)),
|
||||
[organisations],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
if (!q) {
|
||||
return manageableOrgs;
|
||||
}
|
||||
|
||||
return manageableOrgs.filter((org) => org.name.toLowerCase().includes(q));
|
||||
}, [manageableOrgs, query]);
|
||||
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
|
||||
const handleSelect = (orgUrl: string) => {
|
||||
const destinationOrg = manageableOrgs.find((org) => org.url === orgUrl);
|
||||
|
||||
if (!destinationOrg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manageableTeam = destinationOrg.teams.find((team) =>
|
||||
canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole),
|
||||
);
|
||||
|
||||
const destinationGroups = getSettingsNavGroups({
|
||||
organisation: {
|
||||
url: destinationOrg.url,
|
||||
currentOrganisationRole: destinationOrg.currentOrganisationRole,
|
||||
organisationClaim: destinationOrg.organisationClaim,
|
||||
},
|
||||
team: manageableTeam ? { url: manageableTeam.url, currentTeamRole: manageableTeam.currentTeamRole } : null,
|
||||
hasManageableBillingOrgs,
|
||||
});
|
||||
|
||||
// The list also contains organisations the user can only reach through a team they
|
||||
// manage — for those `getSettingsNavGroups` returns no organisation group, so we land
|
||||
// them in the team group instead of on an organisation page they aren't authorised for.
|
||||
const destinationGroup = destinationGroups.organisation ?? destinationGroups.team;
|
||||
|
||||
if (!destinationGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
|
||||
void navigate(
|
||||
computeSwitcherContinuityPath({
|
||||
currentPath: pathname,
|
||||
destinationPaths: destinationGroup.items.map((item) => item.path),
|
||||
fallbackPath: destinationGroup.items[0].path,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (!currentOrg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve an organisation's plan label. Unknown or custom claims (including
|
||||
// self-hosted custom claim IDs) fall back to "Custom Plan".
|
||||
const getPlanName = (organisationClaimId: string | null) => {
|
||||
const planClaim =
|
||||
organisationClaimId && organisationClaimId in internalClaims
|
||||
? internalClaims[organisationClaimId as INTERNAL_CLAIM_ID]
|
||||
: undefined;
|
||||
|
||||
return planClaim ? t`${planClaim.name} Plan` : t`Custom Plan`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="settings-org-switcher-trigger"
|
||||
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
|
||||
>
|
||||
<AvatarWithText
|
||||
className="max-w-none"
|
||||
avatarClass="h-8 w-8"
|
||||
avatarSrc={formatAvatarUrl(currentOrg.avatarImageId)}
|
||||
avatarFallback={currentOrg.name.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold text-muted-foreground">{currentOrg.name}</span>}
|
||||
secondaryText={
|
||||
isBillingEnabled ? getPlanName(currentOrg.organisationClaim.originalSubscriptionClaimId) : undefined
|
||||
}
|
||||
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
data-testid="settings-org-switcher-content"
|
||||
>
|
||||
{manageableOrgs.length >= SEARCH_THRESHOLD && (
|
||||
<div className="border-b p-2">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t`Search organisations…`}
|
||||
className="h-8 pl-7"
|
||||
data-testid="settings-org-switcher-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="max-h-72 space-y-1 overflow-auto p-1">
|
||||
{filtered.map((org) => {
|
||||
const isCurrent = org.url === currentOrgUrl;
|
||||
return (
|
||||
<li key={org.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(org.url)}
|
||||
className={cn(
|
||||
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
|
||||
isCurrent && 'bg-muted',
|
||||
)}
|
||||
data-testid={`settings-org-switcher-item-${org.url}`}
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarClass="h-8 w-8"
|
||||
avatarSrc={formatAvatarUrl(org.avatarImageId)}
|
||||
avatarFallback={org.name.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{org.name}</span>}
|
||||
secondaryText={
|
||||
isBillingEnabled ? getPlanName(org.organisationClaim.originalSubscriptionClaimId) : undefined
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="border-t p-1">
|
||||
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-org-switcher-create">
|
||||
<a href="/settings/organisations?action=add-organisation">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Create organisation</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Building2Icon, ChevronRightIcon, UserIcon, Users2Icon } from 'lucide-react';
|
||||
|
||||
export type SettingsScopeBreadcrumbProps = {
|
||||
scope: 'organisation' | 'team' | 'account';
|
||||
scopeName: string;
|
||||
crumbs: string[];
|
||||
};
|
||||
|
||||
export const SettingsScopeBreadcrumb = ({ scope, scopeName, crumbs }: SettingsScopeBreadcrumbProps) => {
|
||||
return (
|
||||
<nav
|
||||
aria-label="settings-scope-breadcrumb"
|
||||
className="mb-4 flex flex-wrap items-center gap-2 text-muted-foreground text-sm"
|
||||
data-testid="settings-scope-breadcrumb"
|
||||
>
|
||||
<span>{scopeName}</span>
|
||||
|
||||
{crumbs.map((crumb, idx) => {
|
||||
const isLeaf = idx === crumbs.length - 1;
|
||||
|
||||
return (
|
||||
<span key={`${crumb}-${idx}`} className="flex items-center gap-2">
|
||||
<ChevronRightIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
<span className={cn(isLeaf && 'font-semibold text-foreground')}>{crumb}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<Badge
|
||||
variant={scope === 'organisation' ? 'default' : scope === 'team' ? 'secondary' : 'neutral'}
|
||||
role="presentation"
|
||||
className="ml-auto gap-1.5"
|
||||
data-testid="settings-scope-breadcrumb-chip"
|
||||
>
|
||||
{scope === 'organisation' && (
|
||||
<>
|
||||
<Building2Icon className="h-3.5 w-3.5" />
|
||||
<Trans>Organisation Settings</Trans>
|
||||
</>
|
||||
)}
|
||||
{scope === 'team' && (
|
||||
<>
|
||||
<Users2Icon className="h-3.5 w-3.5" />
|
||||
<Trans>Team Settings</Trans>
|
||||
</>
|
||||
)}
|
||||
{scope === 'account' && (
|
||||
<>
|
||||
<UserIcon className="h-3.5 w-3.5" />
|
||||
<Trans>Account Settings</Trans>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
|
||||
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
const SEARCH_THRESHOLD = 5;
|
||||
|
||||
export type SettingsTeamSwitcherProps = {
|
||||
currentOrgUrl: string;
|
||||
currentTeamUrl: string | null;
|
||||
};
|
||||
|
||||
export const SettingsTeamSwitcher = ({ currentOrgUrl, currentTeamUrl }: SettingsTeamSwitcherProps) => {
|
||||
const { t } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const currentOrg = organisations.find((org) => org.url === currentOrgUrl);
|
||||
|
||||
const manageableTeams = useMemo(
|
||||
() =>
|
||||
currentOrg ? currentOrg.teams.filter((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) : [],
|
||||
[currentOrg],
|
||||
);
|
||||
|
||||
const currentTeam = manageableTeams.find((team) => team.url === currentTeamUrl) ?? manageableTeams[0];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
if (!q) {
|
||||
return manageableTeams;
|
||||
}
|
||||
|
||||
return manageableTeams.filter((team) => team.name.toLowerCase().includes(q));
|
||||
}, [manageableTeams, query]);
|
||||
|
||||
const handleSelect = (teamUrl: string) => {
|
||||
const destinationTeam = manageableTeams.find((team) => team.url === teamUrl);
|
||||
|
||||
if (!currentOrg || !destinationTeam) {
|
||||
return;
|
||||
}
|
||||
|
||||
const destinationGroup = getSettingsNavGroups({
|
||||
organisation: {
|
||||
url: currentOrg.url,
|
||||
currentOrganisationRole: currentOrg.currentOrganisationRole,
|
||||
organisationClaim: currentOrg.organisationClaim,
|
||||
},
|
||||
team: { url: destinationTeam.url, currentTeamRole: destinationTeam.currentTeamRole },
|
||||
hasManageableBillingOrgs: false,
|
||||
}).team;
|
||||
|
||||
if (!destinationGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
|
||||
void navigate(
|
||||
computeSwitcherContinuityPath({
|
||||
currentPath: pathname,
|
||||
destinationPaths: destinationGroup.items.map((item) => item.path),
|
||||
fallbackPath: destinationGroup.items[0].path,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (!currentOrg || !currentTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="settings-team-switcher-trigger"
|
||||
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
|
||||
>
|
||||
<AvatarWithText
|
||||
className="max-w-none"
|
||||
avatarClass="h-8 w-8"
|
||||
avatarSrc={formatAvatarUrl(currentTeam.avatarImageId)}
|
||||
avatarFallback={currentTeam.name.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold text-muted-foreground">{currentTeam.name}</span>}
|
||||
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
data-testid="settings-team-switcher-content"
|
||||
>
|
||||
{manageableTeams.length >= SEARCH_THRESHOLD && (
|
||||
<div className="border-b p-2">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t`Search teams…`}
|
||||
className="h-8 pl-7"
|
||||
data-testid="settings-team-switcher-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="max-h-72 space-y-1 overflow-auto p-1">
|
||||
{filtered.map((team) => {
|
||||
const isCurrent = team.url === currentTeam.url;
|
||||
return (
|
||||
<li key={team.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(team.url)}
|
||||
className={cn(
|
||||
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
|
||||
isCurrent && 'bg-muted',
|
||||
)}
|
||||
data-testid={`settings-team-switcher-item-${team.url}`}
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarClass="h-8 w-8"
|
||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||
avatarFallback={team.name.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{team.name}</span>}
|
||||
secondaryText={t(EXTENDED_TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="border-t p-1">
|
||||
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-team-switcher-create">
|
||||
<a href={`/o/${currentOrg.url}/settings/teams?action=add-team`}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Create team</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
const DEMO_BRANDS = [
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#A2E771',
|
||||
ink: '#162C07',
|
||||
tint: '#F2FBEA',
|
||||
sheen: 'rgba(162, 231, 113, 0.32)',
|
||||
},
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#387BC7',
|
||||
ink: '#ffffff',
|
||||
tint: '#EDF3FA',
|
||||
sheen: 'rgba(56, 123, 199, 0.28)',
|
||||
},
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#9747F5',
|
||||
ink: '#ffffff',
|
||||
tint: '#F4EDFE',
|
||||
sheen: 'rgba(151, 71, 245, 0.26)',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Milliseconds each brand is shown before cycling to the next.
|
||||
*/
|
||||
const BRAND_CYCLE_INTERVAL_MS = 2400;
|
||||
|
||||
export const BrandingUpsell = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS));
|
||||
|
||||
const isStatic = isReducedMotion ?? false;
|
||||
|
||||
const brand = DEMO_BRANDS[brandIndex];
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Teams</Trans>}
|
||||
title={<Trans>Unlock Branding Preferences</Trans>}
|
||||
description={
|
||||
<Trans>Put your own brand on every document you send. Branding is available on the Teams plan and above.</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="logo">Your logo on signing pages and emails</Trans>,
|
||||
<Trans key="details">Company details and website in email footers</Trans>,
|
||||
<Trans key="teams">Separate branding per team</Trans>,
|
||||
]}
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="flex h-8 items-center justify-between px-1">
|
||||
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
<Trans>Brand accent</Trans>
|
||||
</span>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{DEMO_BRANDS.map((dotBrand, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{
|
||||
scale: index === brandIndex ? 1.25 : 1,
|
||||
opacity: index === brandIndex ? 1 : 0.42,
|
||||
boxShadow:
|
||||
index === brandIndex ? '0 0 0 3px rgba(15, 23, 42, 0.08)' : '0 0 0 0 rgba(15, 23, 42, 0)',
|
||||
}}
|
||||
transition={SPRING}
|
||||
className="h-[13px] w-[13px] rounded-full"
|
||||
style={{ backgroundColor: dotBrand.accent }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-3 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.tint }}
|
||||
transition={{ duration: 0.45, ease: EASE }}
|
||||
className="flex items-center gap-2.5 border-b px-4 py-3"
|
||||
>
|
||||
{/* The sender identity never changes — only the tile colours tween per brand. */}
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg font-semibold text-sm"
|
||||
>
|
||||
{brand.letter}
|
||||
</motion.div>
|
||||
|
||||
{/*
|
||||
* Hardcoded inks (not theme tokens): this row sits on the
|
||||
* hardcoded light `tint` band, so it pairs with hardcoded ink
|
||||
* colours the same way the email sibling pairs its hardcoded
|
||||
* avatar surfaces (hardcoded surface => hardcoded ink).
|
||||
*/}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-[#0f172a] text-sm">
|
||||
<p className="truncate">{brand.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-[#64748b] text-xs">
|
||||
<p className="truncate">{brand.domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Please sign: Example.pdf</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||
</p>
|
||||
|
||||
{/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */}
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||
className="mt-3 inline-block rounded-md px-3 py-1.5 font-medium text-xs"
|
||||
>
|
||||
<motion.span
|
||||
key={brandIndex}
|
||||
initial={isStatic ? false : { scale: 0.96 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ ...POP, delay: 0.06 }}
|
||||
className="inline-block"
|
||||
>
|
||||
<Trans>Sign</Trans>
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2.5 border-t bg-muted px-4 py-2.5">
|
||||
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
<Trans>Company details</Trans>
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="h-1.5 w-[54px] rounded-full"
|
||||
style={{ opacity: 0.45 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="h-1.5 w-[34px] rounded-full"
|
||||
style={{ opacity: 0.22 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Keyed remount replays the sweep per brand. No opacity envelope —
|
||||
* keyframe arrays are unreliable on strict-mode remounts; both
|
||||
* endpoints sit outside the overflow-hidden card, so the clip
|
||||
* provides the fade in/out instead.
|
||||
*/}
|
||||
<motion.div
|
||||
key={`sheen-${brandIndex}`}
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '240%' }}
|
||||
transition={{ duration: 1.15, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||
style={{ background: `linear-gradient(105deg, transparent, ${brand.sheen}, transparent)` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { BadgeCheckIcon, MailIcon } from 'lucide-react';
|
||||
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
/**
|
||||
* Named sender identities cycled through while the preview is in its branded
|
||||
* state — one per branded cycle step, shown as the sender name and address.
|
||||
*/
|
||||
const BRANDED_SENDERS = [
|
||||
{ name: 'Support', email: 'support@example.com' },
|
||||
{ name: 'Team', email: 'hello@example.com' },
|
||||
{ name: 'Sales', email: 'sales@example.com' },
|
||||
{ name: 'Example', email: 'noreply@example.com' },
|
||||
];
|
||||
|
||||
/**
|
||||
* How long the initial unbranded (Documenso default) state is shown before
|
||||
* the first flip starts. Shown exactly once — the cycle never returns to it.
|
||||
*/
|
||||
const INITIAL_STATE_DURATION_MS = 2500;
|
||||
|
||||
/**
|
||||
* How long each branded sender identity is shown before cycling to the next,
|
||||
* giving the viewer time to read the changed address.
|
||||
*/
|
||||
const BRANDED_STATE_DURATION_MS = 5000;
|
||||
|
||||
/**
|
||||
* One duration per cycle step: the unbranded state first, then one step per
|
||||
* named sender identity, derived from the identity count so the two cannot
|
||||
* drift.
|
||||
*/
|
||||
const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)];
|
||||
|
||||
export const EmailDomainsUpsell = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
|
||||
// Loop from index 1: the unbranded Documenso intro plays exactly once,
|
||||
// then the cycle rotates through the branded senders only.
|
||||
const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1);
|
||||
|
||||
const isBranded = cycleIndex > 0;
|
||||
const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0];
|
||||
|
||||
const isStatic = isReducedMotion ?? false;
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Enterprise</Trans>}
|
||||
title={<Trans>Unlock Email Domains</Trans>}
|
||||
description={
|
||||
<Trans>Send documents from your own domain. Email domains are available on the Enterprise plan.</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="journey">Send emails to recipients from your domain</Trans>,
|
||||
<Trans key="dns">Easy DNS setup with auto-generated DKIM and SPF records</Trans>,
|
||||
<Trans key="senders">Named senders with defaults per team, template or document</Trans>,
|
||||
]}
|
||||
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
ctaExternal
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="relative h-8">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={isBranded ? 'chip-on' : 'chip-off'}
|
||||
initial={isStatic ? false : { opacity: 0, y: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={SPRING}
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center gap-2 rounded-full border bg-background px-3 font-mono text-xs',
|
||||
isBranded ? 'border-documenso-300 text-documenso-800' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isBranded ? (
|
||||
<BadgeCheckIcon className="h-3.5 w-3.5 shrink-0 text-documenso-700" />
|
||||
) : (
|
||||
<MailIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<span className="truncate">
|
||||
{isBranded ? <Trans>Sending from your domain</Trans> : <Trans>Sending from app.documenso.com</Trans>}
|
||||
</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b p-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-semibold text-sm">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={`logo-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{/*
|
||||
* Remounts with its keyed parent on every cycle step so the
|
||||
* pop replays each change. Single-value spring (POP
|
||||
* overshoots past 1) instead of scale keyframes — keyframe
|
||||
* arrays are unreliable on strict-mode remounts.
|
||||
*/}
|
||||
<motion.span
|
||||
initial={isStatic ? false : { scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ scale: POP }}
|
||||
className="inline-block"
|
||||
>
|
||||
{isBranded ? (
|
||||
<Avatar className="h-8 w-8 border border-solid">
|
||||
{organisation.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(organisation.avatarImageId)} />
|
||||
)}
|
||||
<AvatarFallback className="text-sm">{brandedSender.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<BrandingLogoIcon className="h-8 w-8" />
|
||||
)}
|
||||
</motion.span>
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`name-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.28, ease: EASE }}
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate">{isBranded ? brandedSender.name : 'Documenso'}</span>
|
||||
|
||||
{/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */}
|
||||
{isBranded && (
|
||||
<motion.span
|
||||
initial={isStatic ? false : { scale: 0, rotate: -40 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ ...POP, delay: 0.12 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<BadgeCheckIcon className="h-3.5 w-3.5 text-documenso-700" />
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-muted-foreground text-xs">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.p
|
||||
key={`addr-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.28, ease: EASE }}
|
||||
className="truncate"
|
||||
>
|
||||
{isBranded ? brandedSender.email : 'noreply@app.documenso.com'}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Please sign: Example.pdf</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Keyed remount replays the sweep on every cycle step. No opacity
|
||||
* envelope — keyframe arrays are unreliable on strict-mode
|
||||
* remounts; both endpoints sit outside the overflow-hidden card,
|
||||
* so the clip provides the fade in/out instead.
|
||||
*/}
|
||||
<motion.div
|
||||
key={`sheen-${cycleIndex}`}
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '240%' }}
|
||||
transition={{ duration: 1.2, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.32), transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Shared motion vocabulary for the settings upsell previews. Values ported
|
||||
* from the design prototype (`design/SSO Upsell.dc.html`).
|
||||
*/
|
||||
export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const;
|
||||
|
||||
export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const;
|
||||
|
||||
export const EASE = [0.22, 0.61, 0.36, 1] as const;
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export type SettingsUpsellCardProps = {
|
||||
planLabel: ReactNode;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
features: ReactNode[];
|
||||
preview: ReactNode;
|
||||
|
||||
/**
|
||||
* CTA label. Defaults to "Upgrade Plan".
|
||||
*/
|
||||
ctaLabel?: ReactNode;
|
||||
|
||||
/**
|
||||
* CTA destination. Defaults to the organisation billing settings page.
|
||||
*/
|
||||
ctaTo?: string;
|
||||
|
||||
/**
|
||||
* Render the CTA as an external link (new tab) instead of an internal route.
|
||||
*/
|
||||
ctaExternal?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared split-card layout for claim-gated settings upsells on Documenso
|
||||
* Cloud. The left pane pitches the feature (plan badge, title, description,
|
||||
* feature list, upgrade CTA); the right pane renders a decorative scenario
|
||||
* preview supplied by the caller.
|
||||
*
|
||||
* Callers decide *when* to render this (cloud + missing claim flag).
|
||||
*/
|
||||
export const SettingsUpsellCard = ({
|
||||
planLabel,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
preview,
|
||||
ctaLabel,
|
||||
ctaTo,
|
||||
ctaExternal = false,
|
||||
}: SettingsUpsellCardProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
|
||||
|
||||
const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`;
|
||||
|
||||
const ctaContent = (
|
||||
<>
|
||||
{ctaLabel ?? <Trans>Upgrade Plan</Trans>}
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-8 overflow-hidden rounded-xl border-2 ring-4 ring-muted/70 md:grid md:grid-cols-[1.08fr_0.92fr] xl:-mx-8">
|
||||
{/*
|
||||
* `min-w-0` on both grid items: `fr` tracks have an `auto` content
|
||||
* minimum, so long preview content (e.g. a wide mono domain line) would
|
||||
* otherwise widen the right track beyond its 0.92fr share — and
|
||||
* re-balance the whole grid on every preview cycle (layout shift).
|
||||
*/}
|
||||
<div className="flex min-w-0 flex-col items-start p-6 md:p-8">
|
||||
<Badge size="small">
|
||||
<LockIcon className="mr-1 h-3 w-3" />
|
||||
<span className="uppercase">{planLabel}</span>
|
||||
</Badge>
|
||||
|
||||
<h3 className="mt-4 font-semibold text-xl">{title}</h3>
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-muted-foreground text-sm">{description}</p>
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-documenso-200">
|
||||
<CheckIcon className="h-3 w-3 text-documenso-800" strokeWidth={2.5} />
|
||||
</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{canManageBilling ? (
|
||||
<Button className="mt-8" asChild>
|
||||
{ctaExternal ? (
|
||||
<a href={ctaHref} target="_blank" rel="noreferrer">
|
||||
{ctaContent}
|
||||
</a>
|
||||
) : (
|
||||
<Link to={ctaHref}>{ctaContent}</Link>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-8 text-muted-foreground text-xs">
|
||||
<Trans>Contact your organisation owner to upgrade plans.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex min-w-0 flex-col justify-center gap-3 border-t bg-muted p-6 md:border-t-0 md:border-l md:p-8"
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,260 +0,0 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { FingerprintIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
export const SsoPortalUpsell = () => {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS);
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Enterprise</Trans>}
|
||||
title={<Trans>Unlock the Organisation SSO Portal</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan.
|
||||
</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="oidc">Works with any OIDC provider — Okta, Entra ID, Google and more</Trans>,
|
||||
<Trans key="jit">Accounts are automatically added to your organisation on sign-in</Trans>,
|
||||
<Trans key="control">Restrict sign-ins by email domain and choose the default role</Trans>,
|
||||
]}
|
||||
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
ctaExternal
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="relative h-[236px]">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{sceneIndex === 0 && <PortalScene key="portal" isStatic={isReducedMotion ?? false} />}
|
||||
{sceneIndex === 1 && <RedirectScene key="redirect" />}
|
||||
{sceneIndex === 2 && <SuccessScene key="success" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Absolute-positioned panel each scene renders in, handling the shared
|
||||
* slide-and-fade transition between scenes.
|
||||
*/
|
||||
const ScenePanel = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||
transition={{ duration: 0.34, ease: EASE }}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-6 text-center shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 1: the organisation's SSO portal, with a timed faux press on the
|
||||
* "Continue with SSO" button (cursor flies in, button dips, sheen sweeps).
|
||||
*
|
||||
* When `isStatic` is set (reduced motion) every element renders with
|
||||
* `initial={false}`, skipping entrance and press animations.
|
||||
*/
|
||||
const PortalScene = ({ isStatic }: { isStatic: boolean }) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const rise = (delay: number) => ({
|
||||
initial: isStatic ? false : { y: 10, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
transition: { ...SPRING, delay },
|
||||
});
|
||||
|
||||
return (
|
||||
<ScenePanel>
|
||||
<motion.div
|
||||
initial={isStatic ? false : { scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ ...POP, delay: 0.04 }}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-documenso-200 font-semibold text-documenso-900">
|
||||
{([...organisation.name][0] ?? 'D').toUpperCase()}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p {...rise(0.12)} className="mt-3.5 font-semibold text-sm">
|
||||
<Trans>Welcome to {organisation.name}</Trans>
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...rise(0.18)} className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>Single sign-on</Trans>
|
||||
</motion.p>
|
||||
|
||||
<div className="relative mt-4 w-full">
|
||||
<motion.div
|
||||
initial={isStatic ? false : { y: 10, opacity: 0, scale: 1 }}
|
||||
animate={{ y: 0, opacity: 1, scale: [1, 1, 0.955, 1] }}
|
||||
transition={{
|
||||
y: { ...SPRING, delay: 0.24 },
|
||||
opacity: { duration: 0.3, delay: 0.24 },
|
||||
scale: { duration: 2.6, times: [0, 0.63, 0.72, 0.84], ease: 'easeOut' },
|
||||
}}
|
||||
className="relative flex h-[38px] w-full items-center justify-center overflow-hidden rounded-md bg-foreground font-semibold text-background text-sm"
|
||||
>
|
||||
<span>
|
||||
<Trans>Continue with SSO</Trans>
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '150%' }}
|
||||
transition={{ duration: 0.85, delay: 1.75, ease: 'easeOut' }}
|
||||
className="absolute top-0 bottom-0 left-[20%] w-3/5"
|
||||
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.45), transparent)' }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.svg
|
||||
initial={isStatic ? false : { x: 30, y: 30, opacity: 0, scale: 1 }}
|
||||
animate={{
|
||||
x: [30, 30, 0, 0, 0],
|
||||
y: [30, 30, 0, 0, 0],
|
||||
opacity: [0, 1, 1, 1, 0],
|
||||
scale: [1, 1, 1, 0.82, 1],
|
||||
}}
|
||||
transition={{ duration: 2.6, times: [0, 0.3, 0.63, 0.72, 0.94], ease: EASE }}
|
||||
width={17}
|
||||
height={17}
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.4}
|
||||
strokeLinejoin="round"
|
||||
className="absolute right-[26px] -bottom-2.5 fill-foreground stroke-background"
|
||||
>
|
||||
<path d="M4 2.5 19 12l-6.6 1.4L9.7 19.6z" />
|
||||
</motion.svg>
|
||||
</div>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 2: redirecting to the identity provider, with a rotating ring around
|
||||
* a fingerprint tile and a filling progress bar.
|
||||
*/
|
||||
const RedirectScene = () => {
|
||||
return (
|
||||
<ScenePanel>
|
||||
<div className="relative flex h-[46px] w-[46px] items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 0.95, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
|
||||
className="absolute inset-0 rounded-full border-2"
|
||||
style={{ borderTopColor: '#A2E771' }}
|
||||
/>
|
||||
|
||||
<div className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-muted">
|
||||
<FingerprintIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.6} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 8, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.08 }}
|
||||
className="mt-3.5 max-w-[22ch] text-sm"
|
||||
>
|
||||
<Trans>Redirecting to your identity provider</Trans>
|
||||
</motion.p>
|
||||
|
||||
<div className="mt-4 h-1 w-[140px] overflow-hidden rounded-full bg-border">
|
||||
<motion.div
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: '100%' }}
|
||||
transition={{ duration: 1.35, ease: 'easeInOut' }}
|
||||
className="h-full rounded-full bg-documenso"
|
||||
/>
|
||||
</div>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 3: signed in, with an expanding pulse ring, a popping green circle,
|
||||
* a drawn checkmark and the signed-in member's email.
|
||||
*/
|
||||
const SuccessScene = () => {
|
||||
const { user } = useSession();
|
||||
|
||||
return (
|
||||
<ScenePanel>
|
||||
<div className="relative h-10 w-10">
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0.85 }}
|
||||
animate={{ scale: 2.1, opacity: 0 }}
|
||||
transition={{ duration: 1.1, ease: 'easeOut' }}
|
||||
className="absolute inset-0 rounded-full border-2"
|
||||
style={{ borderColor: '#A2E771' }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.4, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={POP}
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-documenso-200"
|
||||
>
|
||||
<svg
|
||||
width={19}
|
||||
height={19}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-documenso-900"
|
||||
>
|
||||
<motion.path
|
||||
d="M20 6 9 17l-5-5"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.12, ease: 'easeOut' }}
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.16 }}
|
||||
className="mt-3.5 font-semibold text-sm"
|
||||
>
|
||||
<Trans>Signed in</Trans>
|
||||
</motion.p>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.24 }}
|
||||
className="mt-1 font-mono text-muted-foreground text-xs"
|
||||
>
|
||||
{user.email}
|
||||
</motion.p>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Milliseconds each scene is shown before advancing: portal, redirect,
|
||||
* success.
|
||||
*/
|
||||
const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000];
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useReducedMotion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Cycles an index through `durations.length` steps, waiting `durations[i]`
|
||||
* milliseconds on step `i` before advancing to the next.
|
||||
*
|
||||
* When the cycle wraps past the last step it continues from `loopStartIndex`
|
||||
* (default `0`), letting consumers play intro-only steps exactly once and
|
||||
* then loop through the remaining steps forever.
|
||||
*
|
||||
* Under `prefers-reduced-motion` the cycle never starts and the index stays
|
||||
* at 0, so consumers render their initial state statically.
|
||||
*
|
||||
* Pass module-level constants for `durations` and `loopStartIndex` — their
|
||||
* identities are intentionally not dependencies.
|
||||
*/
|
||||
export const useTimedCycle = (durations: number[], loopStartIndex = 0) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion || durations.length === 0) {
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let current = 0;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = () => {
|
||||
const next = current + 1;
|
||||
|
||||
current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next;
|
||||
|
||||
setIndex(current);
|
||||
timeout = setTimeout(tick, durations[current]);
|
||||
};
|
||||
|
||||
timeout = setTimeout(tick, durations[0]);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isReducedMotion]);
|
||||
|
||||
return index;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
|
||||
|
||||
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
|
||||
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
|
||||
|
||||
export type TeamEmailDropdownProps = {
|
||||
team: Awaited<ReturnType<typeof getTeamWithEmail>>;
|
||||
};
|
||||
|
||||
export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
|
||||
trpc.team.email.verification.resend.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Email verification has been resent`),
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Unable to resend verification at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
{!team.teamEmail && team.emailVerification && (
|
||||
<DropdownMenuItem
|
||||
disabled={isResendingEmailVerification}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void resendEmailVerification({ teamId: team.id });
|
||||
}}
|
||||
>
|
||||
{isResendingEmailVerification ? (
|
||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Resend verification</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{team.teamEmail && (
|
||||
<TeamEmailUpdateDialog
|
||||
teamEmail={team.teamEmail}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TeamEmailDeleteDialog
|
||||
team={team}
|
||||
teamName={team.name}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,211 +0,0 @@
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { getSettingsNavGroups, type SettingsNavGroup, type SettingsNavItem } from '@documenso/lib/utils/settings-nav';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Link, Outlet, useLocation } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { SettingsScopeBreadcrumb } from './settings-scope-breadcrumb';
|
||||
import { UnifiedSettingsSidebar } from './unified-settings-sidebar';
|
||||
import { UnifiedSettingsSidebarMobile } from './unified-settings-sidebar-mobile';
|
||||
|
||||
export type UnifiedSettingsScope = 'organisation' | 'team' | 'account';
|
||||
|
||||
export type UnifiedSettingsLayoutProps = {
|
||||
activeScope: UnifiedSettingsScope;
|
||||
|
||||
/**
|
||||
* The team the user last worked in, read from the `preferred-team-url` cookie by the
|
||||
* layout's loader. Used to keep the sidebar's team switcher stable at organisation and
|
||||
* account scope, where the URL carries no team.
|
||||
*/
|
||||
preferredTeamUrl?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk a group's items and find the item (and its parent if it's a sub-nav child)
|
||||
* that matches the current pathname most specifically. Returns the labels to use
|
||||
* as breadcrumb crumbs (e.g. ['Preferences', 'Document'] or just ['Members']).
|
||||
*/
|
||||
const findActiveCrumbs = (group: SettingsNavGroup | null, pathname: string): MessageDescriptor[] => {
|
||||
if (!group) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let bestMatch: SettingsNavItem | null = null;
|
||||
|
||||
for (const item of group.items) {
|
||||
if (item.isSubNavParent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pathname === item.path || pathname.startsWith(`${item.path}/`)) {
|
||||
if (!bestMatch || item.path.length > bestMatch.path.length) {
|
||||
bestMatch = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (bestMatch.isSubNav) {
|
||||
const parent = group.items.find((it) => it.isSubNavParent);
|
||||
return parent ? [parent.label, bestMatch.label] : [bestMatch.label];
|
||||
}
|
||||
|
||||
return [bestMatch.label];
|
||||
};
|
||||
|
||||
export const UnifiedSettingsLayout = ({ activeScope, preferredTeamUrl = null }: UnifiedSettingsLayoutProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const contentPaneRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Scroll back to the top when navigating between settings pages.
|
||||
useEffect(() => {
|
||||
contentPaneRef.current?.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
// An organisation is worth showing in the sidebar if it has settings the user can reach —
|
||||
// either the organisation's own, or those of a team inside it.
|
||||
const hasReachableSettings = (org: (typeof organisations)[number]) =>
|
||||
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
|
||||
org.teams.some((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
|
||||
|
||||
const organisation =
|
||||
currentOrganisation ??
|
||||
organisations.find((org) => org.teams.some((t) => t.url === preferredTeamUrl) && hasReachableSettings(org)) ??
|
||||
organisations.find(hasReachableSettings) ??
|
||||
null;
|
||||
|
||||
const manageableTeams = organisation?.teams.filter((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
|
||||
|
||||
const teamForSidebar =
|
||||
team ?? manageableTeams?.find((t) => t.url === preferredTeamUrl) ?? manageableTeams?.[0] ?? null;
|
||||
|
||||
const sidebarTeamUrl = teamForSidebar?.url ?? null;
|
||||
|
||||
// Sync the selected team URL in the sidebar into the preferred team URL cookie.
|
||||
useEffect(() => {
|
||||
if (!sidebarTeamUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
|
||||
body.append('teamUrl', sidebarTeamUrl);
|
||||
|
||||
void fetch('/api/preferred-team', { method: 'POST', body });
|
||||
}, [sidebarTeamUrl]);
|
||||
|
||||
const groups = getSettingsNavGroups({
|
||||
organisation: organisation
|
||||
? {
|
||||
url: organisation.url,
|
||||
currentOrganisationRole: organisation.currentOrganisationRole,
|
||||
organisationClaim: organisation.organisationClaim,
|
||||
}
|
||||
: null,
|
||||
team: teamForSidebar ? { url: teamForSidebar.url, currentTeamRole: teamForSidebar.currentTeamRole } : null,
|
||||
hasManageableBillingOrgs: organisations.some((org) =>
|
||||
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||
),
|
||||
});
|
||||
|
||||
const canManageOrg =
|
||||
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
|
||||
|
||||
// Must be derived from the team in the URL context, NOT from `teamForSidebar` — the
|
||||
// latter falls back to any manageable team in the org, which would let a team manager
|
||||
// through the organisation-scope guard (and `useOptionalCurrentTeam()` resolves for any
|
||||
// member regardless of role, which would let a plain member through the team guard).
|
||||
const canManageCurrentTeam = team !== null && canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole);
|
||||
|
||||
// Account pages are available to every user. The organisation and team scopes each
|
||||
// require the manage permission for THAT scope — they are not interchangeable.
|
||||
const isAuthorised = match(activeScope)
|
||||
.with('account', () => true)
|
||||
.with('organisation', () => canManageOrg)
|
||||
.with('team', () => canManageCurrentTeam)
|
||||
.exhaustive();
|
||||
|
||||
if (!isAuthorised) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={401}
|
||||
errorCodeMap={{
|
||||
401: {
|
||||
heading: msg`Unauthorized`,
|
||||
subHeading: msg`401 Unauthorized`,
|
||||
message: msg`You are not authorized to access this page.`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to="/settings/profile">
|
||||
<Trans>Go to your settings</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const scopeName = match(activeScope)
|
||||
.with('account', () => _(msg`Account`))
|
||||
.with('organisation', () => organisation?.name ?? '')
|
||||
.with('team', () => team?.name ?? organisation?.name ?? '')
|
||||
.exhaustive();
|
||||
|
||||
const activeGroup =
|
||||
activeScope === 'account' ? groups.account : activeScope === 'organisation' ? groups.organisation : groups.team;
|
||||
|
||||
const crumbs = findActiveCrumbs(activeGroup, pathname).map((label) => _(label));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:min-h-0 md:flex-1 md:flex-row">
|
||||
<aside className="hover-scrollbar w-full shrink-0 border-b bg-background md:w-80 md:overflow-y-auto md:border-r md:border-b-0">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="hidden md:block">
|
||||
<UnifiedSettingsSidebar
|
||||
groups={groups}
|
||||
currentOrgUrl={organisation?.url ?? null}
|
||||
currentTeamUrl={teamForSidebar?.url ?? null}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<UnifiedSettingsSidebarMobile
|
||||
groups={groups}
|
||||
activeScope={activeScope}
|
||||
currentOrgUrl={organisation?.url ?? null}
|
||||
currentTeamUrl={teamForSidebar?.url ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main ref={contentPaneRef} className="relative flex-1 px-4 md:overflow-y-auto md:px-12 lg:px-16">
|
||||
<div className="mx-auto w-full max-w-3xl py-6 md:py-8" data-testid="unified-settings-content">
|
||||
<SettingsScopeBreadcrumb scope={activeScope} scopeName={scopeName} crumbs={crumbs} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,165 +0,0 @@
|
||||
import type { SettingsNavGroups, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import { SettingsOrgSwitcher } from './settings-org-switcher';
|
||||
import { SettingsTeamSwitcher } from './settings-team-switcher';
|
||||
|
||||
type MobileScope = SettingsNavScope | 'account';
|
||||
|
||||
export type UnifiedSettingsSidebarMobileProps = {
|
||||
groups: SettingsNavGroups;
|
||||
activeScope: MobileScope;
|
||||
currentOrgUrl: string | null;
|
||||
currentTeamUrl: string | null;
|
||||
};
|
||||
|
||||
export const UnifiedSettingsSidebarMobile = ({
|
||||
groups,
|
||||
activeScope,
|
||||
currentOrgUrl,
|
||||
currentTeamUrl,
|
||||
}: UnifiedSettingsSidebarMobileProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const visibleScopes = useMemo<MobileScope[]>(() => {
|
||||
const scopes: MobileScope[] = [];
|
||||
if (groups.organisation) {
|
||||
scopes.push('organisation');
|
||||
}
|
||||
if (groups.team) {
|
||||
scopes.push('team');
|
||||
}
|
||||
scopes.push('account');
|
||||
return scopes;
|
||||
}, [groups]);
|
||||
|
||||
// Falls back to the Account group rather than rendering nothing — an empty scope group
|
||||
// would leave the mobile viewport with no settings navigation at all.
|
||||
const activeGroup =
|
||||
(activeScope === 'organisation' ? groups.organisation : activeScope === 'team' ? groups.team : null) ??
|
||||
groups.account;
|
||||
|
||||
const selectableItems = useMemo(() => activeGroup.items.filter((item) => !item.isSubNavParent), [activeGroup.items]);
|
||||
|
||||
// The select matches on exact value, but plenty of settings pages are sub-routes of a
|
||||
// nav item (`/settings/security/passkeys`, `/t/x/settings/webhooks/:id`, …). Resolving
|
||||
// to the longest matching item path keeps the trigger populated on those pages instead
|
||||
// of rendering an empty box — mirrors the desktop sidebar's prefix highlighting.
|
||||
const selectedPath = useMemo(() => {
|
||||
let bestMatch: string | undefined;
|
||||
|
||||
for (const item of selectableItems) {
|
||||
if (pathname !== item.path && !pathname.startsWith(`${item.path}/`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bestMatch || item.path.length > bestMatch.length) {
|
||||
bestMatch = item.path;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}, [selectableItems, pathname]);
|
||||
|
||||
const handleScopeChange = (scope: MobileScope) => {
|
||||
if (scope === activeScope) {
|
||||
return;
|
||||
}
|
||||
if (scope === 'organisation' && groups.organisation) {
|
||||
void navigate(groups.organisation.items[0].path);
|
||||
} else if (scope === 'team' && groups.team) {
|
||||
void navigate(groups.team.items[0].path);
|
||||
} else if (scope === 'account') {
|
||||
void navigate(groups.account.items[0].path);
|
||||
}
|
||||
};
|
||||
|
||||
// The tab row stays full-bleed (its border-b acts as a full-width divider); the
|
||||
// sections under it get `px-4` to line up with the content pane's own `px-4`
|
||||
// inset, and `pb-4` keeps the last control off the aside's bottom border.
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pb-4" data-testid="unified-settings-sidebar-mobile">
|
||||
{visibleScopes.length > 1 ? (
|
||||
<div className="flex border-border border-b" role="tablist">
|
||||
{visibleScopes.map((scope) => {
|
||||
const isActive = scope === activeScope;
|
||||
const accent =
|
||||
scope === 'organisation'
|
||||
? 'border-emerald-500 text-emerald-700 dark:text-emerald-300'
|
||||
: scope === 'team'
|
||||
? 'border-blue-500 text-blue-700 dark:text-blue-300'
|
||||
: 'border-foreground text-foreground';
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => handleScopeChange(scope)}
|
||||
className={cn(
|
||||
'flex-1 border-b-2 py-2 text-center font-bold text-xs uppercase tracking-wide',
|
||||
isActive ? accent : 'border-transparent text-muted-foreground',
|
||||
)}
|
||||
data-testid={`unified-settings-mobile-tab-${scope}`}
|
||||
>
|
||||
{scope === 'organisation' && <Trans>Organisation</Trans>}
|
||||
{scope === 'team' && <Trans>Team</Trans>}
|
||||
{scope === 'account' && <Trans>Account</Trans>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-2 text-center font-bold text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{activeScope === 'organisation' && <Trans>Organisation</Trans>}
|
||||
{activeScope === 'team' && <Trans>Team</Trans>}
|
||||
{activeScope === 'account' && <Trans>Account</Trans>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The organisation switcher is always present in a scoped view — at team scope it's
|
||||
the only way for a user who can't manage the organisation to move between them,
|
||||
since the Organisation tab is absent when they have no organisation pages. */}
|
||||
{activeScope !== 'account' && currentOrgUrl && (
|
||||
<div className="flex flex-col gap-2 px-4">
|
||||
<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />
|
||||
|
||||
{activeScope === 'team' && (
|
||||
<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4">
|
||||
<div className="mb-1 font-bold text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
<Trans>Jump to</Trans>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedPath}
|
||||
onValueChange={(value) => {
|
||||
void navigate(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger data-testid="unified-settings-mobile-section-trigger">
|
||||
<SelectValue placeholder={_(msg`Select a section`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableItems.map((item) => (
|
||||
<SelectItem key={item.path} value={item.path}>
|
||||
{item.isSubNav ? `${_(msg`Preferences`)} › ${_(item.label)}` : _(item.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,275 +0,0 @@
|
||||
import type { SettingsNavGroups, SettingsNavItem, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@documenso/ui/primitives/collapsible';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router';
|
||||
|
||||
import { SettingsOrgSwitcher } from './settings-org-switcher';
|
||||
import { SettingsTeamSwitcher } from './settings-team-switcher';
|
||||
|
||||
export type UnifiedSettingsSidebarProps = {
|
||||
groups: SettingsNavGroups;
|
||||
currentOrgUrl: string | null;
|
||||
currentTeamUrl: string | null;
|
||||
};
|
||||
|
||||
export const UnifiedSettingsSidebar = ({ groups, currentOrgUrl, currentTeamUrl }: UnifiedSettingsSidebarProps) => {
|
||||
return (
|
||||
<aside className="flex w-full flex-col" data-testid="unified-settings-sidebar">
|
||||
{currentOrgUrl && (
|
||||
<div className="p-4">
|
||||
<SidebarGroup
|
||||
heading={<Trans>Organisation Settings</Trans>}
|
||||
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
|
||||
switcher={<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />}
|
||||
items={groups.organisation?.items ?? []}
|
||||
scope="organisation"
|
||||
emptyState={
|
||||
<p
|
||||
className="rounded-md border border-dashed px-3 py-2 text-muted-foreground text-xs"
|
||||
data-testid="unified-settings-organisation-empty-state"
|
||||
>
|
||||
<Trans>
|
||||
You don't have permission to manage this organisation. Switch to another one above, or continue in
|
||||
your team settings below.
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.team && currentOrgUrl && (
|
||||
<div className="border-t p-4">
|
||||
<SidebarGroup
|
||||
heading={<Trans>Team Settings</Trans>}
|
||||
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
|
||||
switcher={<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />}
|
||||
items={groups.team.items}
|
||||
scope={groups.team.scope}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('p-4', currentOrgUrl && 'border-t')}>
|
||||
<SidebarGroup
|
||||
heading={<Trans>Account Settings</Trans>}
|
||||
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
|
||||
items={groups.account.items}
|
||||
scope={groups.account.scope}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarGroupProps = {
|
||||
className?: string;
|
||||
headingClassName?: string;
|
||||
heading: ReactNode;
|
||||
activeBgClassName: string;
|
||||
switcher?: ReactNode;
|
||||
items: SettingsNavItem[];
|
||||
scope: SettingsNavScope;
|
||||
emptyState?: ReactNode;
|
||||
};
|
||||
|
||||
type GroupedNavEntry =
|
||||
| { kind: 'flat'; item: SettingsNavItem }
|
||||
| { kind: 'collapsible'; parent: SettingsNavItem; children: SettingsNavItem[] };
|
||||
|
||||
/**
|
||||
* Walk a flat item list and group consecutive `isSubNav` items under their preceding
|
||||
* `isSubNavParent`. Anything else renders as a flat entry.
|
||||
*/
|
||||
const groupNavEntries = (items: SettingsNavItem[]): GroupedNavEntry[] => {
|
||||
const entries: GroupedNavEntry[] = [];
|
||||
let currentCollapsible: { parent: SettingsNavItem; children: SettingsNavItem[] } | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isSubNavParent) {
|
||||
currentCollapsible = { parent: item, children: [] };
|
||||
entries.push({ kind: 'collapsible', ...currentCollapsible });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.isSubNav && currentCollapsible) {
|
||||
currentCollapsible.children.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
currentCollapsible = null;
|
||||
entries.push({ kind: 'flat', item });
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
const SidebarGroup = ({
|
||||
className,
|
||||
headingClassName,
|
||||
heading,
|
||||
activeBgClassName,
|
||||
switcher,
|
||||
items,
|
||||
scope,
|
||||
emptyState,
|
||||
}: SidebarGroupProps) => {
|
||||
const grouped = groupNavEntries(items);
|
||||
|
||||
return (
|
||||
<div className={className} data-testid="unified-settings-sidebar-group">
|
||||
<div
|
||||
className={cn('mb-2 font-semibold text-muted-foreground text-xs uppercase tracking-widest', headingClassName)}
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
{switcher && <div className="mb-2">{switcher}</div>}
|
||||
|
||||
{items.length === 0 && emptyState}
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{grouped.map((entry) => {
|
||||
if (entry.kind === 'flat') {
|
||||
return (
|
||||
<FlatNavItem
|
||||
key={`${entry.item.key}-${entry.item.path}`}
|
||||
item={entry.item}
|
||||
activeBgClassName={activeBgClassName}
|
||||
scope={scope}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsibleNavSection
|
||||
key={`${entry.parent.key}-${entry.parent.path}`}
|
||||
parent={entry.parent}
|
||||
childItems={entry.children}
|
||||
activeBgClassName={activeBgClassName}
|
||||
scope={scope}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type FlatNavItemProps = {
|
||||
item: SettingsNavItem;
|
||||
activeBgClassName: string;
|
||||
scope: SettingsNavScope;
|
||||
};
|
||||
|
||||
const FlatNavItem = ({ item, activeBgClassName, scope }: FlatNavItemProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// The General items link to the settings root, which prefixes every other
|
||||
// item's path — those require an exact match. Everything else highlights on
|
||||
// its sub-routes too (e.g. Security on /settings/security/passkeys).
|
||||
const isActive =
|
||||
item.key === 'general' ? pathname === item.path : pathname === item.path || pathname.startsWith(`${item.path}/`);
|
||||
|
||||
return (
|
||||
<NavLink to={item.path} className="group block" data-testid={`unified-settings-nav-${scope}-${item.key}`} end>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 w-full justify-start font-normal text-muted-foreground',
|
||||
isActive && cn(activeBgClassName, 'font-medium'),
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="mr-2 h-4 w-4" />}
|
||||
{_(item.label)}
|
||||
</Button>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
type CollapsibleNavSectionProps = {
|
||||
parent: SettingsNavItem;
|
||||
childItems: SettingsNavItem[];
|
||||
activeBgClassName: string;
|
||||
scope: SettingsNavScope;
|
||||
};
|
||||
|
||||
const CollapsibleNavSection = ({ parent, childItems, activeBgClassName, scope }: CollapsibleNavSectionProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const hasActiveChild = childItems.some((child) => pathname === child.path || pathname.startsWith(`${child.path}/`));
|
||||
|
||||
const [isOpen, setIsOpen] = useState(hasActiveChild);
|
||||
|
||||
// Auto-open when navigating to a sub-route. Closing while on a sub-route is
|
||||
// respected (state stays closed) until the user navigates away and back.
|
||||
useEffect(() => {
|
||||
if (hasActiveChild) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [hasActiveChild]);
|
||||
|
||||
const ParentIcon = parent.icon ? parent.icon : null;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 w-full justify-start font-normal text-muted-foreground',
|
||||
// Emphasise the parent when one of its children is the active page.
|
||||
hasActiveChild && 'font-medium text-foreground',
|
||||
)}
|
||||
data-testid={`unified-settings-nav-${scope}-${parent.key}`}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{ParentIcon && <ParentIcon className="mr-2 h-4 w-4" />}
|
||||
{_(parent.label)}
|
||||
<ChevronRightIcon
|
||||
className={cn('ml-auto h-4 w-4 transition-transform duration-200', isOpen && 'rotate-90')}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* data-[state=closed]:hidden — the `flex` display class would otherwise
|
||||
defeat the `hidden` attribute Radix sets when closed, leaving the
|
||||
empty content box (and its mt-1) inflating the gap below the trigger. */}
|
||||
<CollapsibleContent className="mt-1 flex flex-col gap-1 data-[state=closed]:hidden">
|
||||
{childItems.map((child) => {
|
||||
const isActive = pathname === child.path || pathname.startsWith(`${child.path}/`);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={`${child.key}-${child.path}`}
|
||||
to={child.path}
|
||||
className="group block pl-6"
|
||||
data-testid={`unified-settings-nav-${scope}-${child.key}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 w-full justify-start font-normal text-muted-foreground',
|
||||
isActive && cn(activeBgClassName, 'font-medium'),
|
||||
)}
|
||||
>
|
||||
{_(child.label)}
|
||||
</Button>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: '7d', label: <Trans>Last 7 days</Trans> },
|
||||
{ value: '14d', label: <Trans>Last 14 days</Trans> },
|
||||
{ value: '30d', label: <Trans>Last 30 days</Trans> },
|
||||
];
|
||||
|
||||
export const DocumentsTablePeriodFilter = () => {
|
||||
const [{ period }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
period: documentsSearchParams.period,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const onChange = (newPeriod: string | null) => {
|
||||
void setSearchParams({
|
||||
period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
icon={CalendarIcon}
|
||||
label={<Trans>Period</Trans>}
|
||||
value={period}
|
||||
onChange={onChange}
|
||||
options={PERIOD_OPTIONS}
|
||||
testId="documents-table-period-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +1,63 @@
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
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 { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
type DocumentsTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [{ senderIds }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
senderIds: documentsSearchParams.senderIds,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString());
|
||||
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const options = (data ?? []).map((member) => ({
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: string[]) => {
|
||||
void setSearchParams({
|
||||
senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null,
|
||||
page: null,
|
||||
});
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
multiple
|
||||
icon={UserIcon}
|
||||
label={<Trans>Sender</Trans>}
|
||||
value={selectedSenderIds}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
enableSearch
|
||||
searchPlaceholder={_(msg`Search members...`)}
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="font-normal text-muted-foreground">
|
||||
<Trans>
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder={msg`Search`}
|
||||
loading={!isMounted || isLoading}
|
||||
testId="documents-table-sender-filter"
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
import { ListFilterIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
type DocumentsTableStatusFilterProps = {
|
||||
stats: TFindDocumentsInternalResponse['stats'];
|
||||
};
|
||||
|
||||
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [{ status }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
status: documentsSearchParams.status,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectableStatuses = useMemo(
|
||||
() =>
|
||||
SELECTABLE_STATUSES.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[organisation.type],
|
||||
);
|
||||
|
||||
const selectedStatus = useMemo(
|
||||
() => selectableStatuses.find((value) => value === status) ?? null,
|
||||
[selectableStatuses, status],
|
||||
);
|
||||
|
||||
const onChange = (newStatus: string | null) => {
|
||||
void setSearchParams({
|
||||
status: selectableStatuses.find((value) => value === newStatus) ?? null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterPill
|
||||
icon={ListFilterIcon}
|
||||
label={<Trans>Status</Trans>}
|
||||
value={selectedStatus}
|
||||
onChange={onChange}
|
||||
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
|
||||
options={selectableStatuses.map((value) => ({
|
||||
value,
|
||||
label: <DocumentStatus status={value} />,
|
||||
trailing: formatStatsCount(stats[value]),
|
||||
}))}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
ExtendedDocumentStatus.EXPIRED,
|
||||
];
|
||||
|
||||
const formatStatsCount = (count: number) => {
|
||||
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||
|
||||
export type EnvelopesTableBulkActionBarProps = {
|
||||
selectedCount: number;
|
||||
onDownloadClick?: () => void;
|
||||
onMoveClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onCancelClick?: () => void;
|
||||
@@ -14,7 +12,6 @@ export type EnvelopesTableBulkActionBarProps = {
|
||||
|
||||
export const EnvelopesTableBulkActionBar = ({
|
||||
selectedCount,
|
||||
onDownloadClick,
|
||||
onMoveClick,
|
||||
onDeleteClick,
|
||||
onCancelClick,
|
||||
@@ -22,106 +19,37 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
}: EnvelopesTableBulkActionBarProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
// Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault
|
||||
// when handling Escape, so this only clears the selection when nothing
|
||||
// else consumed the key press.
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) {
|
||||
onClearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [selectedCount, onClearSelection]);
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
|
||||
<div className="flex items-center gap-x-2 px-2">
|
||||
<span className="sr-only" aria-live="polite">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
|
||||
>
|
||||
{selectedCount}
|
||||
</span>
|
||||
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
|
||||
<Trans>selected</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
|
||||
<span className="font-medium text-sm">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</span>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<FolderInputIcon className="size-4 shrink-0" />
|
||||
<Trans>Move</Trans>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
|
||||
<FolderInputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</Button>
|
||||
|
||||
{onDownloadClick && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDownloadClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<DownloadIcon className="size-4 shrink-0" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCancelClick && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancelClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<XCircleIcon className="size-4 shrink-0" />
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
|
||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDeleteClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0" />
|
||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearSelection}
|
||||
aria-label={t`Clear selection`}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XIcon className="size-4 shrink-0" />
|
||||
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ export const OrganisationTeamsTable = () => {
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/t/${row.original.url}/settings/general`}>
|
||||
<Link to={`/t/${row.original.url}/settings`}>
|
||||
<Trans>Manage</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -29,6 +29,8 @@ export const UserOrganisationsTable = () => {
|
||||
})),
|
||||
});
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(data);
|
||||
|
||||
const results = {
|
||||
data: data || [],
|
||||
perPage: 10,
|
||||
@@ -42,13 +44,31 @@ export const UserOrganisationsTable = () => {
|
||||
header: _(msg`Organisation`),
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => (
|
||||
<Link to={`/o/${row.original.url}`} preventScrollReset={true}>
|
||||
<Link
|
||||
to={isPersonalLayoutMode ? `/settings/organisations` : `/o/${row.original.url}`}
|
||||
preventScrollReset={true}
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold text-foreground/80">{row.original.name}</span>}
|
||||
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`}
|
||||
primaryText={
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{isPersonalLayoutMode
|
||||
? _(
|
||||
msg({
|
||||
message: `Personal`,
|
||||
context: `Personal organisation (adjective)`,
|
||||
}),
|
||||
)
|
||||
: row.original.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={
|
||||
isPersonalLayoutMode
|
||||
? _(msg`Your personal organisation`)
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
@@ -72,7 +92,7 @@ export const UserOrganisationsTable = () => {
|
||||
<div className="flex justify-end space-x-2">
|
||||
{canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/o/${row.original.url}/settings/general`}>
|
||||
<Link to={`/o/${row.original.url}/settings`}>
|
||||
<Trans>Manage</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -97,7 +117,7 @@ export const UserOrganisationsTable = () => {
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
}, [isPersonalLayoutMode]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -110,6 +130,9 @@ export const UserOrganisationsTable = () => {
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
columnVisibility={{
|
||||
actions: !isPersonalLayoutMode,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 3,
|
||||
@@ -131,12 +154,14 @@ export const UserOrganisationsTable = () => {
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-end space-x-2">
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
<Skeleton className="h-10 w-16 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
{!isPersonalLayoutMode && (
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-end space-x-2">
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
<Skeleton className="h-10 w-16 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
|
||||
+7
-10
@@ -1,6 +1,5 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||
import { getBasePath } from '@documenso/lib/constants/app';
|
||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||
import { createPublicEnv } from '@documenso/lib/utils/env';
|
||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
useMatches,
|
||||
} from 'react-router';
|
||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
import stylesheet from './app.css?url';
|
||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||
@@ -68,7 +68,6 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
lang,
|
||||
theme: getTheme(),
|
||||
disableAnimations,
|
||||
basePath: getBasePath(),
|
||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||
// routes that need it) can carry the matching nonce attribute.
|
||||
@@ -91,10 +90,10 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { theme, basePath } = useLoaderData<typeof loader>() || {};
|
||||
const { theme } = useLoaderData<typeof loader>() || {};
|
||||
|
||||
return (
|
||||
<ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
|
||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
</ThemeProvider>
|
||||
);
|
||||
@@ -112,8 +111,6 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const [theme] = useTheme();
|
||||
|
||||
const basePath = data.basePath ?? '';
|
||||
|
||||
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
||||
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
||||
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
||||
@@ -129,11 +126,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<Meta />
|
||||
<Links nonce={nonce(cspNonce)} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
@@ -47,8 +46,6 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
||||
|
||||
const { user, organisations } = useSession();
|
||||
|
||||
const { layoutMode } = useChildRouteFlags();
|
||||
|
||||
const teamUrl = params.teamUrl;
|
||||
const orgUrl = params.orgUrl;
|
||||
|
||||
@@ -111,26 +108,23 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
||||
return (
|
||||
<OrganisationProvider organisation={currentOrganisation}>
|
||||
<TeamProvider team={currentTeam || null}>
|
||||
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
|
||||
<OrganisationBillingBanner />
|
||||
<OrganisationBillingBanner />
|
||||
|
||||
<OrganisationQuotaBanner />
|
||||
<OrganisationQuotaBanner />
|
||||
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
|
||||
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||
|
||||
{!hideHeader && <Header fullWidth={layoutMode === 'settings'} />}
|
||||
{!hideHeader && <Header />}
|
||||
|
||||
<main
|
||||
className={cn({
|
||||
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader && layoutMode !== 'settings',
|
||||
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
|
||||
})}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<main
|
||||
className={cn({
|
||||
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader,
|
||||
})}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</TeamProvider>
|
||||
</OrganisationProvider>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`}>
|
||||
<SettingsHeader title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`} hideDivider>
|
||||
<ClaimCreateDialog licenseFlags={licenseFlags} />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AdminEmailTransportsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={t`Email Transports`} subtitle={t`Manage all email transports`}>
|
||||
<SettingsHeader title={t`Email Transports`} subtitle={t`Manage all email transports`} hideDivider>
|
||||
<EmailTransportCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -258,11 +258,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Manage organisation`}
|
||||
subtitle={t`Manage the ${organisation.name} organisation`}
|
||||
>
|
||||
<SettingsHeader title={t`Manage organisation`} subtitle={t`Manage the ${organisation.name} organisation`}>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/organisation-insights/${organisationId}`}>
|
||||
<Trans>View insights</Trans>
|
||||
@@ -273,10 +269,10 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
<GenericOrganisationAdminForm organisation={organisation} />
|
||||
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation usage`}
|
||||
subtitle={t`Current usage against organisation limits.`}
|
||||
className="mt-6"
|
||||
hideDivider
|
||||
/>
|
||||
|
||||
<OrganisationUsagePanel
|
||||
@@ -312,7 +308,6 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
</div>
|
||||
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Manage subscription`}
|
||||
subtitle={t`Manage the ${organisation.name} organisation subscription`}
|
||||
className="mt-16"
|
||||
@@ -430,7 +425,6 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
</div>
|
||||
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Danger Zone`}
|
||||
subtitle={t`Irreversible actions for this organisation`}
|
||||
className="mt-16"
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function DashboardPage() {
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
|
||||
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Link to={`/o/${org.url}/settings/general`}>
|
||||
<Link to={`/o/${org.url}/settings`}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -212,7 +212,7 @@ export default function DashboardPage() {
|
||||
|
||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Link to={`/t/${team.url}/settings/general`}>
|
||||
<Link to={`/t/${team.url}/settings`}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function OrganisationSettingsTeamsPage() {
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}/settings/general`}>
|
||||
<Link to={`/o/${organisation.url}/settings`}>
|
||||
<Trans>Manage Organisation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -184,7 +184,7 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/t/${team.url}/settings/general`}>
|
||||
<Link to={`/t/${team.url}/settings`}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Settings</Trans>
|
||||
</Link>
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function Layout() {
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
|
||||
const { layoutMode } = useChildRouteFlags();
|
||||
|
||||
// Note: We use a key to force a re-render if the team context changes.
|
||||
// This is required otherwise you would see the wrong page content.
|
||||
return (
|
||||
<div
|
||||
className={cn({
|
||||
'mx-auto w-full max-w-screen-xl px-4 md:px-8': layoutMode !== 'settings',
|
||||
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
|
||||
})}
|
||||
key={currentOrganisation.url}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8" key={currentOrganisation.url}>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export function loader({ params }: Route.LoaderArgs) {
|
||||
if (params.orgUrl) {
|
||||
throw redirect(`/o/${params.orgUrl}/settings/general`);
|
||||
}
|
||||
|
||||
throw redirect('/');
|
||||
}
|
||||
@@ -1,45 +1,173 @@
|
||||
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
|
||||
import { extractCookieFromDocument } from '@documenso/lib/client-only/cookies';
|
||||
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
Building2Icon,
|
||||
CreditCardIcon,
|
||||
GroupIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
Users2Icon,
|
||||
} from 'lucide-react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
import { Link, NavLink, Outlet } from 'react-router';
|
||||
|
||||
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings._layout';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Organisation Settings`);
|
||||
}
|
||||
|
||||
export const handle: RouteHandle = {
|
||||
layoutMode: 'settings',
|
||||
};
|
||||
export default function SettingsLayout() {
|
||||
const { t } = useLingui();
|
||||
|
||||
/**
|
||||
* Only runs on the initial document request (SSR) so the first paint has the
|
||||
* correct preferred team without a hydration mismatch.
|
||||
*/
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
|
||||
};
|
||||
}
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
/**
|
||||
* Runs instead of the server loader on client-side navigations, otherwise every
|
||||
* settings page switch would trigger a `.data` round-trip to the server just to
|
||||
* read this cookie.
|
||||
*
|
||||
* The cookie is not `HttpOnly` so it can be read straight from the document.
|
||||
*/
|
||||
export function clientLoader() {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
|
||||
};
|
||||
}
|
||||
const organisationSettingRoutes = [
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/general`,
|
||||
label: t`General`,
|
||||
icon: Building2Icon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: t`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
hideHighlight: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: t`Document`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/branding`,
|
||||
label: t`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/email`,
|
||||
label: t`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/email-domains`,
|
||||
label: t`Email Domains`,
|
||||
icon: MailboxIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/teams`,
|
||||
label: t`Teams`,
|
||||
icon: FaUsers,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/members`,
|
||||
label: t`Members`,
|
||||
icon: Users2Icon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/groups`,
|
||||
label: t`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/sso`,
|
||||
label: t`SSO`,
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/billing`,
|
||||
label: t`Billing`,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
].filter((route) => {
|
||||
if (!isBillingEnabled && route.path.includes('/billing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsLayout({ loaderData }: Route.ComponentProps) {
|
||||
return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />;
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) &&
|
||||
route.path.includes('/email-domains')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
|
||||
route.path.includes('/sso')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={401}
|
||||
errorCodeMap={{
|
||||
401: {
|
||||
heading: msg`Unauthorized`,
|
||||
subHeading: msg`401 Unauthorized`,
|
||||
message: msg`You are not authorized to access this page.`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}`}>
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation Settings</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
{/* Navigation */}
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
|
||||
)}
|
||||
>
|
||||
{organisationSettingRoutes.map((route) => (
|
||||
<NavLink
|
||||
to={route.path}
|
||||
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
|
||||
key={route.path}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', {
|
||||
'group-aria-[current]:bg-secondary': !route.hideHighlight,
|
||||
})}
|
||||
>
|
||||
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
|
||||
<Trans>{route.label}</Trans>
|
||||
</Button>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 md:col-span-9">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
type TBrandingPreferencesFormSchema,
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -26,12 +26,16 @@ export function meta() {
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsBrandingPage() {
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
|
||||
|
||||
const {
|
||||
@@ -118,22 +122,17 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
const settingsHeaderText = t`Branding Preferences`;
|
||||
|
||||
const settingsHeaderSubtitle = team
|
||||
? t`Here you can set branding preferences for your team.`
|
||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
const brandingPreferencesFormEnabled =
|
||||
organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||
? t`Here you can set your general branding preferences.`
|
||||
: team
|
||||
? t`Here you can set branding preferences for your team.`
|
||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={settingsHeaderText}
|
||||
subtitle={settingsHeaderSubtitle}
|
||||
hideDivider={!brandingPreferencesFormEnabled}
|
||||
/>
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
|
||||
{brandingPreferencesFormEnabled ? (
|
||||
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
context="Organisation"
|
||||
@@ -168,8 +167,6 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<BrandingUpsell />
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
@@ -184,7 +181,7 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
|
||||
<Trans>Update Billing</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import {
|
||||
CertificatePreferencesForm,
|
||||
type TCertificatePreferencesFormSchema,
|
||||
} from '~/components/forms/certificate-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
export default function OrganisationSettingsCertificatesPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const onCertificatePreferencesFormSubmit = async (data: TCertificatePreferencesFormSchema) => {
|
||||
try {
|
||||
const { includeSigningCertificate, includeAuditLog } = data;
|
||||
|
||||
if (includeSigningCertificate === null || includeAuditLog === null) {
|
||||
throw new Error('Should not be possible.');
|
||||
}
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Certificate preferences updated`,
|
||||
description: t`Your certificate preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your certificate preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Certificates`}
|
||||
subtitle={t`Here you can set certificate and audit log preferences for your organisation. Teams will inherit these settings by default.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<CertificatePreferencesForm
|
||||
canInherit={false}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onCertificatePreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
|
||||
import {
|
||||
DocumentPreferencesForm,
|
||||
@@ -17,12 +21,23 @@ export function meta() {
|
||||
return appMetaTags(msg`Document Preferences`);
|
||||
}
|
||||
|
||||
export const loader = () => {
|
||||
return {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
const { organisations } = useSession();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
@@ -36,16 +51,24 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
documentVisibility === null ||
|
||||
documentLanguage === null ||
|
||||
documentDateFormat === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null ||
|
||||
includeAuditLog === null ||
|
||||
aiFeaturesEnabled === null
|
||||
) {
|
||||
throw new Error('Should not be possible.');
|
||||
@@ -58,12 +81,17 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
reminderSettings: reminderSettings ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,15 +119,18 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
}
|
||||
|
||||
const settingsHeaderText = t`Document Preferences`;
|
||||
const settingsHeaderSubtitle = t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||
? t`Here you can set your general document preferences.`
|
||||
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={false}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
||||
@@ -27,9 +27,8 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
|
||||
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
|
||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||
|
||||
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
@@ -97,23 +96,10 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
||||
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
||||
}, [organisation]);
|
||||
|
||||
const pageHeader = t`Email Domain Settings`;
|
||||
const pageSubtitle = t`Manage your email domain settings.`;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
|
||||
|
||||
<EmailDomainsUpsell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingEmailDomain) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
@@ -146,7 +132,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
|
||||
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
|
||||
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -9,7 +10,6 @@ import { Link } from 'react-router';
|
||||
|
||||
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -19,9 +19,12 @@ export function meta() {
|
||||
|
||||
export default function OrganisationSettingsEmailDomains() {
|
||||
const { t } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
@@ -30,11 +33,7 @@ export default function OrganisationSettingsEmailDomains() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Email Domains`}
|
||||
subtitle={t`Here you can add email domains to your organisation.`}
|
||||
>
|
||||
<SettingsHeader title={t`Email Domains`} subtitle={t`Here you can add email domains to your organisation.`}>
|
||||
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
|
||||
</SettingsHeader>
|
||||
|
||||
@@ -42,8 +41,6 @@ export default function OrganisationSettingsEmailDomains() {
|
||||
<section>
|
||||
<OrganisationEmailDomainsDataTable />
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<EmailDomainsUpsell />
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
@@ -58,7 +55,7 @@ export default function OrganisationSettingsEmailDomains() {
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
|
||||
<Trans>Update Billing</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function OrganisationSettingsGeneral() {
|
||||
|
||||
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
|
||||
try {
|
||||
const { emailId, emailReplyTo, emailDocumentSettings, includeSenderDetails } = data;
|
||||
const { emailId, emailReplyTo, emailDocumentSettings } = data;
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
@@ -36,7 +36,6 @@ export default function OrganisationSettingsGeneral() {
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
// emailReplyToName,
|
||||
emailDocumentSettings,
|
||||
includeSenderDetails: includeSenderDetails ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,7 +59,7 @@ export default function OrganisationSettingsGeneral() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
|
||||
|
||||
<section>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function OrganisationSettingsGeneral() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title={_(msg`General`)} subtitle={_(msg`Here you can edit your organisation details.`)} />
|
||||
|
||||
<div className="space-y-8">
|
||||
@@ -30,19 +30,23 @@ export default function OrganisationSettingsGeneral() {
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<AlertTitle>
|
||||
<Trans>Delete organisation</Trans>
|
||||
</AlertTitle>
|
||||
<>
|
||||
<hr className="my-4" />
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>This organisation, and any associated data will be permanently deleted.</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Delete organisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<OrganisationDeleteDialog />
|
||||
</Alert>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>This organisation, and any associated data will be permanently deleted.</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<OrganisationDeleteDialog />
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -94,11 +94,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation Group Settings`}
|
||||
subtitle={t`Manage your organisation group settings.`}
|
||||
>
|
||||
<SettingsHeader title={t`Organisation Group Settings`} subtitle={t`Manage your organisation group settings.`}>
|
||||
<OrganisationGroupDeleteDialog
|
||||
organisationGroupId={groupId}
|
||||
organisationGroupName={group.name || ''}
|
||||
|
||||
@@ -10,7 +10,6 @@ export default function TeamsSettingsMembersPage() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Custom Organisation Groups`}
|
||||
subtitle={t`Manage the custom groups of members for your organisation.`}
|
||||
>
|
||||
|
||||
@@ -46,11 +46,7 @@ export default function TeamsSettingsMembersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={_(msg`Organisation Members`)}
|
||||
subtitle={_(msg`Manage the members or invite new members.`)}
|
||||
>
|
||||
<SettingsHeader title={_(msg`Organisation Members`)} subtitle={_(msg`Manage the members or invite new members.`)}>
|
||||
<OrganisationMemberInviteDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import {
|
||||
ReminderPreferencesForm,
|
||||
type TReminderPreferencesFormSchema,
|
||||
} from '~/components/forms/reminder-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
export default function OrganisationSettingsRemindersPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const onReminderPreferencesFormSubmit = async (data: TReminderPreferencesFormSchema) => {
|
||||
try {
|
||||
const { envelopeExpirationPeriod, reminderSettings } = data;
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
reminderSettings: reminderSettings ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Reminder preferences updated`,
|
||||
description: t`Your reminder preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your reminder preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Reminders`}
|
||||
subtitle={t`Here you can set expiration and signing reminder preferences for your organisation. Teams will inherit these settings by default.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<ReminderPreferencesForm
|
||||
canInherit={false}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onReminderPreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import {
|
||||
@@ -29,7 +28,6 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { SsoPortalUpsell } from '~/components/general/settings-upsell/sso-portal-upsell';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
|
||||
@@ -65,42 +63,18 @@ export default function OrganisationSettingSSOLoginPage() {
|
||||
const { t } = useLingui();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isAuthenticationPortalEnabled = organisation.organisationClaim.flags.authenticationPortal === true;
|
||||
|
||||
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
|
||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
{
|
||||
// The endpoint rejects orgs without the claim flag, so don't fire
|
||||
// requests that are guaranteed to error.
|
||||
enabled: isAuthenticationPortalEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isAuthenticationPortalEnabled && IS_DOCUMENSO_CLOUD()) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation SSO Portal`}
|
||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||
/>
|
||||
|
||||
<SsoPortalUpsell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation SSO Portal`}
|
||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||
/>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function OrganisationSettingsTeamsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
|
||||
<SettingsHeader title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
|
||||
<TeamCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
|
||||
import { TeamProvider } from '~/providers/team';
|
||||
|
||||
/**
|
||||
* These routes should only render if the user has:
|
||||
*
|
||||
* - 1 Personal organisation
|
||||
* - Nothing else
|
||||
*
|
||||
* This removes the UX complexity for users who only have a single personal organisation, instead of showing them multiple settings pages:
|
||||
*
|
||||
* - Organisation settings
|
||||
* - Teams settings
|
||||
*/
|
||||
export default function Layout() {
|
||||
const { organisations } = useSession();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const currentOrganisation = organisations[0];
|
||||
const team = currentOrganisation?.teams[0] || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPersonalLayoutMode || !team) {
|
||||
void navigate('/settings/profile');
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isPersonalLayoutMode || !team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trpcHeaders = {
|
||||
'x-team-Id': team.id.toString(),
|
||||
};
|
||||
|
||||
return (
|
||||
<TrpcProvider headers={trpcHeaders}>
|
||||
<OrganisationProvider organisation={currentOrganisation}>
|
||||
<TeamProvider team={team}>
|
||||
<Outlet />
|
||||
</TeamProvider>
|
||||
</OrganisationProvider>
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import BillingPage, { meta } from '../../o.$orgUrl.settings.billing';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default BillingPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
import BrandingPage, { meta } from '../../o.$orgUrl.settings.branding';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default BrandingPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
|
||||
|
||||
export { loader, meta };
|
||||
|
||||
export default DocumentPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
import EmailPage, { meta } from '../../o.$orgUrl.settings.email';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default EmailPage;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import PublicProfilePage, { loader, meta } from '../../t.$teamUrl+/settings.public-profile';
|
||||
|
||||
export { loader, meta };
|
||||
|
||||
export default PublicProfilePage;
|
||||
@@ -0,0 +1,5 @@
|
||||
import TokensPage, { meta } from '../../t.$teamUrl+/settings.tokens';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default TokensPage;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default WebhookPage;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default WebhookPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
export function loader() {
|
||||
throw redirect('/settings/profile');
|
||||
}
|
||||
@@ -1,45 +1,30 @@
|
||||
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
|
||||
import { extractCookieFromDocument } from '@documenso/lib/client-only/cookies';
|
||||
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
|
||||
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
|
||||
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Settings`);
|
||||
}
|
||||
|
||||
export const handle: RouteHandle = {
|
||||
layoutMode: 'settings',
|
||||
};
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Settings</Trans>
|
||||
</h1>
|
||||
|
||||
/**
|
||||
* Only runs on the initial document request (SSR) so the first paint has the
|
||||
* correct preferred team without a hydration mismatch.
|
||||
*/
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
|
||||
};
|
||||
}
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
|
||||
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
|
||||
|
||||
/**
|
||||
* Runs instead of the server loader on client-side navigations, otherwise every
|
||||
* settings page switch would trigger a `.data` round-trip to the server just to
|
||||
* read this cookie.
|
||||
*
|
||||
* The cookie is not `HttpOnly` so it can be read straight from the document.
|
||||
*/
|
||||
export function clientLoader() {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
|
||||
};
|
||||
}
|
||||
|
||||
export default function SettingsLayout({ loaderData }: Route.ComponentProps) {
|
||||
return <UnifiedSettingsLayout activeScope="account" preferredTeamUrl={loaderData.preferredTeamUrl} />;
|
||||
<div className="col-span-12 md:col-span-9">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export default function SettingsBilling() {
|
||||
<SettingsHeader
|
||||
title={t`Billing`}
|
||||
subtitle={t`Manage billing and subscriptions for organisations where you have billing management permissions.`}
|
||||
hideDivider
|
||||
/>
|
||||
|
||||
<UserBillingOrganisationsTable />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user