mirror of
https://github.com/documenso/documenso.git
synced 2026-06-23 21:02:06 +10:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de134afba1 | |||
| 36bbd97514 | |||
| 943a0b50e3 | |||
| 6ef501c9f2 | |||
| ac09a48eaa | |||
| 70fb834a6a | |||
| 66e357c9b3 | |||
| 3106fd7483 | |||
| 32c54e1245 | |||
| 83fbc70a1c | |||
| 1ee6ec87a2 | |||
| 6b1b1d0417 | |||
| 9f680c7a61 | |||
| 76d96d2f65 | |||
| 2f2b5dd232 | |||
| 8d97f1dcfa | |||
| e67e19358a | |||
| 364537e8fe | |||
| 4751c9cecc | |||
| a5fd814fbc | |||
| 1d2c781a6d | |||
| 03ca3971a0 |
@@ -153,6 +153,8 @@ NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
||||
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
||||
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ ISO 27001 is an international standard for managing information security, specif
|
||||
|
||||
## HIPAA
|
||||
|
||||
<Callout type="warn">Status: [Planned](https://github.com/documenso/backlog/issues/25)</Callout>
|
||||
<Callout type="info">Status: [Compliant](https://documen.so/trust)</Callout>
|
||||
|
||||
The HIPAA (Health Insurance Portability and Accountability Act) is a U.S. law designed to protect patient health information's privacy and security and improve the healthcare system's efficiency and effectiveness.
|
||||
|
||||
|
||||
@@ -115,9 +115,10 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
|
||||
### Create Component Only
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ------ | ------------------------------ | -------- | --------------------------------------------------- |
|
||||
| `type` | `"DOCUMENT"` \| `"TEMPLATE"` | Yes | Whether to create a document or template envelope |
|
||||
| Prop | Type | Required | Description |
|
||||
| ---------- | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | `"DOCUMENT"` \| `"TEMPLATE"` | Yes | Whether to create a document or template envelope |
|
||||
| `folderId` | `string` | No | The ID of the folder to create the envelope in. If not provided, the envelope is created in the root folder. The folder must match the envelope type and team. |
|
||||
|
||||
### Update Component Only
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ All webhook events share a common structure:
|
||||
{
|
||||
"event": "DOCUMENT_COMPLETED",
|
||||
"payload": {
|
||||
// Document data with recipients
|
||||
// Document or template data with recipients
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:18.277Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
@@ -33,14 +33,13 @@ All webhook events share a common structure:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------- | ------------------------------------------------------ |
|
||||
| `id` | number | Document ID |
|
||||
| `id` | number | Document or template ID |
|
||||
| `externalId` | string? | External identifier for integration |
|
||||
| `userId` | number | Owner's user ID |
|
||||
| `authOptions` | object? | Document-level authentication options |
|
||||
| `formValues` | object? | PDF form values associated with the document |
|
||||
| `title` | string | Document title |
|
||||
| `title` | string | Document or template title |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
|
||||
| `documentDataId` | string | Reference to the document's PDF data |
|
||||
| `visibility` | string | Document visibility setting |
|
||||
| `createdAt` | datetime | Document creation timestamp |
|
||||
| `updatedAt` | datetime | Last modification timestamp |
|
||||
@@ -50,45 +49,50 @@ All webhook events share a common structure:
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
|
||||
| `documentMeta` | object | Document metadata (subject, message, signing options) |
|
||||
| `Recipient` | array | List of recipient objects |
|
||||
| `recipients` | array | List of recipient objects |
|
||||
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
|
||||
|
||||
### Document Metadata Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | ------- | --------------------------------------- |
|
||||
| `id` | string | Metadata record identifier |
|
||||
| `subject` | string? | Email subject line |
|
||||
| `message` | string? | Email message body |
|
||||
| `timezone` | string | Timezone for date display |
|
||||
| `password` | string? | Document access password (if set) |
|
||||
| `dateFormat` | string | Date format string |
|
||||
| `redirectUrl` | string? | URL to redirect after signing |
|
||||
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
|
||||
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
||||
| `language` | string | Document language code |
|
||||
| `distributionMethod` | string | How document is distributed |
|
||||
| `emailSettings` | object? | Custom email settings for this document |
|
||||
| Field | Type | Description |
|
||||
| -------------------------- | ------- | --------------------------------------- |
|
||||
| `id` | string | Metadata record identifier |
|
||||
| `subject` | string? | Email subject line |
|
||||
| `message` | string? | Email message body |
|
||||
| `timezone` | string | Timezone for date display |
|
||||
| `password` | string? | Document access password (if set) |
|
||||
| `dateFormat` | string | Date format string |
|
||||
| `redirectUrl` | string? | URL to redirect after signing |
|
||||
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
|
||||
| `allowDictateNextSigner` | boolean | Whether signers can choose the next signer |
|
||||
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
||||
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
|
||||
| `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed |
|
||||
| `language` | string | Document language code |
|
||||
| `distributionMethod` | string | How document is distributed |
|
||||
| `emailSettings` | object? | Custom email settings for this document |
|
||||
|
||||
### Recipient Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | --------- | ------------------------------------------ |
|
||||
| `id` | number | Recipient ID |
|
||||
| `documentId` | number | Parent document ID |
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `email` | string | Recipient email address |
|
||||
| `name` | string | Recipient name |
|
||||
| `token` | string | Unique signing token |
|
||||
| `documentDeletedAt` | datetime? | When the document was deleted (if deleted) |
|
||||
| `expired` | boolean? | Whether the recipient's link has expired |
|
||||
| `signedAt` | datetime? | When recipient signed |
|
||||
| `authOptions` | object? | Per-recipient authentication options |
|
||||
| `role` | string | Role: `SIGNER`, `VIEWER`, `APPROVER`, `CC` |
|
||||
| `signingOrder` | number? | Position in signing sequence |
|
||||
| `readStatus` | string | `NOT_OPENED` or `OPENED` |
|
||||
| `signingStatus` | string | `NOT_SIGNED`, `SIGNED`, or `REJECTED` |
|
||||
| `sendStatus` | string | `NOT_SENT` or `SENT` |
|
||||
| `rejectionReason` | string? | Reason if recipient rejected |
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | --------- | ------------------------------------------ |
|
||||
| `id` | number | Recipient ID |
|
||||
| `documentId` | number? | Parent document ID |
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `email` | string | Recipient email address |
|
||||
| `name` | string | Recipient name |
|
||||
| `token` | string | Unique signing token |
|
||||
| `documentDeletedAt` | datetime? | When the recipient hid the document |
|
||||
| `expiresAt` | datetime? | When the recipient's signing link expires |
|
||||
| `expirationNotifiedAt` | datetime? | When the expiration notification was sent |
|
||||
| `signedAt` | datetime? | When recipient signed |
|
||||
| `authOptions` | object? | Per-recipient authentication options |
|
||||
| `role` | string | Role: `SIGNER`, `VIEWER`, `APPROVER`, `ASSISTANT`, `CC` |
|
||||
| `signingOrder` | number? | Position in signing sequence |
|
||||
| `readStatus` | string | `NOT_OPENED` or `OPENED` |
|
||||
| `signingStatus` | string | `NOT_SIGNED`, `SIGNED`, or `REJECTED` |
|
||||
| `sendStatus` | string | `NOT_SENT` or `SENT` |
|
||||
| `rejectionReason` | string? | Reason if recipient rejected |
|
||||
|
||||
---
|
||||
|
||||
@@ -98,7 +102,7 @@ These events track the document through its lifecycle.
|
||||
|
||||
### `document.created`
|
||||
|
||||
Triggered when a new document is uploaded.
|
||||
Triggered when a new document is created.
|
||||
|
||||
**Event name:** `DOCUMENT_CREATED`
|
||||
|
||||
@@ -114,7 +118,6 @@ Triggered when a new document is uploaded.
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "DRAFT",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:44:43.341Z",
|
||||
"completedAt": null,
|
||||
@@ -131,11 +134,35 @@ Triggered when a new document is uploaded.
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "NOT_SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
@@ -145,7 +172,8 @@ Triggered when a new document is uploaded.
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
@@ -182,7 +210,6 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:48:07.569Z",
|
||||
"completedAt": null,
|
||||
@@ -199,11 +226,35 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
@@ -213,7 +264,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
@@ -230,6 +282,106 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.opened`
|
||||
|
||||
Triggered when a recipient opens the document for the first time.
|
||||
|
||||
**Event name:** `DOCUMENT_OPENED`
|
||||
|
||||
The recipient's `readStatus` changes to `OPENED`.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_OPENED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:50:26.174Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.signed`
|
||||
|
||||
Triggered when a recipient signs the document. This fires for each individual signature, not just when the document is fully completed.
|
||||
|
||||
**Event name:** `DOCUMENT_SIGNED`
|
||||
|
||||
The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_SIGNED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"status": "COMPLETED",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"completedAt": "2024-04-22T11:52:05.707Z",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 51,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:18.577Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.recipient.completed`
|
||||
|
||||
Triggered when an individual recipient completes their required action (signing, approving, or viewing). This is useful for tracking per-recipient progress in documents with multiple recipients.
|
||||
|
||||
**Event name:** `DOCUMENT_RECIPIENT_COMPLETED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_RECIPIENT_COMPLETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:06.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.completed`
|
||||
|
||||
Triggered when all recipients have completed their required actions.
|
||||
@@ -250,7 +402,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "COMPLETED",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:52:05.708Z",
|
||||
"completedAt": "2024-04-22T11:52:05.707Z",
|
||||
@@ -267,12 +418,15 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
"recipients": [
|
||||
{
|
||||
"id": 50,
|
||||
"documentId": 10,
|
||||
@@ -281,7 +435,8 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"name": "Jane Smith",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": "2024-04-22T11:51:10.055Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
@@ -302,7 +457,54 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"name": "John Doe",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 2,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 50,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "reviewer@example.com",
|
||||
"name": "Jane Smith",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": "2024-04-22T11:51:10.055Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "VIEWER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
@@ -335,53 +537,17 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
||||
"event": "DOCUMENT_REJECTED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:48:07.569Z",
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": "2024-04-22T11:48:07.569Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": "I do not agree with the terms",
|
||||
"role": "SIGNER",
|
||||
"signedAt": "2024-04-22T11:48:07.569Z",
|
||||
"rejectionReason": "I do not agree with the terms",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "REJECTED",
|
||||
"sendStatus": "SENT"
|
||||
@@ -395,7 +561,9 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
||||
|
||||
### `document.cancelled`
|
||||
|
||||
Triggered when the document owner cancels a pending document.
|
||||
Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted.
|
||||
|
||||
This event is **not** triggered when a recipient hides a document from their inbox.
|
||||
|
||||
**Event name:** `DOCUMENT_CANCELLED`
|
||||
|
||||
@@ -411,7 +579,6 @@ Triggered when the document owner cancels a pending document.
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "cm6exvn93006hi02ru90a265a",
|
||||
"createdAt": "2025-01-27T11:02:14.393Z",
|
||||
"updatedAt": "2025-01-27T11:03:16.387Z",
|
||||
"completedAt": null,
|
||||
@@ -428,11 +595,35 @@ Triggered when the document owner cancels a pending document.
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"redirectUrl": "",
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": 7,
|
||||
"documentId": 7,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 7,
|
||||
@@ -442,7 +633,8 @@ Triggered when the document owner cancels a pending document.
|
||||
"name": "John Doe",
|
||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||
"signingOrder": 1,
|
||||
@@ -459,147 +651,127 @@ Triggered when the document owner cancels a pending document.
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### `document.reminder.sent`
|
||||
|
||||
## Recipient Events
|
||||
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
|
||||
|
||||
Recipient events track individual signer actions. These events use the same payload structure as document events, but focus on a specific recipient's action.
|
||||
|
||||
### `document.opened`
|
||||
|
||||
Triggered when a recipient opens the document for the first time.
|
||||
|
||||
**Event name:** `DOCUMENT_OPENED`
|
||||
|
||||
The recipient's `readStatus` changes to `OPENED`.
|
||||
**Event name:** `DOCUMENT_REMINDER_SENT`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_OPENED",
|
||||
"event": "DOCUMENT_REMINDER_SENT",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:48:07.569Z",
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:50:26.174Z",
|
||||
"createdAt": "2024-04-23T09:00:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.signed`
|
||||
---
|
||||
|
||||
Triggered when a recipient signs the document.
|
||||
## Template Events
|
||||
|
||||
**Event name:** `DOCUMENT_SIGNED`
|
||||
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated.
|
||||
|
||||
The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
||||
### `template.created`
|
||||
|
||||
Triggered when a new template is created.
|
||||
|
||||
**Event name:** `TEMPLATE_CREATED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_SIGNED",
|
||||
"event": "TEMPLATE_CREATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "COMPLETED",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:52:05.708Z",
|
||||
"completedAt": "2024-04-22T11:52:05.707Z",
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 51,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
"title": "My Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:18.577Z",
|
||||
"createdAt": "2024-04-22T11:44:44.779Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `template.updated`
|
||||
|
||||
Triggered when a template's settings, recipients, or fields are modified.
|
||||
|
||||
**Event name:** `TEMPLATE_UPDATED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "TEMPLATE_UPDATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"title": "My Updated Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
"createdAt": "2024-04-22T12:00:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `template.deleted`
|
||||
|
||||
Triggered when a template is deleted.
|
||||
|
||||
**Event name:** `TEMPLATE_DELETED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "TEMPLATE_DELETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"title": "Deleted Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
"createdAt": "2024-04-22T13:00:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `template.used`
|
||||
|
||||
Triggered when a document is created from a template. This event fires alongside `document.created`, giving you a way to specifically track template usage.
|
||||
|
||||
**Event name:** `TEMPLATE_USED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "TEMPLATE_USED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"title": "Document from Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
"createdAt": "2024-04-22T14:00:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
@@ -608,15 +780,28 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
||||
|
||||
## Event Summary
|
||||
|
||||
| Event | Trigger | Key Changes |
|
||||
| -------------------- | ------------------------------- | ------------------------------------------------------------ |
|
||||
| `DOCUMENT_CREATED` | Document uploaded | `status: "DRAFT"` |
|
||||
| `DOCUMENT_SENT` | Document sent to recipients | `status: "PENDING"`, recipients `sendStatus: "SENT"` |
|
||||
| `DOCUMENT_OPENED` | Recipient opens document | Recipient `readStatus: "OPENED"` |
|
||||
| `DOCUMENT_SIGNED` | Recipient signs document | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
||||
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
|
||||
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
|
||||
| `DOCUMENT_CANCELLED` | Owner cancels document | Document cancelled while pending |
|
||||
### Document Events
|
||||
|
||||
| Event | Trigger | Key Changes |
|
||||
| ---------------------------- | ------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `DOCUMENT_CREATED` | Document uploaded or created from template | `status: "DRAFT"` |
|
||||
| `DOCUMENT_SENT` | Document sent to recipients | `status: "PENDING"`, recipients `sendStatus: "SENT"` |
|
||||
| `DOCUMENT_OPENED` | Recipient opens document for the first time | Recipient `readStatus: "OPENED"` |
|
||||
| `DOCUMENT_SIGNED` | Recipient signs document | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
||||
| `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
||||
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
|
||||
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
|
||||
| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted |
|
||||
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
|
||||
|
||||
### Template Events
|
||||
|
||||
| Event | Trigger | Key Changes |
|
||||
| ------------------ | ------------------------------------ | ------------------------- |
|
||||
| `TEMPLATE_CREATED` | New template created | `source: "TEMPLATE"` |
|
||||
| `TEMPLATE_UPDATED` | Template settings or fields modified | `source: "TEMPLATE"` |
|
||||
| `TEMPLATE_DELETED` | Template deleted | `source: "TEMPLATE"` |
|
||||
| `TEMPLATE_USED` | Document created from template | `source: "TEMPLATE"` |
|
||||
|
||||
---
|
||||
|
||||
@@ -652,19 +837,22 @@ app.post('/webhook', (req, res) => {
|
||||
|
||||
switch (event) {
|
||||
case 'DOCUMENT_COMPLETED':
|
||||
// Handle completed document
|
||||
console.log(`Document ${payload.id} completed`);
|
||||
break;
|
||||
case 'DOCUMENT_RECIPIENT_COMPLETED':
|
||||
const signer = payload.recipients.find((r) => r.signingStatus === 'SIGNED');
|
||||
console.log(`${signer?.name} completed their action on document ${payload.id}`);
|
||||
break;
|
||||
case 'DOCUMENT_SIGNED':
|
||||
// Handle signature
|
||||
const signer = payload.Recipient.find((r) => r.signingStatus === 'SIGNED');
|
||||
console.log(`${signer?.name} signed document ${payload.id}`);
|
||||
console.log(`Signature added to document ${payload.id}`);
|
||||
break;
|
||||
case 'DOCUMENT_REJECTED':
|
||||
// Handle rejection
|
||||
const rejecter = payload.Recipient.find((r) => r.signingStatus === 'REJECTED');
|
||||
const rejecter = payload.recipients.find((r) => r.signingStatus === 'REJECTED');
|
||||
console.log(`${rejecter?.name} rejected: ${rejecter?.rejectionReason}`);
|
||||
break;
|
||||
case 'TEMPLATE_USED':
|
||||
console.log(`Template ${payload.templateId} used to create document ${payload.id}`);
|
||||
break;
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Webhooks
|
||||
description: Receive real-time notifications when documents are signed, completed, or updated.
|
||||
description: Receive real-time notifications for document and template events.
|
||||
---
|
||||
|
||||
## How Webhooks Work
|
||||
@@ -9,6 +9,8 @@ description: Receive real-time notifications when documents are signed, complete
|
||||
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) as well as template events (created, updated, deleted, used).
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -41,7 +43,15 @@ description: Receive real-time notifications when documents are signed, complete
|
||||
"payload": {
|
||||
"id": 123,
|
||||
"title": "Contract",
|
||||
"status": "COMPLETED"
|
||||
"status": "COMPLETED",
|
||||
"completedAt": "2024-01-15T10:30:00.000Z",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 1,
|
||||
"email": "signer@example.com",
|
||||
"signingStatus": "SIGNED"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
|
||||
@@ -220,11 +220,17 @@ When creating a webhook, you can subscribe to one or more events:
|
||||
| ----- | ------- |
|
||||
| `DOCUMENT_CREATED` | A new document is created |
|
||||
| `DOCUMENT_SENT` | A document is sent to recipients |
|
||||
| `DOCUMENT_OPENED` | A recipient opens the document |
|
||||
| `DOCUMENT_OPENED` | A recipient opens the document for the first time |
|
||||
| `DOCUMENT_SIGNED` | A recipient signs the document |
|
||||
| `DOCUMENT_COMPLETED` | All recipients have signed the document |
|
||||
| `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action |
|
||||
| `DOCUMENT_COMPLETED` | All recipients have completed their actions |
|
||||
| `DOCUMENT_REJECTED` | A recipient rejects the document |
|
||||
| `DOCUMENT_CANCELLED` | The document owner cancels the document |
|
||||
| `DOCUMENT_CANCELLED` | The document owner deletes the document |
|
||||
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
|
||||
| `TEMPLATE_CREATED` | A new template is created |
|
||||
| `TEMPLATE_UPDATED` | A template is modified |
|
||||
| `TEMPLATE_DELETED` | A template is deleted |
|
||||
| `TEMPLATE_USED` | A document is created from a template |
|
||||
|
||||
You can subscribe to all events or select specific ones based on your needs. For example, if you only need to know when documents are fully signed, subscribe only to `DOCUMENT_COMPLETED`.
|
||||
|
||||
|
||||
@@ -250,9 +250,15 @@ const validEvents = [
|
||||
'DOCUMENT_SENT',
|
||||
'DOCUMENT_OPENED',
|
||||
'DOCUMENT_SIGNED',
|
||||
'DOCUMENT_RECIPIENT_COMPLETED',
|
||||
'DOCUMENT_COMPLETED',
|
||||
'DOCUMENT_REJECTED',
|
||||
'DOCUMENT_CANCELLED',
|
||||
'DOCUMENT_REMINDER_SENT',
|
||||
'TEMPLATE_CREATED',
|
||||
'TEMPLATE_UPDATED',
|
||||
'TEMPLATE_DELETED',
|
||||
'TEMPLATE_USED',
|
||||
];
|
||||
|
||||
if (!validEvents.includes(event)) {
|
||||
|
||||
@@ -224,11 +224,31 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ------- |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration | `false` |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||
|
||||
### Signup Restrictions
|
||||
|
||||
You can control who is allowed to create accounts on your instance using two environment variables:
|
||||
|
||||
- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups.
|
||||
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
|
||||
|
||||
Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
|
||||
|
||||
When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list.
|
||||
|
||||
```bash
|
||||
# Allow signups only from specific domains
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
|
||||
# Or disable signups entirely
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -328,6 +348,10 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@example.com"
|
||||
|
||||
# Signing (certificate must be configured)
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
||||
|
||||
# Signup restrictions (optional)
|
||||
# NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -154,8 +154,9 @@ PORT=3000
|
||||
# Signing certificate (see Signing Certificate section)
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
|
||||
|
||||
# Disable public signups
|
||||
# Signup restrictions (optional)
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=false
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
||||
```
|
||||
|
||||
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
|
||||
@@ -251,7 +252,8 @@ Navigate to the signup page and create your account. Verify your email address
|
||||
<Callout type="info">
|
||||
All accounts created through signup are regular user accounts. Admin access must be granted
|
||||
directly in the database. Once your accounts are set up, consider disabling public signups by
|
||||
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`.
|
||||
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with
|
||||
`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`.
|
||||
</Callout>
|
||||
|
||||
## Managing Services
|
||||
|
||||
@@ -101,6 +101,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
|
||||
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
||||
|
||||
|
||||
@@ -153,8 +153,9 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------- | ---------------------------------- | ------- |
|
||||
| `PORT` | Application port | `3000` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -22,6 +22,37 @@ Organisation members have different permission levels that determine what they c
|
||||
organisation.
|
||||
</Callout>
|
||||
|
||||
## Transferring Organisation Ownership
|
||||
|
||||
Organisation ownership cannot be transferred through the regular organisation settings. Only a Documenso instance administrator can transfer ownership through the admin panel.
|
||||
|
||||
If you are using Documenso Cloud, contact support to request an ownership transfer. If you are self-hosting, an instance administrator can follow the steps below.
|
||||
|
||||
The target user must already be a member of the organisation.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
Navigate to **Admin > Organisations** and select the organisation.
|
||||
</Step>
|
||||
<Step>
|
||||
In the **Organisation Members** table, find the target member and click **Update role**.
|
||||
</Step>
|
||||
<Step>
|
||||
Select **Owner** from the role dropdown and click **Update**.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
After the transfer:
|
||||
|
||||
- The new owner is promoted to Admin if they previously held a lower role (Manager or Member).
|
||||
- The previous owner retains their Admin role and remains a member of the organisation.
|
||||
- Only one user can be the owner at a time.
|
||||
|
||||
<Callout type="warn">
|
||||
The current owner cannot be demoted below Admin. Transfer ownership to another member first.
|
||||
</Callout>
|
||||
|
||||
## Team Member Roles
|
||||
|
||||
Teams have three roles with different permission levels:
|
||||
|
||||
@@ -135,7 +135,9 @@ Additional options that apply to all documents created from this template:
|
||||
|
||||
## Template Visibility
|
||||
|
||||
All templates are created in a team context. Team members can see, edit, delete, and use the templates in that team. See [Organisations](/docs/users/organisations) to learn about creating and managing organisations.
|
||||
All templates are created in a team context. By default, templates are **Private** and only visible to members of the owning team.
|
||||
|
||||
If your organisation has multiple teams, you can set a template's type to **Organisation** to share it across all teams. See [Organisation Templates](/docs/users/templates/organisation-templates) for details.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ description: Create reusable document templates for common signing workflows.
|
||||
description="Create documents from your templates."
|
||||
href="/docs/users/templates/use"
|
||||
/>
|
||||
<Card
|
||||
title="Organisation Templates"
|
||||
description="Share templates across all teams in your organisation."
|
||||
href="/docs/users/templates/organisation-templates"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Templates",
|
||||
"pages": ["create", "use"]
|
||||
"pages": ["create", "use", "organisation-templates"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: Organisation Templates
|
||||
description: Share templates across all teams in your organisation so any team can create documents from them.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Overview
|
||||
|
||||
Organisation templates are templates shared across all teams within the same organisation. Any team in the organisation can browse and use them to create documents, but only the owning team can edit or delete them.
|
||||
|
||||
This is useful when you have standardised documents that multiple teams need to use, such as company-wide NDAs, onboarding agreements, or compliance forms.
|
||||
|
||||
## Requirements
|
||||
|
||||
The Organisation template type is available when your organisation has **two or more teams**. If your organisation has only one team, the option does not appear.
|
||||
|
||||
## Template Types
|
||||
|
||||
| Type | Who can see it | Who can edit it | Who can use it |
|
||||
| ---------------- | ------------------------- | ----------------- | --------------------------- |
|
||||
| **Private** | Members of the owning team | Owning team | Owning team |
|
||||
| **Organisation** | All teams in the org | Owning team only | All teams in the org |
|
||||
| **Public** | Anyone with the link | Owning team | Anyone via direct link |
|
||||
|
||||
## Set a Template as Organisation
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Open template settings
|
||||
|
||||
Navigate to **Templates**, open the template you want to share, and click **Edit Template** to open the editor. Then open the settings dialog.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Change the template type
|
||||
|
||||
In the **Template type** dropdown, select **Organisation**. This option only appears if your organisation has at least two teams.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Save
|
||||
|
||||
Click **Save** to apply the change. The template is now visible to all teams in your organisation.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
You can also set the template type to Organisation when creating a new template. The type dropdown appears in the template settings step.
|
||||
|
||||
## Browse Organisation Templates
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Open the templates page
|
||||
|
||||
Navigate to **Templates** in the sidebar.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Switch to the Organisation tab
|
||||
|
||||
Click the **Organisation** tab above the template list. This tab only appears for non-personal organisations.
|
||||
|
||||
The Organisation tab shows all organisation templates from every team in your organisation, including your own.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Templates from other teams display the owning team's name next to the template type.
|
||||
|
||||
## Use an Organisation Template
|
||||
|
||||
Any team member in the organisation can create documents from an organisation template, even if the template belongs to a different team.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
Find the template in the **Organisation** tab or click through from the template detail page.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
Click **Use Template** and fill in the recipient details. The document is created under your team, not the template's owning team.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
See [Use Templates](/docs/users/templates/use) for details on creating documents from templates.
|
||||
|
||||
## Editing and Permissions
|
||||
|
||||
Only members of the team that owns the template can edit or delete it. When viewing an organisation template from another team:
|
||||
|
||||
- The **Edit Template**, **Direct Link**, and **Bulk Send** controls are hidden
|
||||
- The recipients section is read-only
|
||||
- The **Use Template** button is available
|
||||
|
||||
To modify a template owned by another team, contact that team's members or ask an organisation admin to make changes.
|
||||
|
||||
## Visibility
|
||||
|
||||
Organisation templates respect the same visibility settings as other templates. A template's visibility determines which team roles can access it:
|
||||
|
||||
| Visibility | Who can access |
|
||||
| --------------------- | --------------------------------------- |
|
||||
| **Everyone** | All team members (Admin, Manager, Member) |
|
||||
| **Manager and above** | Admins and Managers only |
|
||||
| **Admin** | Admins only |
|
||||
|
||||
This applies to both the owning team and other teams in the organisation. A Member-role user on any team cannot see an organisation template set to Admin visibility.
|
||||
|
||||
## Reverting to Private
|
||||
|
||||
To stop sharing a template across the organisation, change the template type back to **Private** in the template settings. The template will only be visible to the owning team. Documents already created from the template are not affected.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Create Templates](/docs/users/templates/create) - Build reusable templates
|
||||
- [Use Templates](/docs/users/templates/use) - Create documents from templates
|
||||
- [Organisations](/docs/users/organisations) - Managing organisations and teams
|
||||
@@ -57,7 +57,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create Subscription Claim</Trans>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Update Subscription Claim</Trans>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/fi
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
||||
@@ -155,9 +155,7 @@ export const ConfigureFieldsView = ({
|
||||
});
|
||||
|
||||
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
|
||||
const selectedRecipientStyles = useRecipientColors(
|
||||
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
|
||||
);
|
||||
const selectedRecipientStyles = getRecipientColorStyles(selectedRecipientIndex);
|
||||
|
||||
const form = useForm<TConfigureFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -54,8 +54,8 @@ export const ZSignUpFormSchema = z
|
||||
},
|
||||
);
|
||||
|
||||
export const signupErrorMessages: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signups are disabled.`,
|
||||
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
|
||||
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
||||
};
|
||||
@@ -130,7 +130,8 @@ export const SignUpForm = ({
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
||||
const errorMessage =
|
||||
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
@@ -196,7 +197,7 @@ export const SignUpForm = ({
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-center gap-x-12', className)}>
|
||||
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
|
||||
<div className="relative hidden flex-1 overflow-hidden rounded-xl border border-border xl:flex">
|
||||
<div className="absolute -inset-8 -z-[2] backdrop-blur">
|
||||
<img
|
||||
src={communityCardsImage}
|
||||
@@ -205,17 +206,17 @@ export const SignUpForm = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
|
||||
<div className="absolute -inset-8 -z-[1] bg-background/50 backdrop-blur-[2px]" />
|
||||
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
|
||||
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
|
||||
<div className="rounded-2xl border bg-background px-4 py-1 text-sm font-medium">
|
||||
<Trans>User profiles are here!</Trans>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<UserProfileTimur
|
||||
rows={2}
|
||||
className="bg-background border-border rounded-2xl border shadow-md"
|
||||
className="rounded-2xl border border-border bg-background shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -223,13 +224,13 @@ export const SignUpForm = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
||||
<div className="relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||
<div className="h-20">
|
||||
<h1 className="text-xl font-semibold md:text-2xl">
|
||||
<Trans>Create a new account</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
|
||||
<p className="mt-2 text-xs text-muted-foreground md:text-sm">
|
||||
<Trans>
|
||||
Create your account and start using state-of-the-art document signing. Open and
|
||||
beautiful signing is within your grasp.
|
||||
@@ -323,70 +324,62 @@ export const SignUpForm = ({
|
||||
/>
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>Or</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
</>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="bg-transparent text-muted-foreground">
|
||||
<Trans>Or</Trans>
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with Google</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with Google</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with OIDC</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with OIDC</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Already have an account?{' '}
|
||||
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||
@@ -406,7 +399,7 @@ export const SignUpForm = ({
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p className="text-muted-foreground mt-6 text-xs">
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
By proceeding, you agree to our{' '}
|
||||
<Link
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyRoundIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
@@ -32,6 +36,7 @@ type AdminLicenseCardProps = {
|
||||
|
||||
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
|
||||
|
||||
const { license } = licenseData || {};
|
||||
|
||||
@@ -53,6 +58,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
<Trans>Invalid License Key</Trans>
|
||||
</p>
|
||||
{/* Don't need to hide invalid license keys. */}
|
||||
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -135,7 +141,26 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>License Key</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{license.licenseKey}</p>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<p className="min-w-0 break-all text-xs text-muted-foreground">
|
||||
{isLicenseKeyVisible ? license.licenseKey : '•'.repeat(license.licenseKey.length)}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
aria-label={isLicenseKeyVisible ? t`Hide license key` : t`Show license key`}
|
||||
onClick={() => setIsLicenseKeyVisible((prevState) => !prevState)}
|
||||
>
|
||||
{isLicenseKeyVisible ? (
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { signupErrorMessages } from '~/components/forms/signup';
|
||||
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
||||
|
||||
export type ClaimAccountProps = {
|
||||
defaultName: string;
|
||||
@@ -90,7 +90,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
||||
const errorMessage =
|
||||
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
|
||||
+5
-1
@@ -117,7 +117,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const recipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||
defaultValues: {
|
||||
values: {
|
||||
name: recipientPayload?.name ?? '',
|
||||
email: recipientPayload?.email ?? '',
|
||||
},
|
||||
@@ -157,6 +157,10 @@ export const DocumentSigningCompleteDialog = ({
|
||||
}
|
||||
|
||||
recipientOverridePayload = recipientForm.getValues();
|
||||
} else if (recipientPayload && recipientPayload.email && !recipient.email) {
|
||||
// Form is hidden because we have an email (e.g. from embed context),
|
||||
// but the DB recipient doesn't have one yet — send the override.
|
||||
recipientOverridePayload = recipientPayload;
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
|
||||
+11
-13
@@ -9,7 +9,7 @@ import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
@@ -131,18 +131,16 @@ export const DocumentSigningFieldContainer = ({
|
||||
|
||||
return (
|
||||
<FieldRootContainer
|
||||
color={
|
||||
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
|
||||
}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
color={getRecipientColorStyles(field.fieldMeta?.readOnly ? 'readOnly' : 0)}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
|
||||
+8
-14
@@ -23,7 +23,7 @@ import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
|
||||
@@ -253,9 +253,10 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
const selectedRecipientColor = useMemo(() => {
|
||||
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
||||
}, [selectedRecipientId, getRecipientColorKey]);
|
||||
const selectedRecipientStyles = useMemo(
|
||||
() => getRecipientColorStyles(getRecipientColorKey(selectedRecipientId ?? -1)),
|
||||
[selectedRecipientId, getRecipientColorKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -270,21 +271,14 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
className={cn(
|
||||
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
||||
selectedRecipientStyles.fieldButton,
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
|
||||
field.className,
|
||||
{
|
||||
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
||||
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
|
||||
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
|
||||
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
|
||||
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
|
||||
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
|
||||
},
|
||||
selectedRecipientStyles.fieldButtonText,
|
||||
)}
|
||||
>
|
||||
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
||||
@@ -298,7 +292,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
<div
|
||||
className={cn(
|
||||
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||
selectedRecipientStyles.base,
|
||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
Building2Icon,
|
||||
Globe2Icon,
|
||||
LockIcon,
|
||||
RefreshCwIcon,
|
||||
@@ -94,12 +95,19 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<>
|
||||
{envelope.templateType === 'PRIVATE' ? (
|
||||
{envelope.templateType === TemplateType.PRIVATE && (
|
||||
<Badge variant="secondary">
|
||||
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
|
||||
<Trans>Private Template</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
)}
|
||||
{envelope.templateType === TemplateType.ORGANISATION && (
|
||||
<Badge variant="orange">
|
||||
<Building2Icon className="mr-2 size-4" />
|
||||
<Trans>Organisation Template</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{envelope.templateType === TemplateType.PUBLIC && (
|
||||
<Badge variant="default">
|
||||
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Public Template</Trans>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DocumentVisibility,
|
||||
EnvelopeType,
|
||||
SendStatus,
|
||||
TemplateType,
|
||||
} from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
@@ -66,6 +67,10 @@ import {
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import {
|
||||
TemplateTypeSelect,
|
||||
TemplateTypeTooltip,
|
||||
} from '@documenso/ui/components/template/template-type-select';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
@@ -102,6 +107,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export const ZAddSettingsFormSchema = z.object({
|
||||
templateType: z.nativeEnum(TemplateType).optional(),
|
||||
externalId: z.string().optional(),
|
||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||
globalAccessAuth: z
|
||||
@@ -196,6 +202,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
const createDefaultValues = () => {
|
||||
return {
|
||||
templateType: envelope.templateType || TemplateType.PRIVATE,
|
||||
externalId: envelope.externalId || '',
|
||||
visibility: envelope.visibility || '',
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
||||
@@ -270,6 +277,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
try {
|
||||
await updateEnvelopeAsync({
|
||||
data: {
|
||||
templateType: envelope.type === EnvelopeType.TEMPLATE ? data.templateType : undefined,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
@@ -606,6 +614,31 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="templateType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Template type</Trans>
|
||||
<TemplateTypeTooltip
|
||||
organisationTeamCount={organisation.teams.length}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<TemplateTypeSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settings.allowConfigureDistribution && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -59,12 +59,8 @@ export const EnvelopeRecipientSelector = ({
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
|
||||
getRecipientColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
||||
0,
|
||||
),
|
||||
).comboxBoxTrigger,
|
||||
getRecipientColorStyles(recipients.findIndex((r) => r.id === selectedRecipient?.id))
|
||||
.comboBoxTrigger,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -197,12 +193,8 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
key={recipient.id}
|
||||
className={cn(
|
||||
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
|
||||
getRecipientColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === recipient.id),
|
||||
0,
|
||||
),
|
||||
).comboxBoxItem,
|
||||
getRecipientColorStyles(recipients.findIndex((r) => r.id === recipient.id))
|
||||
.comboBoxItem,
|
||||
{
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
'cursor-not-allowed': isRecipientDisabled(recipient.id),
|
||||
|
||||
+5
-8
@@ -32,7 +32,6 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
||||
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
@@ -139,13 +138,11 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
|
||||
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
|
||||
|
||||
let color: TRecipientColor = 'green';
|
||||
|
||||
if (fieldToRender.fieldMeta?.readOnly) {
|
||||
color = 'readOnly';
|
||||
} else if (showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)) {
|
||||
color = 'orange';
|
||||
}
|
||||
const color = fieldToRender.fieldMeta?.readOnly
|
||||
? 'readOnly'
|
||||
: showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)
|
||||
? 'orange'
|
||||
: 'green';
|
||||
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
|
||||
+2
@@ -221,10 +221,12 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
return {
|
||||
name:
|
||||
recipient.name ||
|
||||
fullName ||
|
||||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
'',
|
||||
email:
|
||||
recipient.email ||
|
||||
email ||
|
||||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
||||
'',
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
/**
|
||||
* Not really used anymore, this causes random 500s when the user refreshes while this occurs.
|
||||
*/
|
||||
export const RefreshOnFocus = () => {
|
||||
const { revalidate, state } = useRevalidator();
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
if (state === 'idle') {
|
||||
void revalidate();
|
||||
}
|
||||
}, [revalidate]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [onFocus]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -136,6 +136,7 @@ export const TemplateEditForm = ({
|
||||
templateId: template.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
type: data.templateType,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
|
||||
@@ -14,36 +14,40 @@ export type TemplatePageViewRecipientsProps = {
|
||||
recipients: Recipient[];
|
||||
envelopeId: string;
|
||||
templateRootPath: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export const TemplatePageViewRecipients = ({
|
||||
recipients,
|
||||
envelopeId,
|
||||
templateRootPath,
|
||||
readOnly = false,
|
||||
}: TemplatePageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<h1 className="font-medium text-foreground">
|
||||
<Trans>Recipients</Trans>
|
||||
</h1>
|
||||
|
||||
<Link
|
||||
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
|
||||
title={_(msg`Modify recipients`)}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
{recipients.length === 0 ? (
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<PenIcon className="ml-2 h-3 w-3" />
|
||||
)}
|
||||
</Link>
|
||||
{!readOnly && (
|
||||
<Link
|
||||
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
|
||||
title={_(msg`Modify recipients`)}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
{recipients.length === 0 ? (
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<PenIcon className="ml-2 h-3 w-3" />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="text-muted-foreground divide-y border-t">
|
||||
<ul className="divide-y border-t text-muted-foreground">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -60,13 +64,13 @@ export const TemplatePageViewRecipients = ({
|
||||
}
|
||||
primaryText={
|
||||
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
|
||||
<p className="text-muted-foreground text-sm">{recipient.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{recipient.name}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-sm text-muted-foreground">{recipient.email}</p>
|
||||
)
|
||||
}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { TemplateType as TemplateTypePrisma } from '@prisma/client';
|
||||
import { Globe2, Lock } from 'lucide-react';
|
||||
import { Building2, Globe2, Lock } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -28,6 +28,11 @@ const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
|
||||
icon: Globe2,
|
||||
color: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
ORGANISATION: {
|
||||
label: msg`Organisation`,
|
||||
icon: Building2,
|
||||
color: 'text-orange-500 dark:text-orange-300',
|
||||
},
|
||||
};
|
||||
|
||||
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
|
||||
@@ -19,20 +19,16 @@ export type WebhookLogsSheetProps = {
|
||||
};
|
||||
|
||||
export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | null>(
|
||||
({ call, webhookCall: initialWebhookCall }) => {
|
||||
({ call, webhookCall }) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [webhookCall, setWebhookCall] = useState(initialWebhookCall);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'request' | 'response'>('request');
|
||||
|
||||
const { mutateAsync: resendWebhookCall, isPending: isResending } =
|
||||
trpc.webhook.calls.resend.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast({ title: t`Webhook successfully sent` });
|
||||
|
||||
setWebhookCall(result);
|
||||
onSuccess: () => {
|
||||
toast({ title: t`Webhook queued for resend` });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t`Something went wrong` });
|
||||
@@ -71,20 +67,20 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Webhook Details</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground font-mono text-xs">{webhookCall.id}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{webhookCall.id}</p>
|
||||
</SheetTitle>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mt-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
|
||||
<h4 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Trans>Details</Trans>
|
||||
</h4>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
resendWebhookCall({
|
||||
void resendWebhookCall({
|
||||
webhookId: webhookCall.webhookId,
|
||||
webhookCallId: webhookCall.id,
|
||||
})
|
||||
@@ -98,15 +94,15 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
|
||||
<Trans>Resend</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-border overflow-hidden rounded-lg border">
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<tbody className="divide-border bg-muted/30 divide-y">
|
||||
<tbody className="divide-y divide-border bg-muted/30">
|
||||
{generalWebhookDetails.map(({ header, value }, index) => (
|
||||
<tr key={index}>
|
||||
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
|
||||
<td className="w-1/3 border-r border-border px-4 py-2 font-mono text-xs text-muted-foreground">
|
||||
{header}
|
||||
</td>
|
||||
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
|
||||
<td className="break-all px-4 py-2 font-mono text-xs text-foreground">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -118,13 +114,13 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
|
||||
|
||||
{/* Payload Tabs */}
|
||||
<div className="py-6">
|
||||
<div className="border-border mb-4 flex items-center gap-4 border-b">
|
||||
<div className="mb-4 flex items-center gap-4 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab('request')}
|
||||
className={cn(
|
||||
'relative pb-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'request'
|
||||
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
|
||||
? 'text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -136,7 +132,7 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
|
||||
className={cn(
|
||||
'relative pb-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'response'
|
||||
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
|
||||
? 'text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -155,7 +151,7 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
<pre className="bg-muted/50 border-border text-foreground overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed">
|
||||
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed text-foreground">
|
||||
{JSON.stringify(
|
||||
activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
|
||||
null,
|
||||
@@ -166,19 +162,19 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
|
||||
|
||||
{activeTab === 'response' && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
|
||||
<h4 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Trans>Response Headers</Trans>
|
||||
</h4>
|
||||
<div className="border-border overflow-hidden rounded-lg border">
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<tbody className="divide-border bg-muted/30 divide-y">
|
||||
<tbody className="divide-y divide-border bg-muted/30">
|
||||
{Object.entries(webhookCall.responseHeaders as Record<string, string>).map(
|
||||
([key, value]) => (
|
||||
<tr key={key}>
|
||||
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
|
||||
<td className="w-1/3 border-r border-border px-4 py-2 font-mono text-xs text-muted-foreground">
|
||||
{key}
|
||||
</td>
|
||||
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
|
||||
<td className="break-all px-4 py-2 font-mono text-xs text-foreground">
|
||||
{value as string}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -142,8 +142,8 @@ export const SettingsPublicProfileTemplatesTable = () => {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="text-sm">{template.publicTitle}</p>
|
||||
<p className="text-xs text-neutral-400">{template.publicDescription}</p>
|
||||
<p className="text-sm break-all">{template.publicTitle}</p>
|
||||
<p className="text-xs text-neutral-400 break-all">{template.publicDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Recipient, TemplateDirectLink } from '@prisma/client';
|
||||
import { Copy, Edit, FolderIcon, MoreHorizontal, Share2Icon, Trash2, Upload } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -42,76 +41,72 @@ export const TemplatesTableActionDropdown = ({
|
||||
teamId,
|
||||
onDelete,
|
||||
}: TemplatesTableActionDropdownProps) => {
|
||||
const { user } = useSession();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
|
||||
|
||||
const isOwner = row.userId === user.id;
|
||||
const isTeamTemplate = row.teamId === teamId;
|
||||
const canMutate = isTeamTemplate;
|
||||
|
||||
const formatPath = `${templateRootPath}/${row.envelopeId}/edit`;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||
<DropdownMenuItem disabled={!canMutate} asChild>
|
||||
<Link to={formatPath}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDuplicateDialogOpen(true)}
|
||||
>
|
||||
<DropdownMenuItem disabled={!canMutate} onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
directLink={row.directLink}
|
||||
trigger={
|
||||
<div
|
||||
data-testid="template-direct-link"
|
||||
className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors"
|
||||
>
|
||||
<Share2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Direct link</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{canMutate && (
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
directLink={row.directLink}
|
||||
trigger={
|
||||
<div
|
||||
data-testid="template-direct-link"
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Share2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Direct link</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||
<DropdownMenuItem disabled={!canMutate} onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<TemplateBulkSendDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
trigger={
|
||||
<div className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{canMutate && (
|
||||
<TemplateBulkSendDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
trigger={
|
||||
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<DropdownMenuItem disabled={!canMutate} onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -3,7 +3,15 @@ import { useMemo, useTransition } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Building2Icon,
|
||||
Globe2Icon,
|
||||
InfoIcon,
|
||||
Link2Icon,
|
||||
Loader,
|
||||
LockIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
@@ -166,25 +174,48 @@ export const TemplatesTable = ({
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<Building2Icon className="mr-2 h-5 w-5 text-orange-500 dark:text-orange-300" />
|
||||
<Trans>Organisation</Trans>
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
Organisation templates are shared across all teams within the same
|
||||
organisation. Only the owning team can edit them.
|
||||
</Trans>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-row items-center">
|
||||
<TemplateType type={row.original.type} />
|
||||
cell: ({ row }) => {
|
||||
const isFromOtherTeam = row.original.teamId !== team?.id;
|
||||
|
||||
{row.original.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-2"
|
||||
token={row.original.directLink.token}
|
||||
enabled={row.original.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<TemplateType type={row.original.type} />
|
||||
|
||||
{isFromOtherTeam && row.original.team?.name && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({row.original.team.name})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{row.original.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-2"
|
||||
token={row.original.directLink.token}
|
||||
enabled={row.original.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
|
||||
+11
-8
@@ -1,3 +1,4 @@
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
@@ -138,15 +139,17 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
<SessionProvider initialSession={session}>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>
|
||||
{children}
|
||||
<NuqsAdapter>
|
||||
<SessionProvider initialSession={session}>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>
|
||||
{children}
|
||||
|
||||
<Toaster />
|
||||
</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
<Toaster />
|
||||
</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
</NuqsAdapter>
|
||||
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
|
||||
@@ -67,6 +67,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
|
||||
const { data } = trpc.template.findTemplates.useQuery({
|
||||
type: TemplateType.PRIVATE,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
@@ -82,8 +83,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
const enabledPrivateDirectTemplates = useMemo(
|
||||
() =>
|
||||
(data?.data ?? []).filter(
|
||||
(template): template is DirectTemplate =>
|
||||
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
|
||||
(template): template is DirectTemplate => template.directLink?.enabled === true,
|
||||
),
|
||||
[data],
|
||||
);
|
||||
@@ -143,7 +143,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
|
||||
'flex flex-row items-center justify-center space-x-2 text-xs text-muted-foreground/50',
|
||||
{
|
||||
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
|
||||
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
|
||||
@@ -164,7 +164,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
|
||||
<TooltipContent className="max-w-[40ch] space-y-2 py-2 text-muted-foreground">
|
||||
{isPublicProfileVisible ? (
|
||||
<>
|
||||
<p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
|
||||
import { ChevronLeft, LucideEdit } from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
@@ -37,19 +37,25 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
import type { Route } from './+types/templates.$id._index';
|
||||
|
||||
export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
const { user } = useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const {
|
||||
data: envelope,
|
||||
isLoading: isLoadingEnvelope,
|
||||
isError: isErrorEnvelope,
|
||||
} = trpc.envelope.get.useQuery({
|
||||
envelopeId: params.id,
|
||||
});
|
||||
// Try fetching as a team template first; only fall back to the org endpoint if the team
|
||||
// query has definitively failed (i.e. this template belongs to a sibling team).
|
||||
// We disable retries on the team query so the org fallback kicks in immediately.
|
||||
const teamTemplateQuery = trpc.envelope.get.useQuery({ envelopeId: params.id }, { retry: false });
|
||||
const orgTemplateQuery = trpc.template.getOrganisationTemplateById.useQuery(
|
||||
{ envelopeId: params.id },
|
||||
{ enabled: teamTemplateQuery.isError, retry: false },
|
||||
);
|
||||
|
||||
const envelope = teamTemplateQuery.data ?? orgTemplateQuery.data;
|
||||
const isLoadingEnvelope =
|
||||
teamTemplateQuery.isLoading ||
|
||||
(teamTemplateQuery.isError && !orgTemplateQuery.isError && !orgTemplateQuery.data);
|
||||
const isErrorEnvelope = teamTemplateQuery.isError && orgTemplateQuery.isError;
|
||||
|
||||
if (isLoadingEnvelope) {
|
||||
return (
|
||||
@@ -84,6 +90,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team.url);
|
||||
const templateRootPath = formatTemplatesPath(team.url);
|
||||
const isOwnTeamTemplate = envelope.teamId === team?.id;
|
||||
|
||||
// Remap to fit the DocumentReadOnlyFields component.
|
||||
const readOnlyFields = envelope.fields.map((field) => {
|
||||
@@ -146,23 +153,27 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
directLink={envelope.directLink}
|
||||
recipients={envelope.recipients}
|
||||
/>
|
||||
{isOwnTeamTemplate && (
|
||||
<>
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
directLink={envelope.directLink}
|
||||
recipients={envelope.recipients}
|
||||
/>
|
||||
|
||||
<TemplateBulkSendDialog
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
recipients={envelope.recipients}
|
||||
/>
|
||||
<TemplateBulkSendDialog
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
recipients={envelope.recipients}
|
||||
/>
|
||||
|
||||
<Button className="w-full" asChild>
|
||||
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
|
||||
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||
<Trans>Edit Template</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className="w-full" asChild>
|
||||
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
|
||||
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||
<Trans>Edit Template</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -236,22 +247,28 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
<Trans>Template</Trans>
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<TemplatesTableActionDropdown
|
||||
row={{
|
||||
...envelope,
|
||||
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
}}
|
||||
teamId={team?.id}
|
||||
templateRootPath={templateRootPath}
|
||||
onDelete={async () => navigate(templateRootPath)}
|
||||
/>
|
||||
</div>
|
||||
{isOwnTeamTemplate && (
|
||||
<div>
|
||||
<TemplatesTableActionDropdown
|
||||
row={{
|
||||
...envelope,
|
||||
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
}}
|
||||
teamId={team?.id}
|
||||
templateRootPath={templateRootPath}
|
||||
onDelete={async () => navigate(templateRootPath)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 px-4 text-sm text-muted-foreground">
|
||||
<Trans>Manage and view template</Trans>
|
||||
{isOwnTeamTemplate ? (
|
||||
<Trans>Manage and view template</Trans>
|
||||
) : (
|
||||
<Trans>View organisation template</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 border-t px-4 pt-4">
|
||||
@@ -278,6 +295,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
recipients={envelope.recipients}
|
||||
envelopeId={envelope.id}
|
||||
templateRootPath={templateRootPath}
|
||||
readOnly={!isOwnTeamTemplate}
|
||||
/>
|
||||
|
||||
{/* Recent activity section. */}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
@@ -43,18 +44,36 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
teamUrl: params.teamUrl,
|
||||
});
|
||||
|
||||
// Try the team endpoint first, then fall back to the org endpoint.
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
id: { type: 'templateId', id: templateId },
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
}).catch((err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
if (error.code === AppErrorCode.NOT_FOUND || error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (envelope) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${envelope.id}`));
|
||||
}
|
||||
|
||||
const orgEnvelope = await getOrganisationTemplateById({
|
||||
id: { type: 'templateId', id: templateId },
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
}).catch((err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND || error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
@@ -63,7 +82,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${envelope.id}`));
|
||||
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${orgEnvelope.id}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
@@ -22,12 +25,17 @@ import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
||||
|
||||
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Templates');
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -35,6 +43,14 @@ export default function TemplatesPage() {
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
const [view, setView] = useQueryState(
|
||||
'view',
|
||||
parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'),
|
||||
);
|
||||
|
||||
const isOrgView = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
'templates-bulk-selection',
|
||||
{},
|
||||
@@ -49,16 +65,41 @@ export default function TemplatesPage() {
|
||||
const documentRootPath = formatDocumentsPath(team.url);
|
||||
const templateRootPath = formatTemplatesPath(team.url);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
folderId,
|
||||
});
|
||||
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
enabled: !isOrgView,
|
||||
},
|
||||
);
|
||||
|
||||
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
},
|
||||
{
|
||||
enabled: isOrgView,
|
||||
},
|
||||
);
|
||||
|
||||
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
|
||||
|
||||
const handleViewChange = (newView: string) => {
|
||||
if (newView !== 'team' && newView !== 'organisation') {
|
||||
return;
|
||||
}
|
||||
|
||||
void setView(newView === 'team' ? null : newView);
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
|
||||
{!isOrgView && <FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />}
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-row items-center">
|
||||
@@ -74,8 +115,31 @@ export default function TemplatesPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{showOrgTab && (
|
||||
<div className="mt-6">
|
||||
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
className="min-w-[60px] hover:text-foreground"
|
||||
value="team"
|
||||
data-testid="template-tab-team"
|
||||
>
|
||||
<Trans>Team</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="min-w-[60px] hover:text-foreground"
|
||||
value="organisation"
|
||||
data-testid="template-tab-organisation"
|
||||
>
|
||||
<Trans>Organisation</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
{data && data.count === 0 ? (
|
||||
{activeQuery.data && activeQuery.data.count === 0 ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
@@ -85,51 +149,59 @@ export default function TemplatesPage() {
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 max-w-[50ch]">
|
||||
<Trans>
|
||||
You have not yet created any templates. To create a template please upload
|
||||
one.
|
||||
</Trans>
|
||||
{isOrgView ? (
|
||||
<Trans>No organisation templates are shared with your team yet.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You have not yet created any templates. To create a template please upload
|
||||
one.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TemplatesTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
data={activeQuery.data}
|
||||
isLoading={activeQuery.isLoading}
|
||||
isLoadingError={activeQuery.isLoadingError}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
enableSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
enableSelection={!isOrgView}
|
||||
rowSelection={isOrgView ? {} : rowSelection}
|
||||
onRowSelectionChange={isOrgView ? undefined : setRowSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvelopesTableBulkActionBar
|
||||
selectedCount={selectedEnvelopeIds.length}
|
||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
onClearSelection={() => setRowSelection({})}
|
||||
/>
|
||||
{!isOrgView && (
|
||||
<>
|
||||
<EnvelopesTableBulkActionBar
|
||||
selectedCount={selectedEnvelopeIds.length}
|
||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
onClearSelection={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkMoveDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
open={isBulkMoveDialogOpen}
|
||||
currentFolderId={folderId}
|
||||
onOpenChange={setIsBulkMoveDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
<EnvelopesBulkMoveDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
open={isBulkMoveDialogOpen}
|
||||
currentFolderId={folderId}
|
||||
onOpenChange={setIsBulkMoveDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkDeleteDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
open={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
<EnvelopesBulkDeleteDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
open={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</EnvelopeDropZoneWrapper>
|
||||
);
|
||||
|
||||
@@ -176,10 +176,10 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-semibold leading-none">
|
||||
<p className="text-foreground text-sm font-semibold leading-none break-all">
|
||||
{template.publicTitle}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 max-w-[70ch] whitespace-normal text-xs">
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 max-w-[70ch] whitespace-normal text-xs break-all">
|
||||
{template.publicDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -152,9 +152,12 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
throw redirect(documentMeta?.redirectUrl || `/sign/${token}/complete`);
|
||||
}
|
||||
|
||||
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
|
||||
const [recipientSignatures, settings] = await Promise.all([
|
||||
getRecipientSignatures({ recipientId: recipient.id }),
|
||||
getTeamSettings({ teamId: document.teamId }),
|
||||
]);
|
||||
|
||||
const settings = await getTeamSettings({ teamId: document.teamId });
|
||||
const [recipientSignature] = recipientSignatures;
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link, redirect } from 'react-router';
|
||||
import { Link, redirect, useSearchParams } from 'react-router';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import {
|
||||
@@ -12,8 +13,10 @@ import {
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/signin';
|
||||
@@ -57,8 +60,14 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
returnTo,
|
||||
} = loaderData;
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||
|
||||
const errorParam = searchParams.get('error');
|
||||
const signupError = errorParam ? SIGNUP_ERROR_MESSAGES[errorParam] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
@@ -69,12 +78,18 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
|
||||
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||
{signupError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{_(signupError)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<h1 className="text-2xl font-semibold">
|
||||
<Trans>Sign in to your account</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>Welcome back, we are lucky to have you.</Trans>
|
||||
</p>
|
||||
<hr className="-mx-6 my-4" />
|
||||
@@ -88,7 +103,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
/>
|
||||
|
||||
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function EmbedPlaygroundPage() {
|
||||
const [envelopeType, setEnvelopeType] = useState<'DOCUMENT' | 'TEMPLATE'>(
|
||||
() => (searchParams.get('envelopeType') as 'DOCUMENT' | 'TEMPLATE') || 'DOCUMENT',
|
||||
);
|
||||
const [folderId, setFolderId] = useState(() => searchParams.get('folderId') || '');
|
||||
|
||||
// Auto-launch if query params are present on mount
|
||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||
@@ -201,6 +202,7 @@ export default function EmbedPlaygroundPage() {
|
||||
mode: string;
|
||||
envelopeId: string;
|
||||
envelopeType: string;
|
||||
folderId: string;
|
||||
}) => {
|
||||
const newParams = new URLSearchParams();
|
||||
|
||||
@@ -224,6 +226,10 @@ export default function EmbedPlaygroundPage() {
|
||||
newParams.set('envelopeType', params.envelopeType);
|
||||
}
|
||||
|
||||
if (params.folderId) {
|
||||
newParams.set('folderId', params.folderId);
|
||||
}
|
||||
|
||||
const qs = newParams.toString();
|
||||
|
||||
void navigate(qs ? `?${qs}` : '.', { replace: true });
|
||||
@@ -263,6 +269,7 @@ export default function EmbedPlaygroundPage() {
|
||||
const hashData = {
|
||||
externalId: externalId || undefined,
|
||||
type: mode === 'create' ? envelopeType : undefined,
|
||||
folderId: mode === 'create' && folderId ? folderId : undefined,
|
||||
darkModeDisabled: darkModeDisabled || undefined,
|
||||
css: rawCss || undefined,
|
||||
cssVars: Object.keys(filteredCssVars).length > 0 ? filteredCssVars : undefined,
|
||||
@@ -292,7 +299,7 @@ export default function EmbedPlaygroundPage() {
|
||||
setIframeSrc(buildIframeSrc(basePath, presignToken, hash));
|
||||
setIframeKey((prev) => prev + 1);
|
||||
|
||||
updateQueryParams({ token: inputToken, externalId, mode, envelopeId, envelopeType });
|
||||
updateQueryParams({ token: inputToken, externalId, mode, envelopeId, envelopeType, folderId });
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
@@ -306,6 +313,7 @@ export default function EmbedPlaygroundPage() {
|
||||
mode,
|
||||
envelopeId,
|
||||
envelopeType,
|
||||
folderId,
|
||||
generalFeatures,
|
||||
settingsFeatures,
|
||||
actionsFeatures,
|
||||
@@ -324,6 +332,7 @@ export default function EmbedPlaygroundPage() {
|
||||
setMode('create');
|
||||
setEnvelopeId('');
|
||||
setEnvelopeType('DOCUMENT');
|
||||
setFolderId('');
|
||||
setIframeSrc(null);
|
||||
setMessages([]);
|
||||
setTokenError(null);
|
||||
@@ -422,19 +431,34 @@ export default function EmbedPlaygroundPage() {
|
||||
</div>
|
||||
|
||||
{mode === 'create' && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Envelope Type
|
||||
</label>
|
||||
<select
|
||||
value={envelopeType}
|
||||
onChange={(e) => setEnvelopeType(e.target.value as 'DOCUMENT' | 'TEMPLATE')}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
>
|
||||
<option value="DOCUMENT">Document</option>
|
||||
<option value="TEMPLATE">Template</option>
|
||||
</select>
|
||||
</div>
|
||||
<>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Envelope Type
|
||||
</label>
|
||||
<select
|
||||
value={envelopeType}
|
||||
onChange={(e) => setEnvelopeType(e.target.value as 'DOCUMENT' | 'TEMPLATE')}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
>
|
||||
<option value="DOCUMENT">Document</option>
|
||||
<option value="TEMPLATE">Template</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Folder ID (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={folderId}
|
||||
onChange={(e) => setFolderId(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
placeholder="folder cuid"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && (
|
||||
|
||||
@@ -358,7 +358,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
publicDescription: '',
|
||||
userId: tokenUserId,
|
||||
teamId: tokenTeamId,
|
||||
folderId: null,
|
||||
folderId: embedAuthoringOptions?.folderId ?? null,
|
||||
documentMeta: {
|
||||
id: '',
|
||||
...defaultDocumentMeta,
|
||||
|
||||
@@ -59,20 +59,21 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId: result.userId,
|
||||
teamId: result.teamId,
|
||||
});
|
||||
|
||||
const envelope = await getEditorEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id,
|
||||
},
|
||||
type: null,
|
||||
userId: result.userId,
|
||||
teamId: result.teamId,
|
||||
}).catch(() => null);
|
||||
const [settings, envelope] = await Promise.all([
|
||||
getTeamSettings({
|
||||
userId: result.userId,
|
||||
teamId: result.teamId,
|
||||
}),
|
||||
getEditorEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id,
|
||||
},
|
||||
type: null,
|
||||
userId: result.userId,
|
||||
teamId: result.teamId,
|
||||
}).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!envelope) {
|
||||
throw redirect(`/embed/v2/authoring/error/not-found`);
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"lucide-react": "^0.554.0",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"nuqs": "^2.8.9",
|
||||
"papaparse": "^5.5.3",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
@@ -105,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.8.0"
|
||||
"version": "2.8.1"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
|
||||
import {
|
||||
type DocumentDataType,
|
||||
DocumentStatus,
|
||||
type EnvelopeType,
|
||||
type TemplateType,
|
||||
} from '@prisma/client';
|
||||
import { EnvelopeType as EnvelopeTypeEnum, TemplateType as TemplateTypeEnum } from '@prisma/client';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { type Context } from 'hono';
|
||||
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
|
||||
@@ -79,3 +87,63 @@ export const handleEnvelopeItemFileRequest = async ({
|
||||
|
||||
return c.body(file);
|
||||
};
|
||||
|
||||
type CheckEnvelopeFileAccessOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
envelopeType: EnvelopeType;
|
||||
templateType: TemplateType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a user has access to an envelope's file.
|
||||
*
|
||||
* First checks team membership. If that fails and the envelope is an
|
||||
* ORGANISATION template (not a document), falls back to checking whether
|
||||
* the user belongs to any team in the same organisation.
|
||||
*/
|
||||
export const checkEnvelopeFileAccess = async ({
|
||||
userId,
|
||||
teamId,
|
||||
envelopeType,
|
||||
templateType,
|
||||
}: CheckEnvelopeFileAccessOptions): Promise<boolean> => {
|
||||
const team = await getTeamById({ userId, teamId }).catch(() => null);
|
||||
|
||||
if (team) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
envelopeType === EnvelopeTypeEnum.TEMPLATE &&
|
||||
templateType === TemplateTypeEnum.ORGANISATION
|
||||
) {
|
||||
const orgAccess = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
organisation: {
|
||||
teams: {
|
||||
some: {
|
||||
teamGroups: {
|
||||
some: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: { userId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return orgAccess !== null;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -6,13 +6,12 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { handleEnvelopeItemFileRequest } from './files.helpers';
|
||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
|
||||
import {
|
||||
type TGetPresignedPostUrlResponse,
|
||||
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
||||
@@ -119,16 +118,14 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
return c.json({ error: 'Envelope item not found' }, 404);
|
||||
}
|
||||
|
||||
const team = await getTeamById({
|
||||
userId: userId,
|
||||
const hasAccess = await checkEnvelopeFileAccess({
|
||||
userId,
|
||||
teamId: envelope.teamId,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
return null;
|
||||
envelopeType: envelope.type,
|
||||
templateType: envelope.templateType,
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
if (!hasAccess) {
|
||||
return c.json(
|
||||
{ error: 'User does not have access to the team that this envelope is associated with' },
|
||||
403,
|
||||
@@ -187,16 +184,14 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
return c.json({ error: 'Envelope item not found' }, 404);
|
||||
}
|
||||
|
||||
const team = await getTeamById({
|
||||
const hasDownloadAccess = await checkEnvelopeFileAccess({
|
||||
userId: session.user.id,
|
||||
teamId: envelope.teamId,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
return null;
|
||||
envelopeType: envelope.type,
|
||||
templateType: envelope.templateType,
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
if (!hasDownloadAccess) {
|
||||
return c.json(
|
||||
{ error: 'User does not have access to the team that this envelope is associated with' },
|
||||
403,
|
||||
|
||||
@@ -5,13 +5,13 @@ import { z } from 'zod';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document';
|
||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../../router';
|
||||
import { checkEnvelopeFileAccess } from '../files.helpers';
|
||||
|
||||
const route = new Hono<HonoEnv>();
|
||||
|
||||
@@ -67,7 +67,9 @@ route.get(
|
||||
envelope: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
teamId: true,
|
||||
templateType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -78,12 +80,14 @@ route.get(
|
||||
}
|
||||
|
||||
// Check whether the user has access to the document.
|
||||
const team = await getTeamById({
|
||||
const hasAccess = await checkEnvelopeFileAccess({
|
||||
userId,
|
||||
teamId: envelopeItem.envelope.teamId,
|
||||
}).catch(() => null);
|
||||
envelopeType: envelopeItem.envelope.type,
|
||||
templateType: envelopeItem.envelope.templateType,
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
if (!hasAccess) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const baseConfig = require('@documenso/tailwind-config');
|
||||
const baseConfig = require('@documenso/ui/tailwind.config.cjs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
presets: [baseConfig],
|
||||
content: [
|
||||
...baseConfig.content,
|
||||
'./app/**/*.{ts,tsx}',
|
||||
`${path.join(require.resolve('@documenso/ui'), '..')}/components/**/*.{ts,tsx}`,
|
||||
`${path.join(require.resolve('@documenso/ui'), '..')}/icons/**/*.{ts,tsx}`,
|
||||
|
||||
@@ -72,6 +72,8 @@ export default defineConfig({
|
||||
'playwright',
|
||||
'playwright-core',
|
||||
'@playwright/browser-chromium',
|
||||
'lightningcss',
|
||||
'fsevents',
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
|
||||
@@ -254,3 +254,4 @@ Here's a markdown table documenting all the provided environment variables:
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). |
|
||||
|
||||
@@ -59,6 +59,7 @@ services:
|
||||
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
|
||||
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
|
||||
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
|
||||
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
|
||||
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
|
||||
|
||||
Generated
+433
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.1",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.2.12",
|
||||
"@libpdf/core": "^0.3.3",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
@@ -31,6 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@datadog/pprof": "^5.13.5",
|
||||
"@lingui/cli": "^5.6.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@trpc/client": "11.8.1",
|
||||
@@ -651,7 +652,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.1",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -692,6 +693,7 @@
|
||||
"lucide-react": "^0.554.0",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"nuqs": "^2.8.9",
|
||||
"papaparse": "^5.5.3",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
@@ -2852,6 +2854,32 @@
|
||||
"node": ">=v18"
|
||||
}
|
||||
},
|
||||
"node_modules/@datadog/pprof": {
|
||||
"version": "5.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-5.13.5.tgz",
|
||||
"integrity": "sha512-W0dvo91ff2EMQI9Vhv8PNM+w1ZWuClm1pBVdLB6y0bMB3+E+wEGg0VD1iNJxsuPbwDt5+yV0u3e4WkqK12Lzlg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"node-gyp-build": "<4.0",
|
||||
"pprof-format": "^2.2.1",
|
||||
"source-map": "^0.7.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@datadog/pprof/node_modules/source-map": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@documenso/api": {
|
||||
"resolved": "packages/api",
|
||||
"link": true
|
||||
@@ -4645,9 +4673,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@libpdf/core": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.12.tgz",
|
||||
"integrity": "sha512-z22SyNEXa8YsCJarJBkgQv4SvvDn0Opw21cNQOQ0Xax9Ys1qjpAyVTSjlGExYVI8bT9b02VNy+nsOcJ79SzsQg==",
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.3.3.tgz",
|
||||
"integrity": "sha512-MoyjZ00RPJ1sDgFooerCw3WqXzaaufHFkBYZv6v8qKUaIljdS2MYm1OYvcyV+V1qplo+o8qc0X+0p/JipzJ/Jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
@@ -17099,6 +17127,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
@@ -17396,6 +17435,13 @@
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -18181,6 +18227,141 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"chai": "^6.2.1",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.0.18",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vvo/tzdb": {
|
||||
"version": "6.196.0",
|
||||
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.196.0.tgz",
|
||||
@@ -18621,6 +18802,16 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@@ -19355,6 +19546,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -23608,6 +23809,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@@ -29555,6 +29766,18 @@
|
||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.9.0.tgz",
|
||||
"integrity": "sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-html-better-parser": {
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
|
||||
@@ -29637,6 +29860,49 @@
|
||||
"js-sdsl": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nuqs": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.9.tgz",
|
||||
"integrity": "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/franky47"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@remix-run/react": ">=2",
|
||||
"@tanstack/react-router": "^1",
|
||||
"next": ">=14.2.0",
|
||||
"react": ">=18.2.0 || ^19.0.0-0",
|
||||
"react-router": "^5 || ^6 || ^7",
|
||||
"react-router-dom": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@remix-run/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@tanstack/react-router": {
|
||||
"optional": true
|
||||
},
|
||||
"next": {
|
||||
"optional": true
|
||||
},
|
||||
"react-router": {
|
||||
"optional": true
|
||||
},
|
||||
"react-router-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nuqs/node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nypm": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.0.tgz",
|
||||
@@ -29799,6 +30065,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
@@ -31215,6 +31492,13 @@
|
||||
"node": ">=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pprof-format": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.2.1.tgz",
|
||||
"integrity": "sha512-p4tVN7iK19ccDqQv8heyobzUmbHyds4N2FI6aBMcXz6y99MglTWDxIyhFkNaLeEXs6IFUEzT0zya0icbSLLY0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.28.2",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
|
||||
@@ -33456,6 +33740,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -33836,6 +34127,13 @@
|
||||
"integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/start-server-and-test": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.3.tgz",
|
||||
@@ -33869,6 +34167,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stdin-discarder": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
||||
@@ -34905,6 +35210,13 @@
|
||||
"esm": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
@@ -34930,6 +35242,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
@@ -36139,6 +36461,91 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"@vitest/runner": "4.0.18",
|
||||
"@vitest/snapshot": "4.0.18",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"expect-type": "^1.2.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^3.10.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/browser-preview": "4.0.18",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
@@ -36339,6 +36746,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -37133,7 +37557,8 @@
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "1.56.1",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/pg": "^8.15.6"
|
||||
"@types/pg": "^8.15.6",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"packages/prettier-config": {
|
||||
|
||||
+5
-4
@@ -5,15 +5,15 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.1",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
"dev": "npm run translate:compile && turbo run dev --filter=@documenso/remix",
|
||||
"dev:remix": "npm run translate:compile && turbo run dev --filter=@documenso/remix",
|
||||
"dev:docs": "turbo run dev --filter=@documenso/documentation",
|
||||
"dev:docs": "turbo run dev --filter=@documenso/docs",
|
||||
"dev:openpage-api": "turbo run dev --filter=@documenso/openpage-api",
|
||||
"start": "turbo run start --filter=@documenso/remix --filter=@documenso/documentation --filter=@documenso/openpage-api",
|
||||
"start": "turbo run start --filter=@documenso/remix --filter=@documenso/docs --filter=@documenso/openpage-api",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||
@@ -49,6 +49,7 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@datadog/pprof": "^5.13.5",
|
||||
"@lingui/cli": "^5.6.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@trpc/client": "11.8.1",
|
||||
@@ -86,7 +87,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.2.12",
|
||||
"@libpdf/core": "^0.3.3",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
|
||||
@@ -1389,7 +1389,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
throw new Error('Invalid page number');
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
const recipient = await tx.recipient.findFirst({
|
||||
where: {
|
||||
id: Number(recipientId),
|
||||
envelopeId: envelope.id,
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
const TEST_CSS_VARS = {
|
||||
background: '#ff0000',
|
||||
primary: '#00ff00',
|
||||
radius: '1rem',
|
||||
};
|
||||
|
||||
/**
|
||||
* A unique CSS selector used for asserting raw CSS injection.
|
||||
*/
|
||||
const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
|
||||
|
||||
/**
|
||||
* Expected HSL values after conversion by `toNativeCssVars`:
|
||||
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
|
||||
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
|
||||
*/
|
||||
const EXPECTED_CSS_VARS = {
|
||||
'--background': '0 100 50',
|
||||
'--primary': '120 100 50',
|
||||
'--radius': '1rem',
|
||||
};
|
||||
|
||||
const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: userId },
|
||||
include: { organisationClaim: true },
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: {
|
||||
flags: {
|
||||
allowLegacyEnvelopes: true,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The default background color from the theme before any CSS injection.
|
||||
*
|
||||
* The theme default `--background: 0 0% 100%` resolves to hsl(0, 0%, 100%) which is white.
|
||||
*/
|
||||
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
|
||||
|
||||
/**
|
||||
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
|
||||
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
|
||||
*/
|
||||
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
|
||||
|
||||
const assertCssNotInjected = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const cssState = await page.evaluate(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
return {
|
||||
background: rootStyle.getPropertyValue('--background'),
|
||||
primary: rootStyle.getPropertyValue('--primary'),
|
||||
radius: rootStyle.getPropertyValue('--radius'),
|
||||
bodyBgColor,
|
||||
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
|
||||
el.innerHTML.includes('.e2e-css-test-marker'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CSS custom properties should not be set on the inline style.
|
||||
expect(cssState.background).toBe('');
|
||||
expect(cssState.primary).toBe('');
|
||||
expect(cssState.radius).toBe('');
|
||||
|
||||
// No raw CSS style tag should be injected.
|
||||
expect(cssState.hasInjectedStyle).toBe(false);
|
||||
|
||||
// The body should still use the default theme background color.
|
||||
expect(cssState.bodyBgColor).toBe(DEFAULT_BODY_BG_COLOR);
|
||||
};
|
||||
|
||||
const assertCssInjected = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const cssState = await page.evaluate(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
return {
|
||||
background: rootStyle.getPropertyValue('--background'),
|
||||
primary: rootStyle.getPropertyValue('--primary'),
|
||||
radius: rootStyle.getPropertyValue('--radius'),
|
||||
bodyBgColor,
|
||||
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
|
||||
el.innerHTML.includes('.e2e-css-test-marker'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CSS custom properties should be set to the expected HSL values.
|
||||
expect(cssState.background).toBe(EXPECTED_CSS_VARS['--background']);
|
||||
expect(cssState.primary).toBe(EXPECTED_CSS_VARS['--primary']);
|
||||
expect(cssState.radius).toBe(EXPECTED_CSS_VARS['--radius']);
|
||||
|
||||
// Raw CSS style tag should be injected.
|
||||
expect(cssState.hasInjectedStyle).toBe(true);
|
||||
|
||||
// The body background should reflect the injected --background value (red).
|
||||
expect(cssState.bodyBgColor).toBe(INJECTED_BODY_BG_COLOR);
|
||||
};
|
||||
|
||||
const assertDarkModeDisabled = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const hasDarkModeDisabled = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark-mode-disabled'),
|
||||
);
|
||||
|
||||
expect(hasDarkModeDisabled).toBe(true);
|
||||
};
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'create',
|
||||
tokenNamePrefix: 'e2e-embed-css',
|
||||
css: TEST_RAW_CSS,
|
||||
cssVars: TEST_CSS_VARS,
|
||||
darkModeDisabled: true,
|
||||
});
|
||||
|
||||
// darkModeDisabled is applied regardless of the flag.
|
||||
await assertDarkModeDisabled(surface);
|
||||
|
||||
// Flag is disabled by default so CSS should NOT be injected.
|
||||
await assertCssNotInjected(surface);
|
||||
|
||||
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
|
||||
await enableEmbedAuthoringWhiteLabel(surface.userId);
|
||||
|
||||
// Reload the page to re-run the layout loader with the updated claim.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// CSS should now be injected.
|
||||
await assertCssInjected(surface);
|
||||
|
||||
// darkModeDisabled should still be applied after reload.
|
||||
await assertDarkModeDisabled(surface);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-css',
|
||||
css: TEST_RAW_CSS,
|
||||
cssVars: TEST_CSS_VARS,
|
||||
darkModeDisabled: true,
|
||||
});
|
||||
|
||||
// darkModeDisabled is applied regardless of the flag.
|
||||
await assertDarkModeDisabled(surface);
|
||||
|
||||
// Flag is disabled by default so CSS should NOT be injected.
|
||||
await assertCssNotInjected(surface);
|
||||
|
||||
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
|
||||
await enableEmbedAuthoringWhiteLabel(surface.userId);
|
||||
|
||||
// Reload the page to re-run the layout loader with the updated claim.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// CSS should now be injected.
|
||||
await assertCssInjected(surface);
|
||||
|
||||
// darkModeDisabled should still be applied after reload.
|
||||
await assertDarkModeDisabled(surface);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
addEnvelopeItemPdf,
|
||||
createEmbeddedEnvelopeCreateHash,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
const TEST_CSS_VARS = {
|
||||
background: '#ff0000',
|
||||
primary: '#00ff00',
|
||||
radius: '1rem',
|
||||
};
|
||||
|
||||
/**
|
||||
* A unique CSS selector used for asserting raw CSS injection.
|
||||
*/
|
||||
const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
|
||||
|
||||
/**
|
||||
* Expected HSL values after conversion by `toNativeCssVars`:
|
||||
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
|
||||
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
|
||||
*/
|
||||
const EXPECTED_CSS_VARS = {
|
||||
'--background': '0 100 50',
|
||||
'--primary': '120 100 50',
|
||||
'--radius': '1rem',
|
||||
};
|
||||
|
||||
const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: userId },
|
||||
include: { organisationClaim: true },
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: {
|
||||
flags: {
|
||||
allowLegacyEnvelopes: true,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The default background color from the theme before any CSS injection.
|
||||
*
|
||||
* The theme default `--background: 0 0% 100%` resolves to hsl(0, 0%, 100%) which is white.
|
||||
*/
|
||||
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
|
||||
|
||||
/**
|
||||
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
|
||||
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
|
||||
*/
|
||||
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
|
||||
|
||||
const assertCssNotInjected = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const cssState = await page.evaluate(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
return {
|
||||
background: rootStyle.getPropertyValue('--background'),
|
||||
primary: rootStyle.getPropertyValue('--primary'),
|
||||
radius: rootStyle.getPropertyValue('--radius'),
|
||||
bodyBgColor,
|
||||
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
|
||||
el.innerHTML.includes('.e2e-css-test-marker'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CSS custom properties should not be set on the inline style.
|
||||
expect(cssState.background).toBe('');
|
||||
expect(cssState.primary).toBe('');
|
||||
expect(cssState.radius).toBe('');
|
||||
|
||||
// No raw CSS style tag should be injected.
|
||||
expect(cssState.hasInjectedStyle).toBe(false);
|
||||
|
||||
// The body should still use the default theme background color.
|
||||
expect(cssState.bodyBgColor).toBe(DEFAULT_BODY_BG_COLOR);
|
||||
};
|
||||
|
||||
const assertCssInjected = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const cssState = await page.evaluate(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
return {
|
||||
background: rootStyle.getPropertyValue('--background'),
|
||||
primary: rootStyle.getPropertyValue('--primary'),
|
||||
radius: rootStyle.getPropertyValue('--radius'),
|
||||
bodyBgColor,
|
||||
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
|
||||
el.innerHTML.includes('.e2e-css-test-marker'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CSS custom properties should be set to the expected HSL values.
|
||||
expect(cssState.background).toBe(EXPECTED_CSS_VARS['--background']);
|
||||
expect(cssState.primary).toBe(EXPECTED_CSS_VARS['--primary']);
|
||||
expect(cssState.radius).toBe(EXPECTED_CSS_VARS['--radius']);
|
||||
|
||||
// Raw CSS style tag should be injected.
|
||||
expect(cssState.hasInjectedStyle).toBe(true);
|
||||
|
||||
// The body background should reflect the injected --background value (red).
|
||||
expect(cssState.bodyBgColor).toBe(INJECTED_BODY_BG_COLOR);
|
||||
};
|
||||
|
||||
const assertDarkModeDisabled = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const hasDarkModeDisabled = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark-mode-disabled'),
|
||||
);
|
||||
|
||||
expect(hasDarkModeDisabled).toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open an embedded create editor with a pre-seeded user. This is needed for folderId
|
||||
* tests where the folder must exist before the hash is built.
|
||||
*/
|
||||
const openEmbeddedCreateWithUser = async (
|
||||
page: Page,
|
||||
user: { id: number; email: string; name: string | null },
|
||||
team: { id: number },
|
||||
options: { folderId?: string; tokenNamePrefix?: string },
|
||||
): Promise<TEnvelopeEditorSurface> => {
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: `${options.tokenNamePrefix ?? 'e2e-embed-folder'}-document`,
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// Exchange API token for presign token.
|
||||
const response = await page
|
||||
.context()
|
||||
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
if (typeof data !== 'object' || data === null || !('token' in data)) {
|
||||
throw new Error(`Unexpected presign response: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const presignToken = data.token;
|
||||
|
||||
if (typeof presignToken !== 'string' || presignToken.length === 0) {
|
||||
throw new Error(`Unexpected presign response: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const hash = createEmbeddedEnvelopeCreateHash({
|
||||
envelopeType: 'DOCUMENT',
|
||||
folderId: options.folderId,
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(presignToken)}#${hash}`,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: true,
|
||||
envelopeType: 'DOCUMENT',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to set an external ID on the envelope via the settings dialog so we can
|
||||
* look it up in the database after creation.
|
||||
*/
|
||||
const setExternalIdViaSettings = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await getEnvelopeEditorSettingsTrigger(surface.root).click();
|
||||
await expect(surface.root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal setup for an embedded create flow: upload a PDF and add a recipient
|
||||
* so the "Create Document" button works.
|
||||
*/
|
||||
const setupMinimalEnvelope = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await addEnvelopeItemPdf(surface.root);
|
||||
await setRecipientEmail(surface.root, 0, `${nanoid()}@test.documenso.com`);
|
||||
await setExternalIdViaSettings(surface, externalId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Click "Create Document" and expect a failure toast instead of the success heading.
|
||||
*/
|
||||
const expectCreateToFail = async (surface: TEnvelopeEditorSurface) => {
|
||||
const actionButtonName =
|
||||
surface.envelopeType === 'DOCUMENT' ? 'Create Document' : 'Create Template';
|
||||
|
||||
await surface.root.getByRole('button', { name: actionButtonName }).click();
|
||||
await expectToastTextToBeVisible(surface.root, 'Failed to create document');
|
||||
};
|
||||
|
||||
test.describe('embedded create - folderId', () => {
|
||||
test('creates envelope in the specified folder when folderId is provided', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const folder = await prisma.folder.create({
|
||||
data: {
|
||||
name: 'E2E Document Folder',
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
const surface = await openEmbeddedCreateWithUser(page, user, team, {
|
||||
folderId: folder.id,
|
||||
tokenNamePrefix: 'e2e-embed-folder',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-create-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.folderId).toBe(folder.id);
|
||||
});
|
||||
|
||||
test('creates envelope in root folder when no folderId is provided', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'create',
|
||||
tokenNamePrefix: 'e2e-embed-folder-none',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-root-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects creation when folderId has wrong folder type', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a TEMPLATE folder but attempt to create a DOCUMENT envelope in it.
|
||||
const templateFolder = await prisma.folder.create({
|
||||
data: {
|
||||
name: 'E2E Template Folder',
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
type: 'TEMPLATE',
|
||||
},
|
||||
});
|
||||
|
||||
const surface = await openEmbeddedCreateWithUser(page, user, team, {
|
||||
folderId: templateFolder.id,
|
||||
tokenNamePrefix: 'e2e-embed-folder-wrong-type',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-wrong-type-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await expectCreateToFail(surface);
|
||||
|
||||
// Verify no envelope was created with this externalId.
|
||||
const count = await prisma.envelope.count({ where: { externalId } });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects creation when folderId belongs to another team', async ({ page }) => {
|
||||
// Create the user who will use the embedded editor.
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a second user/team that owns the folder.
|
||||
const { user: otherUser, team: otherTeam } = await seedUser();
|
||||
|
||||
const otherTeamFolder = await prisma.folder.create({
|
||||
data: {
|
||||
name: 'E2E Other Team Folder',
|
||||
teamId: otherTeam.id,
|
||||
userId: otherUser.id,
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
// Open the embedded editor for the first user but pass the folder from the other team.
|
||||
const surface = await openEmbeddedCreateWithUser(page, user, team, {
|
||||
folderId: otherTeamFolder.id,
|
||||
tokenNamePrefix: 'e2e-embed-folder-no-perm',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-no-perm-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await expectCreateToFail(surface);
|
||||
|
||||
// Verify no envelope was created with this externalId.
|
||||
const count = await prisma.envelope.count({ where: { externalId } });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects creation when folderId does not exist', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'create',
|
||||
tokenNamePrefix: 'e2e-embed-folder-nonexistent',
|
||||
folderId: 'nonexistent-folder-id',
|
||||
});
|
||||
|
||||
const externalId = `e2e-folder-nonexistent-${nanoid()}`;
|
||||
|
||||
await setupMinimalEnvelope(surface, externalId);
|
||||
await expectCreateToFail(surface);
|
||||
|
||||
// Verify no envelope was created with this externalId.
|
||||
const count = await prisma.envelope.count({ where: { externalId } });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'create',
|
||||
tokenNamePrefix: 'e2e-embed-css',
|
||||
css: TEST_RAW_CSS,
|
||||
cssVars: TEST_CSS_VARS,
|
||||
darkModeDisabled: true,
|
||||
});
|
||||
|
||||
// darkModeDisabled is applied regardless of the flag.
|
||||
await assertDarkModeDisabled(surface);
|
||||
|
||||
// Flag is disabled by default so CSS should NOT be injected.
|
||||
await assertCssNotInjected(surface);
|
||||
|
||||
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
|
||||
await enableEmbedAuthoringWhiteLabel(surface.userId);
|
||||
|
||||
// Reload the page to re-run the layout loader with the updated claim.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// CSS should now be injected.
|
||||
await assertCssInjected(surface);
|
||||
|
||||
// darkModeDisabled should still be applied after reload.
|
||||
await assertDarkModeDisabled(surface);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-css',
|
||||
css: TEST_RAW_CSS,
|
||||
cssVars: TEST_CSS_VARS,
|
||||
darkModeDisabled: true,
|
||||
});
|
||||
|
||||
// darkModeDisabled is applied regardless of the flag.
|
||||
await assertDarkModeDisabled(surface);
|
||||
|
||||
// Flag is disabled by default so CSS should NOT be injected.
|
||||
await assertCssNotInjected(surface);
|
||||
|
||||
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
|
||||
await enableEmbedAuthoringWhiteLabel(surface.userId);
|
||||
|
||||
// Reload the page to re-run the layout loader with the updated claim.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// CSS should now be injected.
|
||||
await assertCssInjected(surface);
|
||||
|
||||
// darkModeDisabled should still be applied after reload.
|
||||
await assertDarkModeDisabled(surface);
|
||||
});
|
||||
});
|
||||
@@ -48,14 +48,16 @@ const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
|
||||
export const createEmbeddedEnvelopeCreateHash = ({
|
||||
envelopeType,
|
||||
externalId,
|
||||
folderId,
|
||||
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: { envelopeType: TEnvelopeEditorType } & TEmbeddedHashCommonOptions) => {
|
||||
}: { envelopeType: TEnvelopeEditorType; folderId?: string } & TEmbeddedHashCommonOptions) => {
|
||||
return encodeEmbeddedOptions({
|
||||
externalId,
|
||||
type: envelopeType,
|
||||
folderId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
@@ -139,6 +141,7 @@ type OpenEmbeddedEnvelopeEditorOptions = {
|
||||
mode?: 'create' | 'edit';
|
||||
tokenNamePrefix?: string;
|
||||
externalId?: string;
|
||||
folderId?: string;
|
||||
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||
css?: string;
|
||||
cssVars?: Record<string, string>;
|
||||
@@ -152,6 +155,7 @@ export const openEmbeddedEnvelopeEditor = async (
|
||||
mode = 'create',
|
||||
tokenNamePrefix = 'e2e-embed',
|
||||
externalId,
|
||||
folderId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
@@ -205,6 +209,7 @@ export const openEmbeddedEnvelopeEditor = async (
|
||||
const hash = createEmbeddedEnvelopeCreateHash({
|
||||
envelopeType,
|
||||
externalId,
|
||||
folderId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
|
||||
@@ -202,6 +202,15 @@ test.describe('PDF Viewer Rendering', () => {
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should not return 500 for invalid QR share token', async ({ page }) => {
|
||||
const response = await page.request.get('/share/qr_invalid_token_for_regression_check', {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers().location).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Embed Pages', () => {
|
||||
|
||||
@@ -136,7 +136,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
await page.getByRole('option', { name: 'Type' }).click();
|
||||
|
||||
@@ -163,7 +163,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
@@ -187,7 +187,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('combobox').nth(7).click();
|
||||
await page.getByRole('option', { name: 'Europe/London' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
@@ -247,7 +247,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
const newExternalId = 'MULTI-TEST-123';
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('combobox').nth(7).click();
|
||||
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to set up the standard two-team-one-org scenario:
|
||||
*
|
||||
* - One organisation with two teams (teamA and teamB).
|
||||
* - ownerA owns both the org and teamA.
|
||||
* - memberB is only a member of teamB (no relation to teamA).
|
||||
* - An ORGANISATION template is created on teamA.
|
||||
*/
|
||||
const seedOrgTemplateScenario = async () => {
|
||||
const { user: ownerA, organisation, team: teamA } = await seedUser();
|
||||
|
||||
const teamBUrl = `team-b-${nanoid()}`;
|
||||
|
||||
await createTeam({
|
||||
userId: ownerA.id,
|
||||
teamName: `Team B ${teamBUrl}`,
|
||||
teamUrl: teamBUrl,
|
||||
organisationId: organisation.id,
|
||||
inheritMembers: false,
|
||||
});
|
||||
|
||||
const teamB = await prisma.team.findFirstOrThrow({
|
||||
where: { url: teamBUrl },
|
||||
});
|
||||
|
||||
// memberB is only added to teamB, not teamA.
|
||||
const memberB = await seedTeamMember({
|
||||
teamId: teamB.id,
|
||||
role: 'MEMBER',
|
||||
});
|
||||
|
||||
const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Org Template ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
},
|
||||
});
|
||||
|
||||
return { ownerA, organisation, teamA, teamB, memberB, orgTemplate };
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to make tRPC queries via the authenticated page context.
|
||||
*/
|
||||
const trpcQuery = async (
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: Record<string, unknown>,
|
||||
teamId?: number,
|
||||
) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (teamId) {
|
||||
headers['x-team-id'] = teamId.toString();
|
||||
}
|
||||
|
||||
const res = await page.context().request.get(url, { headers });
|
||||
|
||||
return { res, json: res.ok() ? await res.json() : null };
|
||||
};
|
||||
|
||||
const trpcMutation = async (
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: Record<string, unknown>,
|
||||
teamId?: number,
|
||||
) => {
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
headers['x-team-id'] = teamId.toString();
|
||||
}
|
||||
|
||||
const res = await page.context().request.post(url, {
|
||||
data: JSON.stringify({ json: input }),
|
||||
headers,
|
||||
});
|
||||
|
||||
return { res, json: res.ok() ? await res.json() : null };
|
||||
};
|
||||
|
||||
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - UI Tabs', () => {
|
||||
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
|
||||
const { ownerA, teamA } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: ownerA.email,
|
||||
redirectPath: `/t/${teamA.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show tabs for personal organisations', async ({ page }) => {
|
||||
const { user, team } = await seedUser({ isPersonalOrganisation: true });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Listing', () => {
|
||||
test('should list org templates from other teams under the Organisation tab', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberB.email,
|
||||
redirectPath: `/t/${teamB.url}/templates`,
|
||||
});
|
||||
|
||||
// Team tab should show 0 (memberB has no templates on teamB).
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
|
||||
// Switch to Organisation tab.
|
||||
await page.getByTestId('template-tab-organisation').click();
|
||||
|
||||
// Should see the org template from teamA.
|
||||
await expect(page.getByText(orgTemplate.title)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show private templates from other teams under Organisation tab', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
// Create a private template on teamA — should NOT appear in org tab.
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Private Template ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberB.email,
|
||||
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
|
||||
});
|
||||
|
||||
await expect(page.getByText(privateTemplate.title)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI: Organisation Template Detail Page ───────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Detail Page', () => {
|
||||
test('should show org template detail but hide edit/delete actions', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberB.email,
|
||||
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
|
||||
});
|
||||
|
||||
// Click into the org template.
|
||||
await page.getByText(orgTemplate.title).click();
|
||||
|
||||
// Should see the template title.
|
||||
await expect(page.getByRole('heading', { name: orgTemplate.title })).toBeVisible();
|
||||
|
||||
// Should see the Use button.
|
||||
await expect(page.getByRole('button', { name: 'Use' })).toBeVisible();
|
||||
|
||||
// Should NOT see the Edit Template button.
|
||||
await expect(page.getByRole('link', { name: 'Edit Template' })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API: findOrganisationTemplates ──────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - findOrganisationTemplates API', () => {
|
||||
test('should return org templates for a member of a sibling team', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res, json } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).toContain(orgTemplate.title);
|
||||
});
|
||||
|
||||
test('should not return private templates from sibling teams', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Private No Show ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { json } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).not.toContain(privateTemplate.title);
|
||||
});
|
||||
|
||||
test('should not return org templates to a user outside the organisation', async ({ page }) => {
|
||||
const { orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
// Create a completely separate user with their own org.
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const { json } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
outsiderTeam.id,
|
||||
);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).not.toContain(orgTemplate.title);
|
||||
});
|
||||
|
||||
test('should respect document visibility based on the viewer team role', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Visibility Everyone ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
visibility: 'EVERYONE',
|
||||
},
|
||||
});
|
||||
|
||||
const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Visibility Admin ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
visibility: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// memberB has role MEMBER on teamB — should only see EVERYONE visibility.
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { json } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).toContain(everyoneTemplate.title);
|
||||
expect(titles).not.toContain(adminOnlyTemplate.title);
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// ownerA has role ADMIN on teamA — should see both.
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const { json: adminJson } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamA.id,
|
||||
);
|
||||
|
||||
const adminTitles = adminJson.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(adminTitles).toContain(everyoneTemplate.title);
|
||||
expect(adminTitles).toContain(adminOnlyTemplate.title);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API: getOrganisationTemplateById ────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - getOrganisationTemplateById API', () => {
|
||||
test('should allow a sibling team member to fetch an org template', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res, json } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: orgTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(json.result.data.json.title).toBe(orgTemplate.title);
|
||||
});
|
||||
|
||||
test('should reject access from a user outside the organisation', async ({ page }) => {
|
||||
const { orgTemplate } = await seedOrgTemplateScenario();
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const { res } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: orgTemplate.id },
|
||||
outsiderTeam.id,
|
||||
);
|
||||
|
||||
// Should fail — outsider is not in the same org.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should reject fetching a private template via the org endpoint', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Private ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const { res } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: privateTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
// Should fail — template is PRIVATE, not ORGANISATION.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API: createDocumentFromTemplate with org template ───────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Use from different team', () => {
|
||||
test('should allow creating a document from an org template owned by a sibling team', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
// Add a recipient to the org template so we can use it.
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: 'recipient@test.documenso.com',
|
||||
name: 'Recipient',
|
||||
token: Math.random().toString().slice(2, 7),
|
||||
envelopeId: orgTemplate.id,
|
||||
},
|
||||
});
|
||||
|
||||
const orgTemplateWithRecipients = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: orgTemplate.id },
|
||||
include: { recipients: true },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const templateId = mapSecondaryIdToTemplateId(orgTemplateWithRecipients.secondaryId);
|
||||
|
||||
const { res } = await trpcMutation(
|
||||
page,
|
||||
'template.createDocumentFromTemplate',
|
||||
{
|
||||
templateId,
|
||||
recipients: orgTemplateWithRecipients.recipients.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
name: r.name ?? '',
|
||||
})),
|
||||
},
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adversarial: Cross-organisation access ──────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Adversarial', () => {
|
||||
test('should not allow accessing org template from a different organisation via getEnvelopeById', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { orgTemplate } = await seedOrgTemplateScenario();
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
// Try to fetch via the standard envelope.get endpoint.
|
||||
const { res } = await trpcQuery(
|
||||
page,
|
||||
'envelope.get',
|
||||
{ envelopeId: orgTemplate.id },
|
||||
outsiderTeam.id,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should not allow a sibling team member to fetch a private template via org endpoint', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Adversarial Private ${nanoid()}`,
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
// Attempt 1: Try via org template endpoint.
|
||||
const { res: orgRes } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: privateTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
expect(orgRes.ok()).toBeFalsy();
|
||||
|
||||
// Attempt 2: Try via standard envelope endpoint.
|
||||
const { res: envelopeRes } = await trpcQuery(
|
||||
page,
|
||||
'envelope.get',
|
||||
{ envelopeId: privateTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
expect(envelopeRes.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should not list org templates from a completely unrelated organisation', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create scenario A.
|
||||
const { orgTemplate: orgTemplateA } = await seedOrgTemplateScenario();
|
||||
|
||||
// Create scenario B (separate org, separate teams).
|
||||
const { memberB: memberFromOrgB, teamB: teamFromOrgB } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberFromOrgB.email });
|
||||
|
||||
const { json } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamFromOrgB.id,
|
||||
);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).not.toContain(orgTemplateA.title);
|
||||
});
|
||||
|
||||
test('should not allow unauthenticated access to org template endpoints', async ({ page }) => {
|
||||
const { orgTemplate, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
// No apiSignin — unauthenticated.
|
||||
|
||||
const { res: findRes } = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamB.id,
|
||||
);
|
||||
expect(findRes.ok()).toBeFalsy();
|
||||
expect(findRes.status()).toBe(401);
|
||||
|
||||
const { res: getRes } = await trpcQuery(
|
||||
page,
|
||||
'template.getOrganisationTemplateById',
|
||||
{ envelopeId: orgTemplate.id },
|
||||
teamB.id,
|
||||
);
|
||||
expect(getRes.ok()).toBeFalsy();
|
||||
expect(getRes.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should not return org template data via findTemplates (team endpoint)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
// The standard findTemplates endpoint should NOT include org templates from other teams.
|
||||
const { json } = await trpcQuery(
|
||||
page,
|
||||
'template.findTemplates',
|
||||
{ page: 1, perPage: 50 },
|
||||
teamB.id,
|
||||
);
|
||||
|
||||
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
|
||||
expect(titles).not.toContain(orgTemplate.title);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const AuthenticationErrorCode = {
|
||||
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
|
||||
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
|
||||
SignupDisabled: 'SIGNUP_DISABLED',
|
||||
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
|
||||
// IncorrectPassword: 'INCORRECT_PASSWORD',
|
||||
|
||||
@@ -3,8 +3,11 @@ import { OAuth2Client, decodeIdToken } from 'arctic';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie } from 'hono/cookie';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
|
||||
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
@@ -114,6 +117,24 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
return c.redirect(redirectPath, 302);
|
||||
}
|
||||
|
||||
// Check if signups are disabled.
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Check domain restriction for new SSO users.
|
||||
if (!isEmailDomainAllowedForSignup(email)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Handle new user.
|
||||
const createdUser = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HTTPException } from 'hono/http-exception';
|
||||
import { DateTime } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
|
||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
@@ -122,7 +123,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||
|
||||
if (is2faEnabled) {
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||
const isValid = await validateTwoFactorAuthentication({
|
||||
backupCode,
|
||||
totpCode,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
@@ -178,8 +183,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
throw new AppError('SIGNUP_DISABLED', {
|
||||
message: 'Signups are disabled.',
|
||||
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,6 +201,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
res: signupLimited,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEmailDomainAllowedForSignup(email)) {
|
||||
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await createUser({ name, email, password, signature }).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -111,41 +111,40 @@ export const createEmailDomain = async ({ domain, organisationId }: CreateEmailD
|
||||
data: privateKeyFlattened,
|
||||
});
|
||||
|
||||
const emailDomain = await prisma.$transaction(async (tx) => {
|
||||
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
|
||||
if (err.name === 'AlreadyExistsException') {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Domain already exists in SES',
|
||||
});
|
||||
}
|
||||
// Verify domain with SES outside a transaction to avoid holding a
|
||||
// connection open during the external API call.
|
||||
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
|
||||
if (err.name === 'AlreadyExistsException') {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Domain already exists in SES',
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Create email domain record.
|
||||
return await tx.emailDomain.create({
|
||||
data: {
|
||||
id: generateDatabaseId('email_domain'),
|
||||
domain,
|
||||
status: EmailDomainStatus.PENDING,
|
||||
organisationId,
|
||||
selector: recordName,
|
||||
publicKey: publicKeyFlattened,
|
||||
privateKey: encryptedPrivateKey,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
organisationId: true,
|
||||
domain: true,
|
||||
selector: true,
|
||||
publicKey: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastVerifiedAt: true,
|
||||
emails: true,
|
||||
},
|
||||
});
|
||||
const emailDomain = await prisma.emailDomain.create({
|
||||
data: {
|
||||
id: generateDatabaseId('email_domain'),
|
||||
domain,
|
||||
status: EmailDomainStatus.PENDING,
|
||||
organisationId,
|
||||
selector: recordName,
|
||||
publicKey: publicKeyFlattened,
|
||||
privateKey: encryptedPrivateKey,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
organisationId: true,
|
||||
domain: true,
|
||||
selector: true,
|
||||
publicKey: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastVerifiedAt: true,
|
||||
emails: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -103,61 +103,59 @@ export const linkOrganisationAccount = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Link the user if not linked yet.
|
||||
if (!userAlreadyLinked) {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: oauthConfig.providerAccountId,
|
||||
access_token: oauthConfig.accessToken,
|
||||
expires_at: oauthConfig.expiresAt,
|
||||
token_type: 'Bearer',
|
||||
id_token: oauthConfig.idToken,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
|
||||
},
|
||||
});
|
||||
|
||||
// If account already exists in an unverified state, remove the password to ensure
|
||||
// they cannot sign in using that method since we cannot confirm the password
|
||||
// was set by the user.
|
||||
if (!user.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
// Todo: (RR7) Will need to update the "password" account after the migration.
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the user to the organisation if they are not already a member.
|
||||
if (!organisationMember) {
|
||||
await addUserToOrganisation({
|
||||
// Link the user if not linked yet.
|
||||
if (!userAlreadyLinked) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: oauthConfig.providerAccountId,
|
||||
access_token: oauthConfig.accessToken,
|
||||
expires_at: oauthConfig.expiresAt,
|
||||
token_type: 'Bearer',
|
||||
id_token: oauthConfig.idToken,
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole:
|
||||
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
},
|
||||
});
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
|
||||
},
|
||||
});
|
||||
|
||||
// If account already exists in an unverified state, remove the password to ensure
|
||||
// they cannot sign in using that method since we cannot confirm the password
|
||||
// was set by the user.
|
||||
if (!user.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
// Todo: (RR7) Will need to update the "password" account after the migration.
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Only add the user to the organisation if they are not already a member.
|
||||
// Done outside the above transaction to avoid nested transactions and
|
||||
// holding connections during the job trigger network I/O.
|
||||
if (!organisationMember) {
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope
|
||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
@@ -346,15 +346,8 @@ export const EnvelopeEditorProvider = ({
|
||||
};
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
(recipientId: number) => {
|
||||
const recipientIndex = envelope.recipients.findIndex(
|
||||
(recipient) => recipient.id === recipientId,
|
||||
);
|
||||
|
||||
return AVAILABLE_RECIPIENT_COLORS[
|
||||
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
|
||||
];
|
||||
},
|
||||
(recipientId: number) =>
|
||||
getRecipientColor(envelope.recipients.findIndex((r) => r.id === recipientId)),
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { type Field, type Recipient } from '@prisma/client';
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
|
||||
@@ -198,13 +198,7 @@ export const EnvelopeRenderProvider = ({
|
||||
);
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
(recipientId: number) => {
|
||||
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
|
||||
|
||||
return AVAILABLE_RECIPIENT_COLORS[
|
||||
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
|
||||
];
|
||||
},
|
||||
(recipientId: number) => getRecipientColor(recipientIds.findIndex((id) => id === recipientId)),
|
||||
[recipientIds],
|
||||
);
|
||||
|
||||
|
||||
@@ -71,11 +71,28 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
|
||||
|
||||
const organisations = await trpc.organisation.internal.getOrganisationSession
|
||||
.query(undefined, SKIP_QUERY_BATCH_META.trpc)
|
||||
.catch(() => {
|
||||
.catch((e) => {
|
||||
const errorMessage = typeof e.message === 'string' ? e.message.toLowerCase() : '';
|
||||
|
||||
const isNetworkError =
|
||||
errorMessage.includes('networkerror') || errorMessage.includes('failed to fetch');
|
||||
|
||||
// If the error is a transient network/abort error (e.g. page refresh while
|
||||
// fetch was in-flight), return null to signal we should skip the state update.
|
||||
if (isNetworkError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Todo: (RR7) Log
|
||||
return [];
|
||||
});
|
||||
|
||||
// Skip session update if the organisation fetch was aborted due to a transient
|
||||
// network error (e.g. page refresh while fetch was in-flight).
|
||||
if (organisations === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSession({
|
||||
session: newSession.session,
|
||||
user: newSession.user,
|
||||
|
||||
@@ -69,3 +69,40 @@ export const getCookieDomain = () => {
|
||||
|
||||
return url.hostname;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get allowed signup domains from env var.
|
||||
* Returns empty array if not set (meaning all domains allowed).
|
||||
*/
|
||||
export const getAllowedSignupDomains = (): string[] => {
|
||||
const domains = env('NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS');
|
||||
|
||||
if (!domains) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return domains
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if email domain is allowed for signup.
|
||||
* Returns true if no domain restriction is configured.
|
||||
*/
|
||||
export const isEmailDomainAllowedForSignup = (email: string): boolean => {
|
||||
const allowedDomains = getAllowedSignupDomains();
|
||||
|
||||
if (allowedDomains.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const emailDomain = email.toLowerCase().split('@').pop();
|
||||
|
||||
if (!emailDomain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowedDomains.includes(emailDomain);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export enum AppErrorCode {
|
||||
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
|
||||
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
|
||||
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
|
||||
'WEBHOOK_INVALID_REQUEST' = 'WEBHOOK_INVALID_REQUEST',
|
||||
}
|
||||
|
||||
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { JobClient } from './client/client';
|
||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
|
||||
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
|
||||
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
|
||||
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
|
||||
@@ -35,6 +36,7 @@ export const jobsClient = new JobClient([
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
|
||||
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
|
||||
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
|
||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { formatDocumentsPath } from '../../../utils/teams';
|
||||
import type { TSendDocumentCreatedFromDirectTemplateEmailJobDefinition } from './send-document-created-from-direct-template-email';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
}: {
|
||||
payload: TSendDocumentCreatedFromDirectTemplateEmailJobDefinition;
|
||||
}) => {
|
||||
const { envelopeId, recipientId } = payload;
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new Error('Envelope not found');
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
const { user: templateOwner } = envelope;
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url ?? '')}/${envelope.id}`;
|
||||
|
||||
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
||||
recipientName: recipient.email,
|
||||
recipientRole: recipient.role,
|
||||
documentLink,
|
||||
documentName: envelope.title,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: [
|
||||
{
|
||||
name: templateOwner.name || '',
|
||||
address: templateOwner.email,
|
||||
},
|
||||
],
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`Document created from direct template`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID =
|
||||
'send.document.created.from.direct.template.email';
|
||||
|
||||
const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
envelopeId: z.string(),
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendDocumentCreatedFromDirectTemplateEmailJobDefinition = z.infer<
|
||||
typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Document Created From Direct Template Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-document-created-from-direct-template-email.handler');
|
||||
|
||||
await handler.run({ payload });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
|
||||
z.infer<typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA>
|
||||
>;
|
||||
@@ -18,6 +18,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
|
||||
embedSigning: z.literal(true).optional(),
|
||||
embedSigningWhiteLabel: z.literal(true).optional(),
|
||||
cfr21: z.literal(true).optional(),
|
||||
hipaa: z.literal(true).optional(),
|
||||
// Todo: Envelopes - Do we need to check?
|
||||
// authenticationPortal & emailDomains missing here.
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Prisma, WebhookCallStatus } from '@prisma/client';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { WebhookCallStatus } from '@prisma/client';
|
||||
|
||||
import { executeWebhookCall } from '@documenso/lib/server-only/webhooks/execute-webhook-call';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
@@ -7,7 +9,7 @@ import type { TExecuteWebhookJobDefinition } from './execute-webhook';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
io: _io,
|
||||
}: {
|
||||
payload: TExecuteWebhookJobDefinition;
|
||||
io: JobRunIO;
|
||||
@@ -29,44 +31,28 @@ export const run = async ({
|
||||
webhookEndpoint: url,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payloadData),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Documenso-Secret': secret ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||
|
||||
try {
|
||||
responseBody = JSON.parse(body);
|
||||
} catch (err) {
|
||||
responseBody = body;
|
||||
}
|
||||
const result = await executeWebhookCall({ url, body: payloadData, secret });
|
||||
|
||||
await prisma.webhookCall.create({
|
||||
data: {
|
||||
url,
|
||||
event,
|
||||
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||
status: result.success ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
requestBody: payloadData as Prisma.InputJsonValue,
|
||||
responseCode: response.status,
|
||||
responseBody,
|
||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||
responseCode: result.responseCode,
|
||||
responseBody: result.responseBody,
|
||||
responseHeaders: result.responseHeaders,
|
||||
webhookId: webhook.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook execution failed with status ${response.status}`);
|
||||
if (!result.success) {
|
||||
throw new Error(`Webhook execution failed with status ${result.responseCode}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
success: true,
|
||||
status: result.responseCode,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"universal/"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"clean": "rimraf node_modules"
|
||||
@@ -65,6 +67,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "1.56.1",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/pg": "^8.15.6"
|
||||
"@types/pg": "^8.15.6",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,32 +108,29 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Send email outside any transaction to avoid holding a connection
|
||||
// open during network I/O.
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -109,7 +109,9 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw new Error(
|
||||
@@ -286,6 +288,18 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: { documentMeta: true, recipients: true },
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.recipient.signed.email',
|
||||
payload: {
|
||||
@@ -367,16 +381,16 @@ export const completeDocumentWithToken = async ({
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,13 @@ export const deleteDocument = async ({
|
||||
user,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
// Continue to hide the document from the user if they are a recipient.
|
||||
@@ -112,13 +119,6 @@ export const deleteDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return envelope;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.envelope.findFirstOrThrow({
|
||||
const result = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
@@ -56,6 +56,14 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.envelopeItems.length === 0) {
|
||||
throw new Error('Completed envelope has no items');
|
||||
}
|
||||
|
||||
const firstDocumentData = result.envelopeItems[0].documentData;
|
||||
|
||||
if (!firstDocumentData) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
@@ -25,12 +26,17 @@ import { prisma } from '@documenso/prisma';
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
@@ -213,45 +219,49 @@ export const resendDocument = async ({
|
||||
}),
|
||||
]);
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: envelope.documentMeta.subject
|
||||
? renderCustomEmailTemplate(
|
||||
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
|
||||
customEmailTemplate,
|
||||
)
|
||||
: emailSubject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Send email outside any transaction to avoid holding a connection
|
||||
// open during network I/O.
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: envelope.documentMeta.subject
|
||||
? renderCustomEmailTemplate(
|
||||
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
|
||||
customEmailTemplate,
|
||||
)
|
||||
: emailSubject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
return envelope;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EnvelopeType, ReadStatus, SendStatus } from '@prisma/client';
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
import { EnvelopeType, ReadStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
|
||||
@@ -265,18 +265,6 @@ export const createEnvelope = async ({
|
||||
// for uploads from the frontend
|
||||
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
|
||||
|
||||
const documentMeta = await prisma.documentMeta.create({
|
||||
data: extractDerivedDocumentMeta(settings, {
|
||||
...meta,
|
||||
timezone: timezoneToUse,
|
||||
}),
|
||||
});
|
||||
|
||||
const secondaryId =
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
||||
|
||||
const getValidatedDelegatedOwner = async () => {
|
||||
if (
|
||||
!settings.delegateDocumentOwnership ||
|
||||
@@ -311,7 +299,18 @@ export const createEnvelope = async ({
|
||||
return delegatedOwner;
|
||||
};
|
||||
|
||||
const delegatedOwner = await getValidatedDelegatedOwner();
|
||||
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
|
||||
prisma.documentMeta.create({
|
||||
data: extractDerivedDocumentMeta(settings, {
|
||||
...meta,
|
||||
timezone: timezoneToUse,
|
||||
}),
|
||||
}),
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||
: incrementTemplateId().then((v) => v.formattedTemplateId),
|
||||
getValidatedDelegatedOwner(),
|
||||
]);
|
||||
const envelopeOwnerId = delegatedOwner?.id ?? userId;
|
||||
|
||||
const createdEnvelope = await prisma.$transaction(async (tx) => {
|
||||
@@ -582,7 +581,7 @@ export const createEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Only create audit logs and webhook events for documents.
|
||||
// Only create audit logs for documents.
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
@@ -619,17 +618,28 @@ export const createEnvelope = async ({
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return createdEnvelope;
|
||||
});
|
||||
|
||||
// Trigger webhook outside the transaction to avoid holding the connection
|
||||
// open during network I/O.
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
} else if (type === EnvelopeType.TEMPLATE) {
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.TEMPLATE_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return createdEnvelope;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DocumentSource, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
|
||||
import pMap from 'p-map';
|
||||
import { omit } from 'remeda';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -35,6 +36,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
|
||||
title: true,
|
||||
userId: true,
|
||||
internalVersion: true,
|
||||
templateType: true,
|
||||
publicTitle: true,
|
||||
publicDescription: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: {
|
||||
@@ -68,23 +72,28 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
|
||||
});
|
||||
}
|
||||
|
||||
const { legacyNumberId, secondaryId } =
|
||||
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
|
||||
envelope.type === EnvelopeType.DOCUMENT
|
||||
? await incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
|
||||
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
|
||||
legacyNumberId: documentId,
|
||||
secondaryId: formattedDocumentId,
|
||||
}))
|
||||
: await incrementTemplateId().then(({ templateId, formattedTemplateId }) => ({
|
||||
: incrementTemplateId().then(({ templateId, formattedTemplateId }) => ({
|
||||
legacyNumberId: templateId,
|
||||
secondaryId: formattedTemplateId,
|
||||
}));
|
||||
})),
|
||||
prisma.documentMeta.create({
|
||||
data: {
|
||||
...omit(envelope.documentMeta, ['id']),
|
||||
emailSettings: envelope.documentMeta.emailSettings || undefined,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const createdDocumentMeta = await prisma.documentMeta.create({
|
||||
data: {
|
||||
...omit(envelope.documentMeta, ['id']),
|
||||
emailSettings: envelope.documentMeta.emailSettings || undefined,
|
||||
},
|
||||
});
|
||||
const duplicatedTemplateType =
|
||||
envelope.templateType === 'ORGANISATION' && envelope.teamId !== teamId
|
||||
? 'PRIVATE'
|
||||
: envelope.templateType ?? undefined;
|
||||
|
||||
const duplicatedEnvelope = await prisma.envelope.create({
|
||||
data: {
|
||||
@@ -98,6 +107,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
|
||||
documentMetaId: createdDocumentMeta.id,
|
||||
authOptions: envelope.authOptions || undefined,
|
||||
visibility: envelope.visibility,
|
||||
templateType: duplicatedTemplateType,
|
||||
publicTitle: envelope.publicTitle ?? undefined,
|
||||
publicDescription: envelope.publicDescription ?? undefined,
|
||||
source:
|
||||
envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
|
||||
},
|
||||
@@ -136,35 +148,38 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
|
||||
}),
|
||||
);
|
||||
|
||||
for (const recipient of envelope.recipients) {
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
fields: {
|
||||
createMany: {
|
||||
data: recipient.fields.map((field) => ({
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
|
||||
})),
|
||||
await pMap(
|
||||
envelope.recipients,
|
||||
(recipient) =>
|
||||
prisma.recipient.create({
|
||||
data: {
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
fields: {
|
||||
createMany: {
|
||||
data: recipient.fields.map((field) => ({
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
{ concurrency: 5 },
|
||||
);
|
||||
|
||||
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
|
||||
const refetchedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { EnvelopeType } from '@prisma/client';
|
||||
import type { EnvelopeType, Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } from '@prisma/client';
|
||||
import { EnvelopeType, FolderType } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, FolderType, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
@@ -12,9 +11,14 @@ import { prisma } from '@documenso/prisma';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getEnvelopeWhereInput } from './get-envelope-by-id';
|
||||
|
||||
export type UpdateEnvelopeOptions = {
|
||||
@@ -309,8 +313,8 @@ export const updateEnvelope = async ({
|
||||
// return envelope;
|
||||
// }
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const updatedEnvelope = await tx.envelope.update({
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
@@ -331,6 +335,10 @@ export const updateEnvelope = async ({
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
@@ -339,6 +347,24 @@ export const updateEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return updatedEnvelope;
|
||||
return result;
|
||||
});
|
||||
|
||||
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
// deconstruct to remove the recipients and documentMeta from the returned object since they aren't needed and can be large.
|
||||
const {
|
||||
recipients: _recipients,
|
||||
documentMeta: _documentMeta,
|
||||
...finalEnvelope
|
||||
} = updatedEnvelope;
|
||||
|
||||
return finalEnvelope;
|
||||
};
|
||||
|
||||
@@ -111,32 +111,27 @@ export const addUserToOrganisation = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member'),
|
||||
userId,
|
||||
organisationId,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
groupId: organisationGroupToUse.id,
|
||||
},
|
||||
},
|
||||
await prisma.organisationMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member'),
|
||||
userId,
|
||||
organisationId,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
groupId: organisationGroupToUse.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bypassEmail) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId,
|
||||
memberUserId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
if (!bypassEmail) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId,
|
||||
memberUserId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import path from 'node:path';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
/**
|
||||
* Ensure all required fonts are registered in the skia-canvas FontLibrary.
|
||||
*
|
||||
* Fonts are registered once per process and retained — calling this multiple
|
||||
* times is a no-op after the first invocation.
|
||||
*/
|
||||
export const ensureFontLibrary = () => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
if (!FontLibrary.has('Caveat')) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
});
|
||||
}
|
||||
|
||||
if (!FontLibrary.has('Inter')) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
|
||||
});
|
||||
}
|
||||
|
||||
if (!FontLibrary.has('Noto Sans')) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
|
||||
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
|
||||
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
|
||||
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type RecipientPlaceholderInfo = {
|
||||
email: string;
|
||||
name: string;
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import Konva from 'konva';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { renderField } from '../../universal/field-renderer/render-field';
|
||||
import { ensureFontLibrary } from './helpers';
|
||||
|
||||
type InsertFieldInPDFV2Options = {
|
||||
pageWidth: number;
|
||||
@@ -21,16 +20,7 @@ export const insertFieldInPDFV2 = async ({
|
||||
pageHeight,
|
||||
fields,
|
||||
}: InsertFieldInPDFV2Options) => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
|
||||
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
|
||||
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
|
||||
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
|
||||
});
|
||||
ensureFontLibrary();
|
||||
|
||||
let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight });
|
||||
let layer: Konva.Layer | null = new Konva.Layer();
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DateTime } from 'luxon';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
import { Image as SkiaImage } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
import { P } from 'ts-pattern';
|
||||
@@ -21,6 +20,7 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TDocumentAuditLog } from '../../types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '../../utils/document-audit-logs';
|
||||
import { ensureFontLibrary } from './helpers';
|
||||
|
||||
export type AuditLogRecipient = {
|
||||
id: number;
|
||||
@@ -575,13 +575,7 @@ export async function renderAuditLogs({
|
||||
i18n,
|
||||
hidePoweredBy,
|
||||
}: GenerateAuditLogsOptions) {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
|
||||
});
|
||||
ensureFontLibrary();
|
||||
|
||||
const minimumMargin = 10;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DateTime } from 'luxon';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
import { Image as SkiaImage } from 'skia-canvas';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { renderSVG } from 'uqr';
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
} from '../../constants/recipient-roles';
|
||||
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
|
||||
import { svgToPng } from '../../utils/images/svg-to-png';
|
||||
import { ensureFontLibrary } from './helpers';
|
||||
|
||||
type ColumnWidths = [number, number, number];
|
||||
|
||||
@@ -724,13 +724,7 @@ export async function renderCertificate({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: GenerateCertificateOptions) {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
|
||||
});
|
||||
ensureFontLibrary();
|
||||
|
||||
const minimumMargin = 10;
|
||||
|
||||
|
||||
@@ -52,36 +52,35 @@ export const createTeamEmailVerification = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Email already taken by another team.',
|
||||
});
|
||||
}
|
||||
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Email already taken by another team.',
|
||||
});
|
||||
}
|
||||
await tx.teamEmailVerification.create({
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.create({
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
// Send email outside the transaction to avoid holding a connection
|
||||
// open during network I/O.
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@@ -78,38 +78,35 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
(member) => member.id,
|
||||
);
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
|
||||
// Purge all internal organisation groups that have no teams.
|
||||
await tx.organisationGroup.deleteMany({
|
||||
where: {
|
||||
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||
teamGroups: {
|
||||
none: {},
|
||||
},
|
||||
// Purge all internal organisation groups that have no teams.
|
||||
await tx.organisationGroup.deleteMany({
|
||||
where: {
|
||||
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||
teamGroups: {
|
||||
none: {},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.team-deleted.email',
|
||||
payload: {
|
||||
team: {
|
||||
name: team.name,
|
||||
url: team.url,
|
||||
},
|
||||
members: membersToNotify,
|
||||
organisationId: team.organisationId,
|
||||
},
|
||||
});
|
||||
await jobs.triggerJob({
|
||||
name: 'send.team-deleted.email',
|
||||
payload: {
|
||||
team: {
|
||||
name: team.name,
|
||||
url: team.url,
|
||||
},
|
||||
members: membersToNotify,
|
||||
organisationId: team.organisationId,
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
type SendTeamDeleteEmailOptions = {
|
||||
|
||||
@@ -35,30 +35,27 @@ export const resendTeamEmailVerification = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const { emailVerification } = team;
|
||||
const { emailVerification } = team;
|
||||
|
||||
if (!emailVerification) {
|
||||
throw new AppError('VerificationNotFound', {
|
||||
message: 'No team email verification exists for this team.',
|
||||
});
|
||||
}
|
||||
if (!emailVerification) {
|
||||
throw new AppError('VerificationNotFound', {
|
||||
message: 'No team email verification exists for this team.',
|
||||
});
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.update({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
|
||||
await prisma.teamEmailVerification.update({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email outside any transaction to avoid holding a connection
|
||||
// open during network I/O.
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user