Compare commits

...
Author SHA1 Message Date
David Nguyen b6d3626e5e Merge branch 'main' into chore/add-manual-translations 2026-08-14 10:58:56 +10:00
David Nguyen 7319c9a402 chore: add translations 2026-08-14 10:58:39 +10:00
Konrad a5e37af3e8 chore: update Polish translations (#3210) 2026-08-14 10:40:42 +10:00
David Nguyen 617f8cc204 feat: show feature gated setting pages (#3167) 2026-08-12 17:33:08 +10:00
Christopher Ryan 1bd09480e6 feat(remix): support serving app under a sub-path via NEXT_PUBLIC_BASE_PATH (#2824) 2026-08-12 16:12:28 +10:00
github-actions[bot] 962cffc9f5 chore: extract translations (#3163) 2026-08-10 15:17:49 +10:00
David Nguyen 820b319474 fix: restore overriden translations (#3161) 2026-08-10 15:13:45 +10:00
David Nguyen 797f5c0e79 fix: improve unified settings (#3160) 2026-08-10 14:57:19 +10:00
Lucas Smith fc95ee9ead fix: handle completion when already signed (#3159)
Previously attempting to complete a document which is already completed
you'd get a generic error toast. Now when completing a document that you
have already completed you are redirected to the completed page.

Handles cases where two mutations managed to fire racing eachother.
2026-08-10 09:25:41 +10:00
David Nguyen d6cf3fec4b feat: unify settings (#3128) 2026-08-09 16:00:55 +10:00
Lucas Smith f0ab7c112e fix: add more logging for errors on sign or complete (#3151) 2026-08-05 11:29:38 +10:00
Lucas Smith 8bfcec8ee6 fix: add logging for errors on sign or complete (#3149) 2026-08-05 08:12:55 +10:00
Lucas Smith 9c27ce6d18 feat: replace document status tabs with filter pills (#3145)
Swaps the tab row and dropdowns for faceted filter pills (status,
sender, period) with a shared reset, and moves URL param handling
to nuqs.

<img width="2198" height="1674" alt="image"
src="https://github.com/user-attachments/assets/6996431c-09c8-45c3-bc30-f0a1e503c941"
/>
2026-08-03 22:52:11 +10:00
Ephraim Duncan b3c609a549 feat: bulk download documents (#2711) 2026-08-03 20:55:43 +10:00
Ephraim Duncan 29020bcbed docs(webhooks): correct retry policy, timeout and payload reference (#3132)
## Description

Corrects the webhooks documentation, which described delivery behavior
that does not exist in the implementation.

## Changes Made

- Replaced the fabricated retry schedule (5 attempts / immediate-to-2h
backoff) with the real provider-dependent behavior: retries belong to
the job provider (`NEXT_PRIVATE_JOBS_PROVIDER`) — local (default) 4
total attempts back-to-back, BullMQ 3 attempts with exponential backoff
from 1s, Inngest 5 attempts with platform backoff.
- Fixed the webhook timeout from 30 seconds to 10 seconds
(`WEBHOOK_TIMEOUT_MS = 10_000`, hard abort).
- Clarified failure semantics: non-2xx fails, 3xx redirects are not
followed (`redirect: 'manual'`), network/SSRF-blocked calls record
response code 0; failed deliveries mark only the `WebhookCall` record —
the webhook itself is never auto-disabled.
- Corrected URL requirements: `http://` is accepted; documented the SSRF
guard (private/loopback blocked,
`NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` bypass for self-hosters).
- Added `envelopeId` to both field tables and all payload/recipient JSON
examples; framed numeric `id` as the legacy v1 identifier.
- Removed a documented `documentMeta` field that exists in neither the
Zod schema nor Prisma; fixed timezone/dateFormat examples to the
hardcoded `Etc/UTC` / `yyyy-MM-dd hh:mm a` values.
- Added missing `REJECTED`/`CANCELLED` statuses and
`TEMPLATE_DIRECT_LINK` source; fixed `templateId` to `null` on
TEMPLATE_* examples; documented the previously missing
`RECIPIENT_EXPIRED` event across setup, events, and verification pages.

## Testing Performed

Docs-only change. Every claim verified against the implementation
(`execute-webhook-call.ts`, job clients, `webhook-payload.ts`,
`assert-webhook-url.ts`, webhook-router schema).
2026-08-03 12:16:39 +10:00
213 changed files with 15494 additions and 6578 deletions
@@ -33,13 +33,14 @@ All webhook events share a common structure:
| Field | Type | Description |
| ---------------- | --------- | ------------------------------------------------------ |
| `id` | number | Document or template ID |
| `id` | number | Legacy numeric v1 document or template ID |
| `envelopeId` | string | Canonical v2 identifier (`envelope_` + 16 characters) |
| `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 or template title |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
| `visibility` | string | Document visibility setting |
| `createdAt` | datetime | Document creation timestamp |
| `updatedAt` | datetime | Last modification timestamp |
@@ -47,8 +48,8 @@ All webhook events share a common structure:
| `deletedAt` | datetime? | Deletion timestamp |
| `teamId` | number? | Team ID if document belongs to a team |
| `templateId` | number? | Template ID if created from a template |
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
| `documentMeta` | object | Document metadata (subject, message, signing options) |
| `source` | string | Source: `DOCUMENT`, `TEMPLATE`, or `TEMPLATE_DIRECT_LINK` |
| `documentMeta` | object? | Nullable document metadata (subject, message, signing options) |
| `recipients` | array | List of recipient objects |
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
@@ -60,7 +61,6 @@ All webhook events share a common structure:
| `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` |
@@ -77,8 +77,9 @@ All webhook events share a common structure:
| Field | Type | Description |
| ---------------------- | --------- | ------------------------------------------ |
| `id` | number | Recipient ID |
| `documentId` | number? | Parent document ID |
| `templateId` | number? | Template ID if created from a template |
| `envelopeId` | string | Canonical parent envelope ID |
| `documentId` | number? | Legacy parent document ID; null for templates |
| `templateId` | number? | Legacy parent template ID; null for documents |
| `email` | string | Recipient email address |
| `name` | string | Recipient name |
| `token` | string | Unique signing token |
@@ -94,6 +95,8 @@ All webhook events share a common structure:
| `sendStatus` | string | `NOT_SENT` or `SENT` |
| `rejectionReason` | string? | Reason if recipient rejected |
Use `recipient.envelopeId` as the reliable parent link. The legacy `documentId` and `templateId` fields depend on the parent envelope type, so one of them is always null.
---
## Document Lifecycle Events
@@ -111,6 +114,7 @@ Triggered when a new document is created.
"event": "DOCUMENT_CREATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -129,9 +133,8 @@ Triggered when a new document is created.
"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",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -145,6 +148,7 @@ Triggered when a new document is created.
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -166,6 +170,7 @@ Triggered when a new document is created.
"Recipient": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -203,6 +208,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"event": "DOCUMENT_SENT",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -221,9 +227,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"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",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -237,6 +242,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -258,6 +264,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"Recipient": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -295,12 +302,14 @@ The recipient's `readStatus` changes to `OPENED`.
"event": "DOCUMENT_OPENED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -328,6 +337,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
"event": "DOCUMENT_SIGNED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "COMPLETED",
"title": "contract.pdf",
"source": "DOCUMENT",
@@ -335,6 +345,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
"recipients": [
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -361,12 +372,14 @@ Triggered when an individual recipient completes their required action (signing,
"event": "DOCUMENT_RECIPIENT_COMPLETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -395,6 +408,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"event": "DOCUMENT_COMPLETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -413,9 +427,8 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"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",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -429,6 +442,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"recipients": [
{
"id": 50,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "reviewer@example.com",
@@ -451,6 +465,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
},
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -475,6 +490,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"Recipient": [
{
"id": 50,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "reviewer@example.com",
@@ -497,6 +513,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
},
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -537,12 +554,14 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
"event": "DOCUMENT_REJECTED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -561,7 +580,7 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
### `document.cancelled`
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.
Triggered when a pending document is explicitly cancelled with `POST /envelope/cancel`, or when a document owner or team member deletes a document. Deleting a draft or pending document hard-deletes it, while deleting a completed document soft-deletes it.
This event is **not** triggered when a recipient hides a document from their inbox.
@@ -572,6 +591,7 @@ This event is **not** triggered when a recipient hides a document from their inb
"event": "DOCUMENT_CANCELLED",
"payload": {
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 3,
"authOptions": null,
@@ -591,7 +611,6 @@ This event is **not** triggered when a recipient hides a document from their inb
"subject": "",
"message": "",
"timezone": "Etc/UTC",
"password": null,
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "",
"signingOrder": "PARALLEL",
@@ -606,6 +625,7 @@ This event is **not** triggered when a recipient hides a document from their inb
"recipients": [
{
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 7,
"templateId": null,
"email": "signer@example.com",
@@ -627,6 +647,7 @@ This event is **not** triggered when a recipient hides a document from their inb
"Recipient": [
{
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 7,
"templateId": null,
"email": "signer@example.com",
@@ -651,6 +672,45 @@ This event is **not** triggered when a recipient hides a document from their inb
}
```
### `recipient.expired`
Triggered when a recipient's signing deadline passes on a pending document before they sign or reject it.
**Event name:** `RECIPIENT_EXPIRED`
The recipient's `expiresAt` contains the signing deadline, and `expirationNotifiedAt` is set when the expiration is processed.
```json
{
"event": "RECIPIENT_EXPIRED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
"expiresAt": "2024-04-22T11:51:00.000Z",
"expirationNotifiedAt": "2024-04-22T11:52:00.000Z",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:52:00.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `document.reminder.sent`
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
@@ -662,12 +722,14 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
"event": "DOCUMENT_REMINDER_SENT",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -686,7 +748,7 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
## Template Events
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.
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads. For `TEMPLATE_CREATED`, `TEMPLATE_UPDATED`, and `TEMPLATE_DELETED` the template's own legacy numeric ID is in `id` and `templateId` is `null`. Only `TEMPLATE_USED` — whose payload describes the new document envelope created from the template — carries the originating template's legacy ID in `templateId`, with `source` set to `TEMPLATE`.
### `template.created`
@@ -699,9 +761,10 @@ Triggered when a new template is created.
"event": "TEMPLATE_CREATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "My Template",
"status": "DRAFT",
"templateId": 10,
"templateId": null,
"source": "TEMPLATE",
"recipients": []
},
@@ -721,9 +784,10 @@ Triggered when a template's settings, recipients, or fields are modified.
"event": "TEMPLATE_UPDATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "My Updated Template",
"status": "DRAFT",
"templateId": 10,
"templateId": null,
"source": "TEMPLATE",
"recipients": []
},
@@ -743,9 +807,10 @@ Triggered when a template is deleted.
"event": "TEMPLATE_DELETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "Deleted Template",
"status": "DRAFT",
"templateId": 10,
"templateId": null,
"source": "TEMPLATE",
"recipients": []
},
@@ -765,6 +830,7 @@ Triggered when a document is created from a template. This event fires alongside
"event": "TEMPLATE_USED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "Document from Template",
"status": "DRAFT",
"templateId": 10,
@@ -791,7 +857,8 @@ Triggered when a document is created from a template. This event fires alongside
| `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_CANCELLED` | Pending document explicitly cancelled, or document deleted | `status: "CANCELLED"` after explicit cancellation; deletion may remove or soft-delete the document |
| `RECIPIENT_EXPIRED` | Recipient signing deadline passes | Recipient `expiresAt` passed, `expirationNotifiedAt` set |
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
### Template Events
@@ -821,7 +888,7 @@ When processing webhook events:
**Process idempotently** — Webhooks may be retried, so handle duplicate events
</Step>
<Step>
**Respond quickly** — Return a 200 status code within 30 seconds
**Respond quickly** — Return a `2xx` status code within 10 seconds
</Step>
</Steps>
@@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events.
2. When an event occurs, Documenso sends an HTTP POST to your URL
3. Your application processes the event and responds with 200 OK
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used).
---
@@ -42,12 +42,14 @@ Documenso supports webhook events for the full document lifecycle (created, sent
"event": "DOCUMENT_COMPLETED",
"payload": {
"id": 123,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "Contract",
"status": "COMPLETED",
"completedAt": "2024-01-15T10:30:00.000Z",
"recipients": [
{
"id": 1,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"signingStatus": "SIGNED"
}
@@ -58,6 +60,8 @@ Documenso supports webhook events for the full document lifecycle (created, sent
}
```
`payload.id` is the legacy numeric v1 ID. Use `payload.envelopeId` as the canonical v2 identifier. Each recipient repeats `envelopeId` as the reliable parent link because the legacy `documentId` and `templateId` fields depend on the parent envelope type, leaving one of them null.
---
## See Also
@@ -148,7 +148,7 @@ func main() {
</Tabs>
<Callout type="warn">
Always respond with a `200 OK` status within 30 seconds. Documenso will retry failed deliveries.
Always respond with a `2xx` status within 10 seconds. Documenso will retry failed deliveries according to the configured background-job provider.
</Callout>
## Configuring Webhooks in Documenso via the Dashboard
@@ -184,7 +184,7 @@ Fill in the following fields:
| Field | Description |
| ----- | ----------- |
| **Webhook URL** | The HTTPS endpoint that will receive webhook events |
| **Webhook URL** | The HTTP or HTTPS endpoint that will receive webhook events |
| **Events** | Select which events should trigger this webhook |
| **Secret** (optional) | A secret key used to sign the payload for verification |
</Step>
@@ -202,12 +202,21 @@ Your webhook endpoint must meet these requirements:
| Requirement | Details |
| ----------- | ------- |
| **Protocol** | HTTPS required (HTTP not allowed in production) |
| **Response** | Must return `2xx` status code within 30 seconds |
| **Protocol** | HTTP and HTTPS are accepted; use HTTPS in production |
| **Response** | Must return a `2xx` status code within 10 seconds |
| **Method** | Must accept HTTP POST requests |
| **Content-Type** | Must accept `application/json` payloads |
| **Availability** | Must be publicly accessible from the internet |
<Callout type="warn">
Documenso performs a best-effort check that rejects webhook URLs which use or resolve to private
or loopback addresses. This is not a complete SSRF mitigation — it does not cover DNS rebinding
and fails open on DNS lookup errors or timeouts — so self-hosted deployments should still enforce
network-level egress rules. Self-hosters that need to deliver to a hostname resolving to a
private address can add that hostname to the comma-separated
`NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` environment variable.
</Callout>
<Callout type="info">
For local development, use a tunneling service like [ngrok](https://ngrok.com) or [localtunnel](https://localtunnel.me) to expose your local server.
</Callout>
@@ -225,7 +234,8 @@ When creating a webhook, you can subscribe to one or more events:
| `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 deletes the document |
| `DOCUMENT_CANCELLED` | A pending document is explicitly cancelled or a document owner deletes it |
| `RECIPIENT_EXPIRED` | A recipient's signing deadline passes before they sign or reject |
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
| `TEMPLATE_CREATED` | A new template is created |
| `TEMPLATE_UPDATED` | A template is modified |
@@ -294,6 +304,7 @@ Each webhook call shows the following details:
- Timestamp
- Response code
- Request and response bodies
- Response headers
Click any call to see full details including headers and response data.
</Step>
@@ -318,17 +329,17 @@ Documenso will attempt to deliver the same payload again
## Retry Policy
When a webhook delivery fails (non-2xx response or timeout), Documenso automatically retries with exponential backoff:
A delivery fails when the endpoint returns a non-`2xx` response, the 10-second timeout expires, or the request fails. Redirects are not followed, so `3xx` responses also fail. Network and SSRF-blocked requests are recorded with response code `0`.
| Attempt | Delay |
| ------- | ----- |
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
For self-hosted deployments, retries are handled by the background-job provider selected with `NEXT_PRIVATE_JOBS_PROVIDER`:
After 5 failed attempts, the webhook is marked as failed and no further automatic retries occur. You can manually resend failed webhooks from the dashboard.
| Provider | Total attempts | Retry timing |
| -------- | -------------- | ------------ |
| Local (default) | 4 | Back-to-back, with no backoff |
| BullMQ | 3 | Exponential backoff starting at 1 second |
| Inngest | 5 | Inngest platform backoff |
Only the individual delivery (`WebhookCall`) record is marked as failed. Documenso does not automatically disable the webhook or apply a circuit breaker, so future matching events continue to be delivered. After automatic attempts are exhausted, you can manually resend a failed delivery from the dashboard.
<Callout type="warn">
If your endpoint consistently fails, consider reviewing your server logs and ensuring your endpoint meets all [URL requirements](#webhook-url-requirements).
@@ -255,6 +255,7 @@ const validEvents = [
'DOCUMENT_REJECTED',
'DOCUMENT_CANCELLED',
'DOCUMENT_REMINDER_SENT',
'RECIPIENT_EXPIRED',
'TEMPLATE_CREATED',
'TEMPLATE_UPDATED',
'TEMPLATE_DELETED',
@@ -18,7 +18,6 @@ export type DocumentPreferencesResetDialogProps = {
onReset: () => Promise<void>;
showAiFeatures?: boolean;
showDocumentVisibility?: boolean;
showIncludeSenderDetails?: boolean;
};
export const DocumentPreferencesResetDialog = ({
@@ -26,7 +25,6 @@ export const DocumentPreferencesResetDialog = ({
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
showIncludeSenderDetails = false,
}: DocumentPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
@@ -92,29 +90,12 @@ export const DocumentPreferencesResetDialog = ({
<li>
<Trans>Default signature settings</Trans>
</li>
{showIncludeSenderDetails && (
<li>
<Trans>Send on behalf of team</Trans>
</li>
)}
<li>
<Trans>Include the signing certificate in the document</Trans>
</li>
<li>
<Trans>Include the audit logs in the document</Trans>
</li>
<li>
<Trans>Default recipients</Trans>
</li>
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Default envelope expiration</Trans>
</li>
<li>
<Trans>Default signing reminders</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
@@ -0,0 +1,377 @@
import {
createZipWriter,
sanitizeZipPathSegment,
type ZipFileEntry,
} from '@documenso/lib/client-only/create-zip-writer';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { fetchPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useRef, useState } from 'react';
import { match } from 'ts-pattern';
/**
* The maximum number of documents that can be downloaded in a single bulk
* download. Each document requires fetching its full PDFs into the browser,
* so this bounds both request volume and blob storage usage. Matches the
* spirit of the server-side 100 cap on bulk move/delete/cancel.
*/
export const MAX_BULK_DOWNLOAD_ENVELOPES = 50;
type BulkDownloadVersion = 'signed' | 'original' | 'pending';
export type EnvelopeBulkDownloadItem = {
id: string;
title: string;
status: DocumentStatus;
/**
* Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a
* different field-rendering pipeline that the partial PDF helper does not
* implement, so the Partial option is hidden for them.
*/
isLegacy: boolean;
};
const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original';
export type EnvelopesBulkDownloadDialogProps = {
envelopes: EnvelopeBulkDownloadItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (successfulEnvelopeIds: string[]) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDownloadDialog = ({
envelopes,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDownloadDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [versionMap, setVersionMap] = useState<Record<string, BulkDownloadVersion>>({});
const [progress, setProgress] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);
const abortRef = useRef(false);
const trpcUtils = trpc.useUtils();
const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES;
useEffect(() => {
if (!open) {
return;
}
setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)])));
setProgress(0);
}, [open]);
const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
versionMap[envelope.id] ?? getDefaultVersion(envelope);
/**
* The version options selectable for an envelope, mirroring the gating used
* by the single envelope download dialog:
* - COMPLETED: signed or original.
* - PENDING (non-legacy): partial or original. Legacy envelopes use a
* field-rendering pipeline the partial PDF helper does not implement.
* - Anything else: original only, so no choice is shown.
*/
const getVersionOptions = (
envelope: EnvelopeBulkDownloadItem,
): { value: BulkDownloadVersion; label: string }[] | null => {
if (envelope.status === DocumentStatus.COMPLETED) {
return [
{ value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) },
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
];
}
if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) {
return [
{ value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) },
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
];
}
return null;
};
const getStatusLabel = (status: DocumentStatus) =>
match(status)
.with(DocumentStatus.COMPLETED, () => t`Completed`)
.with(DocumentStatus.PENDING, () => t`Pending`)
.with(DocumentStatus.DRAFT, () => t`Draft`)
.with(DocumentStatus.REJECTED, () => t`Rejected`)
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
.exhaustive();
const onDownload = async () => {
if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) {
return;
}
abortRef.current = false;
setIsDownloading(true);
setProgress(0);
const zipWriter = createZipWriter();
const successfulEnvelopeIds: string[] = [];
let failedDownloads = 0;
try {
for (const envelope of envelopes) {
if (abortRef.current) {
break;
}
try {
const downloadVersion = getDownloadVersion(envelope);
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
envelopeId: envelope.id,
access: {
type: 'user',
},
});
// Each envelope's items are grouped in their own folder. The id
// prefix guarantees uniqueness, the truncated title keeps it
// readable without risking overly long extraction paths.
const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96));
// Buffer this envelope's files before writing so a failed envelope
// is either fully in the zip or not at all. Files from previous
// envelopes have already been written to the zip stream and freed.
const envelopeFiles: ZipFileEntry[] = [];
for (const envelopeItem of envelopeItems) {
const { filename, blob } = await fetchPDF({
envelopeItem,
token: undefined,
fileName: envelopeItem.title,
version: downloadVersion,
});
envelopeFiles.push({
filename: `${folderName}/${sanitizeZipPathSegment(filename)}`,
data: blob,
});
}
for (const file of envelopeFiles) {
await zipWriter.addFile(file);
}
successfulEnvelopeIds.push(envelope.id);
} catch (error) {
console.error(error);
failedDownloads++;
}
setProgress((p) => p + 1);
}
// The user intentionally stopped the download, discard anything fetched
// so far without toasting an error.
if (abortRef.current) {
zipWriter.abort();
return;
}
if (successfulEnvelopeIds.length === 0) {
zipWriter.abort();
toast({
title: t`Error`,
description: t`An error occurred while downloading the documents.`,
variant: 'destructive',
});
return;
}
try {
downloadFile({
filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`,
data: zipWriter.finalize(),
});
} catch (error) {
console.error(error);
zipWriter.abort();
toast({
title: t`Error`,
description: t`An error occurred while downloading the documents.`,
variant: 'destructive',
});
return;
}
if (failedDownloads > 0) {
toast({
title: t`Documents partially downloaded`,
description: t`${plural(successfulEnvelopeIds.length, {
one: '# document downloaded.',
other: '# documents downloaded.',
})} ${plural(failedDownloads, {
one: '# document could not be downloaded.',
other: '# documents could not be downloaded.',
})}`,
variant: 'destructive',
});
onSuccess?.(successfulEnvelopeIds);
return;
}
toast({
title: t`Documents downloaded`,
description: plural(successfulEnvelopeIds.length, {
one: '# document has been downloaded.',
other: '# documents have been downloaded.',
}),
});
onSuccess?.(successfulEnvelopeIds);
onOpenChange(false);
} finally {
setIsDownloading(false);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => {
if (!isDownloading) {
onOpenChange(value);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Download Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopes.length}
one="Select the version to download for the selected document."
other="Select the version to download for each of the # selected documents."
/>
</DialogDescription>
</DialogHeader>
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
</AlertDescription>
</Alert>
)}
<fieldset disabled={isDownloading} className="space-y-4">
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
<div className="divide-y divide-border rounded-lg border border-border">
{envelopes.map((envelope) => {
const versionOptions = getVersionOptions(envelope);
return (
<div key={envelope.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm" title={envelope.title}>
{envelope.title}
</p>
<p className="text-muted-foreground text-xs">{getStatusLabel(envelope.status)}</p>
</div>
{versionOptions && (
<RadioGroupSegmented
className="shrink-0"
value={getDownloadVersion(envelope)}
onValueChange={(value) =>
setVersionMap((prev) => ({
...prev,
[envelope.id]: value as BulkDownloadVersion,
}))
}
aria-label={t`Download version for ${envelope.title}`}
>
{versionOptions.map((option) => (
<RadioGroupSegmentedItem key={option.value} value={option.value}>
{option.label}
</RadioGroupSegmentedItem>
))}
</RadioGroupSegmented>
)}
</div>
);
})}
</div>
</div>
{isDownloading && (
<p className="text-muted-foreground text-sm">
<Trans>
Downloading {progress} / {envelopes.length}...
</Trans>
</p>
)}
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
if (isDownloading) {
abortRef.current = true;
} else {
onOpenChange(false);
}
}}
>
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
</Button>
<Button
type="button"
onClick={() => void onDownload()}
loading={isDownloading}
disabled={envelopes.length === 0 || isOverDownloadLimit}
>
<Trans>Download</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -1,7 +1,7 @@
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
))}
<Link
to="https://documen.so/enterprise-cta"
to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
target="_blank"
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
>
@@ -17,28 +17,25 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Prisma } from '@prisma/client';
import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client';
import { useState } from 'react';
import { useRevalidator } from 'react-router';
export type TeamEmailDeleteDialogProps = {
trigger?: React.ReactNode;
teamName: string;
team: Prisma.TeamGetPayload<{
include: {
teamEmail: true;
emailVerification: {
select: {
expiresAt: true;
name: true;
email: true;
};
};
};
}>;
team: Pick<Team, 'id' | 'avatarImageId' | 'name'>;
teamEmail: Pick<TeamEmail, 'email' | 'name'> | null;
emailVerification: Pick<TeamEmailVerification, 'email' | 'name' | 'expiresAt'> | null;
};
export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => {
export const TeamEmailDeleteDialog = ({
trigger,
teamName,
team,
teamEmail,
emailVerification,
}: TeamEmailDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
@@ -83,11 +80,11 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
});
const onRemove = async () => {
if (team.teamEmail) {
if (teamEmail) {
await deleteTeamEmail({ teamId: team.id });
}
if (team.emailVerification) {
if (emailVerification) {
await deleteTeamEmailVerification({ teamId: team.id });
}
@@ -121,13 +118,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
avatarFallback={extractInitials((teamEmail?.name || emailVerification?.name) ?? '')}
primaryText={
<span className="font-semibold text-foreground/80 text-sm">
{team.teamEmail?.name || team.emailVerification?.name}
{teamEmail?.name || emailVerification?.name}
</span>
}
secondaryText={<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>}
secondaryText={<span className="text-sm">{teamEmail?.email || emailVerification?.email}</span>}
/>
</Alert>
@@ -23,7 +23,8 @@ import { useRevalidator } from 'react-router';
import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
teamId: number;
teamEmail: Pick<TeamEmail, 'email' | 'name'>;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@@ -33,7 +34,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
@@ -53,7 +54,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
try {
await updateTeamEmail({
teamId: teamEmail.teamId,
teamId,
data: {
name,
},
@@ -29,6 +29,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
@@ -210,11 +211,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enable Custom Branding</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Enable Custom Branding</Trans>}
testId="branding-enabled"
>
<FormControl>
<Select
{...field}
@@ -252,7 +255,7 @@ export function BrandingPreferencesForm({
<Trans>Enable custom branding for all documents in this organisation</Trans>
)}
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -263,11 +266,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingLogo"
render={({ field: { value: _value, onChange, ...field } }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Branding Logo</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={!previewUrl}
label={<Trans>Branding Logo</Trans>}
testId="branding-logo"
>
<div className="flex flex-col gap-4">
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
{previewUrl ? (
@@ -345,7 +350,7 @@ export function BrandingPreferencesForm({
)}
</FormDescription>
</div>
</FormItem>
</InheritableField>
)}
/>
@@ -353,11 +358,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Brand Website</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={!field.value}
label={<Trans>Brand Website</Trans>}
testId="branding-url"
>
<FormControl>
<Input type="url" placeholder="https://example.com" disabled={!isBrandingEnabled} {...field} />
</FormControl>
@@ -372,7 +379,7 @@ export function BrandingPreferencesForm({
</span>
)}
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -380,11 +387,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingCompanyDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Brand Details</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={!field.value}
label={<Trans>Brand Details</Trans>}
testId="branding-company-details"
>
<FormControl>
<Textarea
placeholder={t`Enter your brand details`}
@@ -404,7 +413,7 @@ export function BrandingPreferencesForm({
</span>
)}
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
</div>
@@ -0,0 +1,169 @@
import { Form, FormControl, FormDescription, FormField } from '@documenso/ui/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
const ZCertificatePreferencesFormSchema = z.object({
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
});
export type TCertificatePreferencesFormSchema = z.infer<typeof ZCertificatePreferencesFormSchema>;
type SettingsSubset = Pick<TeamGlobalSettings, 'includeSigningCertificate' | 'includeAuditLog'>;
export type CertificatePreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
onFormSubmit: (data: TCertificatePreferencesFormSchema) => Promise<void>;
};
export const CertificatePreferencesForm = ({ settings, canInherit, onFormSubmit }: CertificatePreferencesFormProps) => {
const form = useForm<TCertificatePreferencesFormSchema>({
defaultValues: {
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
},
resolver: zodResolver(ZCertificatePreferencesFormSchema),
});
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
// The page handler surfaces its own error toast. Keep the form dirty so
// the save bar stays visible and the user can retry.
return;
}
form.reset(data);
});
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Include the Signing Certificate in the Document</Trans>}
testId="include-signing-certificate"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-signing-certificate-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when it is downloaded. The
signing certificate can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Include the Audit Logs in the Document</Trans>}
testId="include-audit-log"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-audit-log-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the audit logs will be included in the document when it is downloaded. The audit
logs can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
</fieldset>
</form>
</Form>
);
};
@@ -1,12 +1,8 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
@@ -16,36 +12,24 @@ import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documens
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
@@ -56,15 +40,10 @@ export type TDocumentPreferencesFormSchema = {
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
documentTimezone: string | null;
documentDateFormat: TDocumentMetaDateFormat | null;
includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
reminderSettings: TEnvelopeReminderSettings | null;
};
type SettingsSubset = Pick<
@@ -73,23 +52,17 @@ type SettingsSubset = Pick<
| 'documentLanguage'
| 'documentTimezone'
| 'documentDateFormat'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'includeAuditLog'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
| 'envelopeExpirationPeriod'
| 'reminderSettings'
>;
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
isAiFeaturesConfigured?: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
@@ -101,50 +74,34 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
};
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }: DocumentPreferencesFormProps) => {
const { _ } = useLingui();
const { user, organisations } = useSession();
const { organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
const isAiFeaturesConfigured = IS_AI_FEATURES_CONFIGURED();
const placeholderEmail = user.email ?? 'user@example.com';
const isPersonalLayoutMode = isPersonalLayout(organisations);
const ZDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const defaultValues = getDocumentPreferencesFormValues(settings);
@@ -189,17 +146,19 @@ export const DocumentPreferencesForm = ({
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{!isPersonalLayoutMode && (
<FormField
control={form.control}
name="documentVisibility"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Visibility</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Document Visibility</Trans>}
testId="document-visibility"
>
<FormControl>
<Select
{...field}
@@ -236,7 +195,7 @@ export const DocumentPreferencesForm = ({
<FormDescription>
<Trans>Controls the default visibility of an uploaded document.</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
)}
@@ -245,11 +204,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentLanguage"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Language</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Document Language</Trans>}
testId="document-language"
>
<FormControl>
<Select
{...field}
@@ -283,7 +244,7 @@ export const DocumentPreferencesForm = ({
communications with the recipients.
</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -291,11 +252,12 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentDateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Date Format</Trans>
</FormLabel>
<InheritableField
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Date Format</Trans>}
testId="document-date-format"
>
<FormControl>
<Select
value={field.value === null ? '-1' : field.value}
@@ -322,7 +284,7 @@ export const DocumentPreferencesForm = ({
</FormControl>
<FormMessage />
</FormItem>
</InheritableField>
)}
/>
@@ -330,11 +292,12 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentTimezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Time Zone</Trans>
</FormLabel>
<InheritableField
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Time Zone</Trans>}
testId="document-timezone"
>
<FormControl>
<Combobox
triggerPlaceholder={canInherit ? t`Inherit from organisation` : t`Local timezone`}
@@ -347,7 +310,7 @@ export const DocumentPreferencesForm = ({
</FormControl>
<FormMessage />
</FormItem>
</InheritableField>
)}
/>
@@ -355,12 +318,18 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="signatureTypes"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="flex flex-row items-center">
<Trans>Default Signature Settings</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={canInherit && (field.value === null || field.value.length === 0)}
label={
<span className="flex flex-row items-center">
<Trans>Default Signature Settings</Trans>
<DocumentSignatureSettingsTooltip />
</span>
}
testId="signature-types"
>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
@@ -383,179 +352,7 @@ export const DocumentPreferencesForm = ({
<Trans>Controls which signatures are allowed to be used when signing a document.</Trans>
</FormDescription>
)}
</FormItem>
)}
/>
{!isPersonalLayoutMode && !isPersonalOrganisation && (
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Send on Behalf of Team</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-sender-details-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<div className="pt-2">
<div className="font-medium text-muted-foreground text-xs">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{field.value ? (
<Trans>
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
</Trans>
) : (
<Trans>"Team Name" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a recipient to sign a
document. If a custom message has been provided while configuring the document, it will be used
instead.
</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Signing Certificate in the Document</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-signing-certificate-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when it is downloaded. The
signing certificate can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Logs in the Document</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the audit logs will be included in the document when it is downloaded. The audit
logs can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -566,11 +363,13 @@ export const DocumentPreferencesForm = ({
const recipients = field.value ?? [];
return (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Recipients</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Recipients</Trans>}
testId="default-recipients"
>
{canInherit && (
<Select
value={field.value === null ? '-1' : '0'}
@@ -638,7 +437,7 @@ export const DocumentPreferencesForm = ({
<FormDescription>
<Trans>Recipients that will be automatically added to new documents.</Trans>
</FormDescription>
</FormItem>
</InheritableField>
);
}}
/>
@@ -647,11 +446,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="delegateDocumentOwnership"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Delegate Document Ownership</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Delegate Document Ownership</Trans>}
testId="delegate-document-ownership"
>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
@@ -681,65 +482,7 @@ export const DocumentPreferencesForm = ({
<FormDescription>
<Trans>Enable team API tokens to delegate document ownership to another team member.</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="envelopeExpirationPeriod"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Envelope Expiration</Trans>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls how long recipients have to complete signing before the document expires. After expiration,
recipients can no longer sign the document.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reminderSettings"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Signing Reminders</Trans>
</FormLabel>
<FormControl>
<ReminderSettingsPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls when and how often reminder emails are sent to recipients who have not yet completed
signing.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
</InheritableField>
)}
/>
@@ -748,11 +491,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="aiFeaturesEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>AI Features</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>AI Features</Trans>}
testId="ai-features-enabled"
>
<FormControl>
<Select
{...field}
@@ -790,7 +535,7 @@ export const DocumentPreferencesForm = ({
prefer European regions where available.
</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
)}
@@ -806,7 +551,6 @@ export const DocumentPreferencesForm = ({
onReset={handleResetToDefaults}
showAiFeatures={isAiFeaturesConfigured}
showDocumentVisibility={!isPersonalLayoutMode}
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
) : undefined
}
@@ -1,9 +1,11 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { Alert } from '@documenso/ui/primitives/alert';
import {
Form,
FormControl,
@@ -17,22 +19,27 @@ import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { OrganisationType, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
const ZEmailPreferencesFormSchema = z.object({
emailId: z.string().nullable(),
emailReplyTo: zEmail().nullable(),
// emailReplyToName: z.string(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
});
export type TEmailPreferencesFormSchema = z.infer<typeof ZEmailPreferencesFormSchema>;
type SettingsSubset = Pick<TeamGlobalSettings, 'emailId' | 'emailReplyTo' | 'emailDocumentSettings'>;
type SettingsSubset = Pick<
TeamGlobalSettings,
'emailId' | 'emailReplyTo' | 'emailDocumentSettings' | 'includeSenderDetails'
>;
export type EmailPreferencesFormProps = {
settings: SettingsSubset;
@@ -41,14 +48,20 @@ export type EmailPreferencesFormProps = {
};
export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: EmailPreferencesFormProps) => {
const { user } = useSession();
const organisation = useCurrentOrganisation();
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
const placeholderEmail = user.email ?? 'user@example.com';
const form = useForm<TEmailPreferencesFormSchema>({
defaultValues: {
emailId: settings.emailId,
emailReplyTo: settings.emailReplyTo,
// emailReplyToName: settings.emailReplyToName,
emailDocumentSettings: settings.emailDocumentSettings,
includeSenderDetails: settings.includeSenderDetails,
},
resolver: zodResolver(ZEmailPreferencesFormSchema),
});
@@ -75,7 +88,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
@@ -122,10 +135,12 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
control={form.control}
name="emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply to email</Trans>
</FormLabel>
<InheritableField
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Reply to email</Trans>}
testId="email-reply-to"
>
<FormControl>
<Input
{...field}
@@ -146,7 +161,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
</span>
)}
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -170,10 +185,13 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
control={form.control}
name="emailDocumentSettings"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Email Settings</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Email Settings</Trans>}
testId="email-document-settings"
>
{canInherit && (
<Select
value={field.value === null ? 'INHERIT' : 'CONTROLLED'}
@@ -212,10 +230,83 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
settings will not affect existing documents or templates.
</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
{!isPersonalOrganisation && (
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Send on Behalf of Team</Trans>}
testId="include-sender-details"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-sender-details-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<div className="pt-2">
<div className="font-medium text-muted-foreground text-xs">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{field.value ? (
<Trans>
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
</Trans>
) : (
<Trans>"Team Name" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a recipient to sign a
document. If a custom message has been provided while configuring the document, it will be used
instead.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
)}
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
@@ -5,6 +5,22 @@ import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
const getScrollParent = (node: HTMLElement): HTMLElement | null => {
let current = node.parentElement;
while (current) {
const { overflowY } = getComputedStyle(current);
if (overflowY === 'auto' || overflowY === 'scroll') {
return current;
}
current = current.parentElement;
}
return null;
};
export type FormStickySaveBarProps = {
isDirty: boolean;
isSubmitting: boolean;
@@ -43,14 +59,18 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefau
}
// The sentinel sits at the bar's resting position (the end of the form). While the
// bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of
// view); once you reach the form's end it comes into view and the bar settles.
// bar is stuck to the bottom of the scroll container the sentinel is scrolled past
// (out of view); once you reach the form's end it comes into view and the bar settles.
//
// Observe relative to the actual scroll container (not always the viewport) so a
// banner shifting the page can't desync the detection from the sticky bar — both
// then share the same reference box.
const observer = new IntersectionObserver(
([entry]) => {
setIsStuck(!entry.isIntersecting);
},
{
root: null,
root: getScrollParent(sentinel),
rootMargin: '0px 0px -24px 0px',
threshold: 0,
},
@@ -0,0 +1,51 @@
import { cn } from '@documenso/ui/lib/utils';
import { FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Trans } from '@lingui/react/macro';
import type { ReactNode } from 'react';
export type InheritableFieldProps = {
isInherited: boolean;
canInherit: boolean;
label: ReactNode;
children: ReactNode;
className?: string;
testId?: string;
};
export const InheritableField = ({
isInherited,
canInherit,
label,
children,
className,
testId,
}: InheritableFieldProps) => {
if (!canInherit) {
return (
<FormItem className={className}>
<FormLabel>{label}</FormLabel>
{children}
</FormItem>
);
}
return (
<FormItem className={className} data-testid={testId ? `inheritable-${testId}` : undefined}>
<FormLabel className="flex items-center gap-2">
{label}
<span
className={cn(
'rounded px-1.5 py-0.5 font-bold text-[9px] uppercase tracking-wide',
isInherited
? 'bg-muted text-muted-foreground'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
)}
data-testid={testId ? `${testId}-status` : undefined}
>
{isInherited ? <Trans>Inherited</Trans> : <Trans>Override</Trans>}
</span>
</FormLabel>
{children}
</FormItem>
);
};
@@ -56,7 +56,7 @@ export const OrganisationUpdateForm = () => {
await refreshSession();
if (url !== organisation.url) {
await navigate(`/o/${url}/settings`);
await navigate(`/o/${url}/settings/general`);
}
toast({
@@ -0,0 +1,134 @@
import {
type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
const ZReminderPreferencesFormSchema = z.object({
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
export type TReminderPreferencesFormSchema = {
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
reminderSettings: TEnvelopeReminderSettings | null;
};
type SettingsSubset = Pick<TeamGlobalSettings, 'envelopeExpirationPeriod' | 'reminderSettings'>;
export type ReminderPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
onFormSubmit: (data: TReminderPreferencesFormSchema) => Promise<void>;
};
export const ReminderPreferencesForm = ({ settings, canInherit, onFormSubmit }: ReminderPreferencesFormProps) => {
const { t } = useLingui();
const form = useForm<TReminderPreferencesFormSchema>({
defaultValues: {
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZReminderPreferencesFormSchema),
});
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
// The page handler surfaces its own error toast. Keep the form dirty so
// the save bar stays visible and the user can retry.
return;
}
form.reset(data);
});
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="envelopeExpirationPeriod"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Envelope Expiration</Trans>}
testId="envelope-expiration-period"
>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls how long recipients have to complete signing before the document expires. After expiration,
recipients can no longer sign the document.
</Trans>
</FormDescription>
<FormMessage />
</InheritableField>
)}
/>
<FormField
control={form.control}
name="reminderSettings"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Signing Reminders</Trans>}
testId="reminder-settings"
>
<FormControl>
<ReminderSettingsPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls when and how often reminder emails are sent to recipients who have not yet completed
signing.
</Trans>
</FormDescription>
<FormMessage />
</InheritableField>
)}
/>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
</fieldset>
</form>
</Form>
);
};
+2 -1
View File
@@ -1,5 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { formatPath } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => {
return message;
};
const LOGIN_REDIRECT_PATH = '/';
const LOGIN_REDIRECT_PATH = formatPath('/');
export const ZSignInFormSchema = z.object({
email: zEmail().min(1),
@@ -65,7 +65,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
});
if (url !== teamUrl) {
await navigate(`/t/${url}/settings`);
await navigate(`/t/${url}/settings/general`);
}
} catch (err) {
const error = AppError.parseError(err);
@@ -1,5 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatPath } from '@documenso/lib/constants/app';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
DOCUMENTS_PAGE_SHORTCUT,
@@ -287,7 +288,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
{
id: 'settings-main',
label: msg`Settings`,
path: '/settings',
path: '/settings/profile',
icon: SettingsIcon,
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
},
@@ -389,7 +390,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
const formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `${count}` : `${count}`);
const goToSettings = useCallback(() => push('/settings'), [push]);
const goToSettings = useCallback(() => push('/settings/profile'), [push]);
const goToDocuments = useCallback(() => {
if (teamUrl) {
push(`/t/${teamUrl}/documents?status=ALL`);
@@ -862,7 +863,7 @@ const PromptLanguageCommands = ({
formData.append('lang', lang);
const response = await fetch('/api/locale', {
const response = await fetch(formatPath('/api/locale'), {
method: 'post',
body: formData,
});
@@ -1,5 +1,3 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { getRootHref } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -14,16 +12,16 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { AppCommandMenu } from './app-command-menu';
import { AppNavDesktop } from './app-nav-desktop';
import { AppNavMobile } from './app-nav-mobile';
import { MenuSwitcher } from './menu-switcher';
import { OrgMenuSwitcher } from './org-menu-switcher';
export type HeaderProps = HTMLAttributes<HTMLDivElement>;
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
/** Span the full viewport width instead of the centered max-w-screen-xl container. */
fullWidth?: boolean;
};
export const Header = ({ className, ...props }: HeaderProps) => {
export const Header = ({ className, fullWidth = false, ...props }: HeaderProps) => {
const params = useParams();
const { organisations } = useSession();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
@@ -51,12 +49,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<header
className={cn(
'sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent bg-background/95 backdrop-blur duration-200 supports-backdrop-blur:bg-background/60',
scrollY > 5 && 'border-b-border',
(scrollY > 5 || fullWidth) && 'border-b-border',
className,
)}
{...props}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<div
className={cn(
'mx-auto flex w-full items-center justify-between gap-x-4 px-4 md:justify-normal',
fullWidth ? 'md:px-6' : 'max-w-screen-xl md:px-8',
)}
data-testid="app-header-container"
>
<Link
to={getRootHref(params)}
className="hidden rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:inline"
@@ -78,7 +82,9 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link>
</Button>
<div className="md:ml-4">{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}</div>
<div className="md:ml-4">
<OrgMenuSwitcher />
</div>
<div className="flex flex-row items-center space-x-4 md:hidden">
<button onClick={() => setIsCommandMenuOpen(true)}>
@@ -1,5 +1,4 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -37,8 +36,8 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
@@ -1,7 +1,6 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -40,8 +39,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
@@ -317,7 +317,6 @@ export const IndividualPersonalLayoutCheckoutButton = ({
const createSubscriptionResponse = await createSubscription({
organisationId: organisations[0].id,
priceId,
isPersonalLayoutMode: true,
});
window.location.href = createSubscriptionResponse.redirectUrl;
@@ -1,4 +1,5 @@
import { authClient } from '@documenso/auth/client';
import { formatPath } from '@documenso/lib/constants/app';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
await authClient.signOut({
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
redirectPath: formatPath(
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
),
});
} catch {
setIsSigningOut(false);
@@ -1,4 +1,5 @@
import { authClient } from '@documenso/auth/client';
import { formatPath } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
@@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
try {
setIsSigningOut(true);
let redirectPath = '/signin';
let redirectPath = formatPath('/signin');
if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
}
await authClient.signOut({
@@ -2,38 +2,24 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [searchTerm, setSearchTerm] = useState(initialValue);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
const handleSearch = useCallback(
(term: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? '');
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
setSearchParams(params);
},
[searchParams],
);
useEffect(() => {
const currentQueryParam = searchParams.get('query') || '';
if (debouncedSearchTerm !== currentQueryParam) {
handleSearch(debouncedSearchTerm);
if (debouncedSearchTerm !== (query ?? '')) {
void setQuery(debouncedSearchTerm || null);
}
}, [debouncedSearchTerm, searchParams]);
}, [debouncedSearchTerm, query, setQuery]);
return (
<Input
@@ -42,7 +42,11 @@ export const EnvelopeSignerCompleteDialog = () => {
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
const { mutateAsync: completeDocument, isPending } = trpc.recipient.completeDocumentWithToken.useMutation();
const {
mutateAsync: completeDocument,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: createDocumentFromDirectTemplate } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
@@ -106,11 +110,21 @@ export const EnvelopeSignerCompleteDialog = () => {
return;
}
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
// The document was already completed by an earlier request (retry,
// stale tab or concurrent submission). Let the user know this click
// didn't complete the document, then continue to the completed page.
if (result.status === 'ALREADY_SIGNED') {
toast({
title: t`Document already signed`,
description: t`This document was already signed and no further action was taken.`,
});
} else {
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
}
if (onDocumentCompleted) {
onDocumentCompleted({
@@ -246,7 +260,7 @@ export const EnvelopeSignerCompleteDialog = () => {
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
isSubmitting={isPending || isSuccess}
recipientPayload={recipientPayload}
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
documentTitle={envelope.title}
@@ -0,0 +1,186 @@
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Separator } from '@documenso/ui/primitives/separator';
import { Trans } from '@lingui/react/macro';
import { CheckIcon, ChevronDownIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ReactNode } from 'react';
import { useState } from 'react';
export type FilterPillOption = {
value: string;
label: ReactNode;
trailing?: string;
};
type FilterPillCommonProps = {
icon: LucideIcon;
label: ReactNode;
options: FilterPillOption[];
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
testId?: string;
};
export type FilterPillSingleProps = FilterPillCommonProps & {
multiple?: false;
value: string | null;
onChange: (value: string | null) => void;
selectedLabel?: ReactNode;
};
export type FilterPillMultipleProps = FilterPillCommonProps & {
multiple: true;
value: string[];
onChange: (value: string[]) => void;
};
export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
/**
* A faceted filter pill.
*
* Renders as a dashed "add a filter" pill at rest, and shows the current
* selection inline once a value is picked. Selecting the active option
* again (or the Clear row) removes it.
*
* Single select by default, closing on pick. When `multiple` is set the
* popover stays open for toggling, and the trigger shows the first two
* selections followed by a "+N more" chip.
*/
export const FilterPill = (props: FilterPillProps) => {
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
const [open, setOpen] = useState(false);
const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value];
const selectedOptions = selectedValues
.map((value) => options.find((option) => option.value === value))
.filter((option): option is FilterPillOption => option !== undefined);
const hasSelection = selectedOptions.length > 0;
const extraCount = selectedOptions.length - 2;
const onSelect = (nextValue: string) => {
if (props.multiple) {
const newValues = selectedValues.includes(nextValue)
? selectedValues.filter((value) => value !== nextValue)
: [...selectedValues, nextValue];
props.onChange(newValues);
return;
}
props.onChange(nextValue === props.value ? null : nextValue);
setOpen(false);
};
const onClear = () => {
if (props.multiple) {
props.onChange([]);
} else {
props.onChange(null);
}
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={loading}
className={cn('border-dashed text-muted-foreground', {
'border-solid text-foreground': hasSelection,
})}
data-testid={testId}
>
<Icon className="mr-2 h-4 w-4" />
{label}
{hasSelection && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
{props.multiple ? (
<span className="flex items-center gap-x-1">
{selectedOptions.slice(0, 2).map((option) => (
<Badge key={option.value} variant="neutral" size="small">
{option.label}
</Badge>
))}
{extraCount > 0 && (
<Badge variant="neutral" size="small">
<Trans>+{extraCount} more</Trans>
</Badge>
)}
</span>
) : (
<span className="font-medium">{props.selectedLabel ?? selectedOptions[0].label}</span>
)}
</>
)}
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align="start">
<Command>
{enableSearch && <CommandInput placeholder={searchPlaceholder} />}
<CommandList>
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
<CheckIcon
className={cn(
'mr-2 h-4 w-4 shrink-0',
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
{option.trailing !== undefined && (
<span className="ml-auto pl-4 text-muted-foreground text-xs">{option.trailing}</span>
)}
</CommandItem>
))}
</CommandGroup>
{hasSelection && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem className="justify-center text-center text-muted-foreground" onSelect={onClear}>
<Trans>Clear</Trans>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -1,104 +0,0 @@
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronsUpDown, Plus } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
export const MenuSwitcher = () => {
const { _ } = useLingui();
const { user } = useSession();
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
const isUserAdmin = isAdmin(user);
const formatAvatarFallback = (name?: string) => {
if (name !== undefined) {
return name.slice(0, 1).toUpperCase();
}
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="menu-switcher"
variant="none"
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
<AvatarWithText
avatarSrc={formatAvatarUrl(user.avatarImageId)}
avatarFallback={formatAvatarFallback(user.name || user.email)}
primaryText={user.name}
secondaryText={_(msg`Personal Account`)}
rightSideComponent={<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />}
textSectionClassName="hidden lg:flex"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')} align="end" forceMount>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/organisations?action=add-organisation" className="flex items-center justify-between">
<Trans>Create Organisation</Trans>
<Plus className="ml-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isUserAdmin && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/admin">
<Trans>Admin panel</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/profile">
<Trans>User settings</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" onClick={() => setLanguageSwitcherOpen(true)}>
<Trans>Language</Trans>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:!text-destructive px-4 py-2 text-destructive/90"
onSelect={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
</DropdownMenu>
);
};
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
@@ -55,6 +56,12 @@ export const OrgMenuSwitcher = () => {
const currentOrganisation = useOptionalCurrentOrganisation();
const currentTeam = useOptionalCurrentTeam();
const canAccessOrganisationSettings =
currentOrganisation &&
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole);
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
// Use hovered org for teams display if available,
// otherwise use current team's org if in a team,
// finally fallback to selected org
@@ -161,7 +168,7 @@ export const OrgMenuSwitcher = () => {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link
to={`/o/${org.url}/settings`}
to={`/o/${org.url}/settings/general`}
className="mr-2 rounded-sm border p-1 text-muted-foreground transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
>
<Settings2Icon className="h-3.5 w-3.5" />
@@ -214,7 +221,7 @@ export const OrgMenuSwitcher = () => {
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link
to={`/t/${team.url}/settings`}
to={`/t/${team.url}/settings/general`}
className="mr-2 rounded-sm border p-1 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
<Settings2Icon className="h-3.5 w-3.5" />
@@ -258,26 +265,23 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem>
)}
{currentOrganisation &&
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/o/${currentOrganisation.url}/settings`}>
<Trans>Organisation settings</Trans>
</Link>
</DropdownMenuItem>
)}
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/t/${currentTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
<Trans>Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
to={
canAccessOrganisationSettings
? `/o/${currentOrganisation?.url}/settings/general`
: canAccessTeamSettings
? `/t/${currentTeam?.url}/settings/general`
: '/settings/profile'
}
>
<Trans>Settings</Trans>
</Link>
</DropdownMenuItem>
@@ -287,6 +291,14 @@ export const OrgMenuSwitcher = () => {
</Link>
</DropdownMenuItem>
{IS_BILLING_ENABLED() && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/billing">
<Trans>Billing</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="px-4 py-2 text-muted-foreground"
onClick={() => setLanguageSwitcherOpen(true)}
@@ -1,6 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -13,8 +12,6 @@ export type OrganisationBillingPortalButtonProps = {
};
export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBillingPortalButtonProps) => {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
@@ -28,11 +25,10 @@ export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBil
try {
const { redirectUrl } = await manageSubscription({
organisationId: organisation.id,
isPersonalLayoutMode: isPersonalLayout(organisations),
});
window.open(redirectUrl, '_blank');
} catch (err) {
} catch (_err) {
toast({
title: _(msg`Something went wrong`),
description: _(
@@ -12,9 +12,9 @@ export type SettingsHeaderProps = {
export const SettingsHeader = ({ children, title, subtitle, className, hideDivider }: SettingsHeaderProps) => {
return (
<>
<div className={cn('flex flex-row items-center justify-between', className)}>
<div className={cn('mb-4 flex flex-row items-center justify-between', className)}>
<div>
<h3 className="font-medium text-lg">{title}</h3>
<h2 className="font-bold text-xl">{title}</h2>
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
</div>
@@ -22,7 +22,7 @@ export const SettingsHeader = ({ children, title, subtitle, className, hideDivid
{children}
</div>
{!hideDivider && <hr className="my-4" />}
{!hideDivider && <hr className="mb-4" />}
</>
);
};
@@ -1,137 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { BracesIcon, CreditCardIcon, Globe2Icon, Lock, Settings2Icon, User, Users, WebhookIcon } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button variant="ghost" className={cn('w-full justify-start')}>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Trans>Document</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<Trans>Branding</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<Trans>Email</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -1,144 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
Lock,
MailIcon,
PaletteIcon,
Settings2Icon,
User,
Users,
WebhookIcon,
} from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Document Preferences</Trans>
</Button>
</Link>
<Link to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<PaletteIcon className="mr-2 h-5 w-5" />
<Trans>Branding Preferences</Trans>
</Button>
</Link>
<Link to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Preferences</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -0,0 +1,200 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { type INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Trans, useLingui } from '@lingui/react/macro';
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
const SEARCH_THRESHOLD = 5;
export type SettingsOrgSwitcherProps = {
currentOrgUrl: string;
};
export const SettingsOrgSwitcher = ({ currentOrgUrl }: SettingsOrgSwitcherProps) => {
const { t } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const manageableOrgs = useMemo(
() =>
organisations.filter(
(org) =>
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
org.teams.some((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)),
),
[organisations],
);
const currentOrg = manageableOrgs.find((org) => org.url === currentOrgUrl);
const hasManageableBillingOrgs = useMemo(
() => organisations.some((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole)),
[organisations],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) {
return manageableOrgs;
}
return manageableOrgs.filter((org) => org.name.toLowerCase().includes(q));
}, [manageableOrgs, query]);
const isBillingEnabled = IS_BILLING_ENABLED();
const handleSelect = (orgUrl: string) => {
const destinationOrg = manageableOrgs.find((org) => org.url === orgUrl);
if (!destinationOrg) {
return;
}
const manageableTeam = destinationOrg.teams.find((team) =>
canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole),
);
const destinationGroups = getSettingsNavGroups({
organisation: {
url: destinationOrg.url,
currentOrganisationRole: destinationOrg.currentOrganisationRole,
organisationClaim: destinationOrg.organisationClaim,
},
team: manageableTeam ? { url: manageableTeam.url, currentTeamRole: manageableTeam.currentTeamRole } : null,
hasManageableBillingOrgs,
});
// The list also contains organisations the user can only reach through a team they
// manage — for those `getSettingsNavGroups` returns no organisation group, so we land
// them in the team group instead of on an organisation page they aren't authorised for.
const destinationGroup = destinationGroups.organisation ?? destinationGroups.team;
if (!destinationGroup) {
return;
}
setIsOpen(false);
void navigate(
computeSwitcherContinuityPath({
currentPath: pathname,
destinationPaths: destinationGroup.items.map((item) => item.path),
fallbackPath: destinationGroup.items[0].path,
}),
);
};
if (!currentOrg) {
return null;
}
// Resolve an organisation's plan label. Unknown or custom claims (including
// self-hosted custom claim IDs) fall back to "Custom Plan".
const getPlanName = (organisationClaimId: string | null) => {
const planClaim =
organisationClaimId && organisationClaimId in internalClaims
? internalClaims[organisationClaimId as INTERNAL_CLAIM_ID]
: undefined;
return planClaim ? t`${planClaim.name} Plan` : t`Custom Plan`;
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
data-testid="settings-org-switcher-trigger"
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
>
<AvatarWithText
className="max-w-none"
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(currentOrg.avatarImageId)}
avatarFallback={currentOrg.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold text-muted-foreground">{currentOrg.name}</span>}
secondaryText={
isBillingEnabled ? getPlanName(currentOrg.organisationClaim.originalSubscriptionClaimId) : undefined
}
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
data-testid="settings-org-switcher-content"
>
{manageableOrgs.length >= SEARCH_THRESHOLD && (
<div className="border-b p-2">
<div className="relative">
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t`Search organisations…`}
className="h-8 pl-7"
data-testid="settings-org-switcher-search"
/>
</div>
</div>
)}
<ul className="max-h-72 space-y-1 overflow-auto p-1">
{filtered.map((org) => {
const isCurrent = org.url === currentOrgUrl;
return (
<li key={org.id}>
<button
type="button"
onClick={() => handleSelect(org.url)}
className={cn(
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
isCurrent && 'bg-muted',
)}
data-testid={`settings-org-switcher-item-${org.url}`}
>
<AvatarWithText
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(org.avatarImageId)}
avatarFallback={org.name.slice(0, 1).toUpperCase()}
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{org.name}</span>}
secondaryText={
isBillingEnabled ? getPlanName(org.organisationClaim.originalSubscriptionClaimId) : undefined
}
/>
</button>
</li>
);
})}
</ul>
<div className="border-t p-1">
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-org-switcher-create">
<a href="/settings/organisations?action=add-organisation">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Create organisation</Trans>
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,58 @@
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Trans } from '@lingui/react/macro';
import { Building2Icon, ChevronRightIcon, UserIcon, Users2Icon } from 'lucide-react';
export type SettingsScopeBreadcrumbProps = {
scope: 'organisation' | 'team' | 'account';
scopeName: string;
crumbs: string[];
};
export const SettingsScopeBreadcrumb = ({ scope, scopeName, crumbs }: SettingsScopeBreadcrumbProps) => {
return (
<nav
aria-label="settings-scope-breadcrumb"
className="mb-4 flex flex-wrap items-center gap-2 text-muted-foreground text-sm"
data-testid="settings-scope-breadcrumb"
>
<span>{scopeName}</span>
{crumbs.map((crumb, idx) => {
const isLeaf = idx === crumbs.length - 1;
return (
<span key={`${crumb}-${idx}`} className="flex items-center gap-2">
<ChevronRightIcon className="h-3.5 w-3.5 opacity-50" />
<span className={cn(isLeaf && 'font-semibold text-foreground')}>{crumb}</span>
</span>
);
})}
<Badge
variant={scope === 'organisation' ? 'default' : scope === 'team' ? 'secondary' : 'neutral'}
role="presentation"
className="ml-auto gap-1.5"
data-testid="settings-scope-breadcrumb-chip"
>
{scope === 'organisation' && (
<>
<Building2Icon className="h-3.5 w-3.5" />
<Trans>Organisation Settings</Trans>
</>
)}
{scope === 'team' && (
<>
<Users2Icon className="h-3.5 w-3.5" />
<Trans>Team Settings</Trans>
</>
)}
{scope === 'account' && (
<>
<UserIcon className="h-3.5 w-3.5" />
<Trans>Account Settings</Trans>
</>
)}
</Badge>
</nav>
);
};
@@ -0,0 +1,166 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Trans, useLingui } from '@lingui/react/macro';
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
const SEARCH_THRESHOLD = 5;
export type SettingsTeamSwitcherProps = {
currentOrgUrl: string;
currentTeamUrl: string | null;
};
export const SettingsTeamSwitcher = ({ currentOrgUrl, currentTeamUrl }: SettingsTeamSwitcherProps) => {
const { t } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const currentOrg = organisations.find((org) => org.url === currentOrgUrl);
const manageableTeams = useMemo(
() =>
currentOrg ? currentOrg.teams.filter((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) : [],
[currentOrg],
);
const currentTeam = manageableTeams.find((team) => team.url === currentTeamUrl) ?? manageableTeams[0];
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) {
return manageableTeams;
}
return manageableTeams.filter((team) => team.name.toLowerCase().includes(q));
}, [manageableTeams, query]);
const handleSelect = (teamUrl: string) => {
const destinationTeam = manageableTeams.find((team) => team.url === teamUrl);
if (!currentOrg || !destinationTeam) {
return;
}
const destinationGroup = getSettingsNavGroups({
organisation: {
url: currentOrg.url,
currentOrganisationRole: currentOrg.currentOrganisationRole,
organisationClaim: currentOrg.organisationClaim,
},
team: { url: destinationTeam.url, currentTeamRole: destinationTeam.currentTeamRole },
hasManageableBillingOrgs: false,
}).team;
if (!destinationGroup) {
return;
}
setIsOpen(false);
void navigate(
computeSwitcherContinuityPath({
currentPath: pathname,
destinationPaths: destinationGroup.items.map((item) => item.path),
fallbackPath: destinationGroup.items[0].path,
}),
);
};
if (!currentOrg || !currentTeam) {
return null;
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
data-testid="settings-team-switcher-trigger"
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
>
<AvatarWithText
className="max-w-none"
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(currentTeam.avatarImageId)}
avatarFallback={currentTeam.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold text-muted-foreground">{currentTeam.name}</span>}
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
data-testid="settings-team-switcher-content"
>
{manageableTeams.length >= SEARCH_THRESHOLD && (
<div className="border-b p-2">
<div className="relative">
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t`Search teams…`}
className="h-8 pl-7"
data-testid="settings-team-switcher-search"
/>
</div>
</div>
)}
<ul className="max-h-72 space-y-1 overflow-auto p-1">
{filtered.map((team) => {
const isCurrent = team.url === currentTeam.url;
return (
<li key={team.id}>
<button
type="button"
onClick={() => handleSelect(team.url)}
className={cn(
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
isCurrent && 'bg-muted',
)}
data-testid={`settings-team-switcher-item-${team.url}`}
>
<AvatarWithText
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={team.name.slice(0, 1).toUpperCase()}
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{team.name}</span>}
secondaryText={t(EXTENDED_TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}
/>
</button>
</li>
);
})}
</ul>
<div className="border-t p-1">
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-team-switcher-create">
<a href={`/o/${currentOrg.url}/settings/teams?action=add-team`}>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Create team</Trans>
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,195 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { Trans } from '@lingui/react/macro';
import { motion, useReducedMotion } from 'framer-motion';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
const DEMO_BRANDS = [
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#A2E771',
ink: '#162C07',
tint: '#F2FBEA',
sheen: 'rgba(162, 231, 113, 0.32)',
},
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#387BC7',
ink: '#ffffff',
tint: '#EDF3FA',
sheen: 'rgba(56, 123, 199, 0.28)',
},
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#9747F5',
ink: '#ffffff',
tint: '#F4EDFE',
sheen: 'rgba(151, 71, 245, 0.26)',
},
];
/**
* Milliseconds each brand is shown before cycling to the next.
*/
const BRAND_CYCLE_INTERVAL_MS = 2400;
export const BrandingUpsell = () => {
const organisation = useCurrentOrganisation();
const isReducedMotion = useReducedMotion();
const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS));
const isStatic = isReducedMotion ?? false;
const brand = DEMO_BRANDS[brandIndex];
return (
<SettingsUpsellCard
planLabel={<Trans>Teams</Trans>}
title={<Trans>Unlock Branding Preferences</Trans>}
description={
<Trans>Put your own brand on every document you send. Branding is available on the Teams plan and above.</Trans>
}
features={[
<Trans key="logo">Your logo on signing pages and emails</Trans>,
<Trans key="details">Company details and website in email footers</Trans>,
<Trans key="teams">Separate branding per team</Trans>,
]}
preview={
<div className="mx-auto w-full max-w-xs">
<div className="flex h-8 items-center justify-between px-1">
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
<Trans>Brand accent</Trans>
</span>
<div className="flex shrink-0 items-center gap-2">
{DEMO_BRANDS.map((dotBrand, index) => (
<motion.div
key={index}
initial={isStatic ? false : undefined}
animate={{
scale: index === brandIndex ? 1.25 : 1,
opacity: index === brandIndex ? 1 : 0.42,
boxShadow:
index === brandIndex ? '0 0 0 3px rgba(15, 23, 42, 0.08)' : '0 0 0 0 rgba(15, 23, 42, 0)',
}}
transition={SPRING}
className="h-[13px] w-[13px] rounded-full"
style={{ backgroundColor: dotBrand.accent }}
/>
))}
</div>
</div>
<div className="relative mt-3 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.tint }}
transition={{ duration: 0.45, ease: EASE }}
className="flex items-center gap-2.5 border-b px-4 py-3"
>
{/* The sender identity never changes — only the tile colours tween per brand. */}
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent, color: brand.ink }}
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg font-semibold text-sm"
>
{brand.letter}
</motion.div>
{/*
* Hardcoded inks (not theme tokens): this row sits on the
* hardcoded light `tint` band, so it pairs with hardcoded ink
* colours the same way the email sibling pairs its hardcoded
* avatar surfaces (hardcoded surface => hardcoded ink).
*/}
<div className="min-w-0">
<div className="font-medium text-[#0f172a] text-sm">
<p className="truncate">{brand.name}</p>
</div>
<div className="font-mono text-[#64748b] text-xs">
<p className="truncate">{brand.domain}</p>
</div>
</div>
</motion.div>
<div className="px-4 py-3.5">
<p className="font-medium text-sm">
<Trans>Please sign: Example.pdf</Trans>
</p>
<p className="mt-1 text-muted-foreground text-xs">
<Trans>{organisation.name} has invited you to sign this document.</Trans>
</p>
{/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */}
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent, color: brand.ink }}
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
className="mt-3 inline-block rounded-md px-3 py-1.5 font-medium text-xs"
>
<motion.span
key={brandIndex}
initial={isStatic ? false : { scale: 0.96 }}
animate={{ scale: 1 }}
transition={{ ...POP, delay: 0.06 }}
className="inline-block"
>
<Trans>Sign</Trans>
</motion.span>
</motion.div>
</div>
<div className="mt-auto flex items-center gap-2.5 border-t bg-muted px-4 py-2.5">
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
<Trans>Company details</Trans>
</span>
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent }}
transition={{ duration: 0.4 }}
className="h-1.5 w-[54px] rounded-full"
style={{ opacity: 0.45 }}
/>
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent }}
transition={{ duration: 0.4 }}
className="h-1.5 w-[34px] rounded-full"
style={{ opacity: 0.22 }}
/>
</div>
{/*
* Keyed remount replays the sweep per brand. No opacity envelope —
* keyframe arrays are unreliable on strict-mode remounts; both
* endpoints sit outside the overflow-hidden card, so the clip
* provides the fade in/out instead.
*/}
<motion.div
key={`sheen-${brandIndex}`}
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '240%' }}
transition={{ duration: 1.15, ease: 'easeOut' }}
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
style={{ background: `linear-gradient(105deg, transparent, ${brand.sheen}, transparent)` }}
/>
</div>
</div>
}
/>
);
};
@@ -0,0 +1,213 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { BadgeCheckIcon, MailIcon } from 'lucide-react';
import { BrandingLogoIcon } from '../branding-logo-icon';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
/**
* Named sender identities cycled through while the preview is in its branded
* state — one per branded cycle step, shown as the sender name and address.
*/
const BRANDED_SENDERS = [
{ name: 'Support', email: 'support@example.com' },
{ name: 'Team', email: 'hello@example.com' },
{ name: 'Sales', email: 'sales@example.com' },
{ name: 'Example', email: 'noreply@example.com' },
];
/**
* How long the initial unbranded (Documenso default) state is shown before
* the first flip starts. Shown exactly once — the cycle never returns to it.
*/
const INITIAL_STATE_DURATION_MS = 2500;
/**
* How long each branded sender identity is shown before cycling to the next,
* giving the viewer time to read the changed address.
*/
const BRANDED_STATE_DURATION_MS = 5000;
/**
* One duration per cycle step: the unbranded state first, then one step per
* named sender identity, derived from the identity count so the two cannot
* drift.
*/
const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)];
export const EmailDomainsUpsell = () => {
const organisation = useCurrentOrganisation();
const isReducedMotion = useReducedMotion();
// Loop from index 1: the unbranded Documenso intro plays exactly once,
// then the cycle rotates through the branded senders only.
const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1);
const isBranded = cycleIndex > 0;
const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0];
const isStatic = isReducedMotion ?? false;
return (
<SettingsUpsellCard
planLabel={<Trans>Enterprise</Trans>}
title={<Trans>Unlock Email Domains</Trans>}
description={
<Trans>Send documents from your own domain. Email domains are available on the Enterprise plan.</Trans>
}
features={[
<Trans key="journey">Send emails to recipients from your domain</Trans>,
<Trans key="dns">Easy DNS setup with auto-generated DKIM and SPF records</Trans>,
<Trans key="senders">Named senders with defaults per team, template or document</Trans>,
]}
ctaLabel={<Trans>Contact Sales</Trans>}
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
ctaExternal
preview={
<div className="mx-auto w-full max-w-xs">
<div className="relative h-8">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={isBranded ? 'chip-on' : 'chip-off'}
initial={isStatic ? false : { opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={SPRING}
className={cn(
'absolute inset-0 flex items-center gap-2 rounded-full border bg-background px-3 font-mono text-xs',
isBranded ? 'border-documenso-300 text-documenso-800' : 'text-muted-foreground',
)}
>
{isBranded ? (
<BadgeCheckIcon className="h-3.5 w-3.5 shrink-0 text-documenso-700" />
) : (
<MailIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">
{isBranded ? <Trans>Sending from your domain</Trans> : <Trans>Sending from app.documenso.com</Trans>}
</span>
</motion.div>
</AnimatePresence>
</div>
<div className="relative mt-4 overflow-hidden rounded-lg border bg-background shadow-sm">
<div className="flex items-center gap-2 border-b p-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-semibold text-sm">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={`logo-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22 }}
>
{/*
* Remounts with its keyed parent on every cycle step so the
* pop replays each change. Single-value spring (POP
* overshoots past 1) instead of scale keyframes — keyframe
* arrays are unreliable on strict-mode remounts.
*/}
<motion.span
initial={isStatic ? false : { scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ scale: POP }}
className="inline-block"
>
{isBranded ? (
<Avatar className="h-8 w-8 border border-solid">
{organisation.avatarImageId && (
<AvatarImage src={formatAvatarUrl(organisation.avatarImageId)} />
)}
<AvatarFallback className="text-sm">{brandedSender.name[0]}</AvatarFallback>
</Avatar>
) : (
<BrandingLogoIcon className="h-8 w-8" />
)}
</motion.span>
</motion.span>
</AnimatePresence>
</div>
<div className="min-w-0">
<div className="font-medium text-sm">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`name-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.28, ease: EASE }}
className="flex min-w-0 items-center gap-1.5"
>
<span className="min-w-0 truncate">{isBranded ? brandedSender.name : 'Documenso'}</span>
{/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */}
{isBranded && (
<motion.span
initial={isStatic ? false : { scale: 0, rotate: -40 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ ...POP, delay: 0.12 }}
className="shrink-0"
>
<BadgeCheckIcon className="h-3.5 w-3.5 text-documenso-700" />
</motion.span>
)}
</motion.div>
</AnimatePresence>
</div>
<div className="font-mono text-muted-foreground text-xs">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`addr-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.28, ease: EASE }}
className="truncate"
>
{isBranded ? brandedSender.email : 'noreply@app.documenso.com'}
</motion.p>
</AnimatePresence>
</div>
</div>
</div>
<div className="p-4">
<p className="font-medium text-sm">
<Trans>Please sign: Example.pdf</Trans>
</p>
<p className="mt-1 text-muted-foreground text-xs">
<Trans>{organisation.name} has invited you to sign this document.</Trans>
</p>
</div>
{/*
* Keyed remount replays the sweep on every cycle step. No opacity
* envelope — keyframe arrays are unreliable on strict-mode
* remounts; both endpoints sit outside the overflow-hidden card,
* so the clip provides the fade in/out instead.
*/}
<motion.div
key={`sheen-${cycleIndex}`}
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '240%' }}
transition={{ duration: 1.2, ease: 'easeOut' }}
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.32), transparent)' }}
/>
</div>
</div>
}
/>
);
};
@@ -0,0 +1,9 @@
/**
* Shared motion vocabulary for the settings upsell previews. Values ported
* from the design prototype (`design/SSO Upsell.dc.html`).
*/
export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const;
export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const;
export const EASE = [0.22, 0.61, 0.36, 1] as const;
@@ -0,0 +1,118 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
export type SettingsUpsellCardProps = {
planLabel: ReactNode;
title: ReactNode;
description: ReactNode;
features: ReactNode[];
preview: ReactNode;
/**
* CTA label. Defaults to "Upgrade Plan".
*/
ctaLabel?: ReactNode;
/**
* CTA destination. Defaults to the organisation billing settings page.
*/
ctaTo?: string;
/**
* Render the CTA as an external link (new tab) instead of an internal route.
*/
ctaExternal?: boolean;
};
/**
* Shared split-card layout for claim-gated settings upsells on Documenso
* Cloud. The left pane pitches the feature (plan badge, title, description,
* feature list, upgrade CTA); the right pane renders a decorative scenario
* preview supplied by the caller.
*
* Callers decide *when* to render this (cloud + missing claim flag).
*/
export const SettingsUpsellCard = ({
planLabel,
title,
description,
features,
preview,
ctaLabel,
ctaTo,
ctaExternal = false,
}: SettingsUpsellCardProps) => {
const organisation = useCurrentOrganisation();
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`;
const ctaContent = (
<>
{ctaLabel ?? <Trans>Upgrade Plan</Trans>}
<ArrowRightIcon className="ml-2 h-4 w-4" />
</>
);
return (
<div className="mt-8 overflow-hidden rounded-xl border-2 ring-4 ring-muted/70 md:grid md:grid-cols-[1.08fr_0.92fr] xl:-mx-8">
{/*
* `min-w-0` on both grid items: `fr` tracks have an `auto` content
* minimum, so long preview content (e.g. a wide mono domain line) would
* otherwise widen the right track beyond its 0.92fr share — and
* re-balance the whole grid on every preview cycle (layout shift).
*/}
<div className="flex min-w-0 flex-col items-start p-6 md:p-8">
<Badge size="small">
<LockIcon className="mr-1 h-3 w-3" />
<span className="uppercase">{planLabel}</span>
</Badge>
<h3 className="mt-4 font-semibold text-xl">{title}</h3>
<p className="mt-2 max-w-[40ch] text-muted-foreground text-sm">{description}</p>
<ul className="mt-6 space-y-3">
{features.map((feature, index) => (
<li key={index} className="flex items-start gap-2.5 text-sm">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-documenso-200">
<CheckIcon className="h-3 w-3 text-documenso-800" strokeWidth={2.5} />
</span>
{feature}
</li>
))}
</ul>
{canManageBilling ? (
<Button className="mt-8" asChild>
{ctaExternal ? (
<a href={ctaHref} target="_blank" rel="noreferrer">
{ctaContent}
</a>
) : (
<Link to={ctaHref}>{ctaContent}</Link>
)}
</Button>
) : (
<p className="mt-8 text-muted-foreground text-xs">
<Trans>Contact your organisation owner to upgrade plans.</Trans>
</p>
)}
</div>
<div
aria-hidden="true"
className="flex min-w-0 flex-col justify-center gap-3 border-t bg-muted p-6 md:border-t-0 md:border-l md:p-8"
>
{preview}
</div>
</div>
);
};
@@ -0,0 +1,260 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { FingerprintIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
export const SsoPortalUpsell = () => {
const isReducedMotion = useReducedMotion();
const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS);
return (
<SettingsUpsellCard
planLabel={<Trans>Enterprise</Trans>}
title={<Trans>Unlock the Organisation SSO Portal</Trans>}
description={
<Trans>
Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan.
</Trans>
}
features={[
<Trans key="oidc">Works with any OIDC provider Okta, Entra ID, Google and more</Trans>,
<Trans key="jit">Accounts are automatically added to your organisation on sign-in</Trans>,
<Trans key="control">Restrict sign-ins by email domain and choose the default role</Trans>,
]}
ctaLabel={<Trans>Contact Sales</Trans>}
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
ctaExternal
preview={
<div className="mx-auto w-full max-w-xs">
<div className="relative h-[236px]">
<AnimatePresence mode="wait" initial={false}>
{sceneIndex === 0 && <PortalScene key="portal" isStatic={isReducedMotion ?? false} />}
{sceneIndex === 1 && <RedirectScene key="redirect" />}
{sceneIndex === 2 && <SuccessScene key="success" />}
</AnimatePresence>
</div>
</div>
}
/>
);
};
/**
* Absolute-positioned panel each scene renders in, handling the shared
* slide-and-fade transition between scenes.
*/
const ScenePanel = ({ children }: { children: ReactNode }) => {
return (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -12, scale: 0.98 }}
transition={{ duration: 0.34, ease: EASE }}
className="absolute inset-0 flex flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-6 text-center shadow-sm"
>
{children}
</motion.div>
);
};
/**
* Scene 1: the organisation's SSO portal, with a timed faux press on the
* "Continue with SSO" button (cursor flies in, button dips, sheen sweeps).
*
* When `isStatic` is set (reduced motion) every element renders with
* `initial={false}`, skipping entrance and press animations.
*/
const PortalScene = ({ isStatic }: { isStatic: boolean }) => {
const organisation = useCurrentOrganisation();
const rise = (delay: number) => ({
initial: isStatic ? false : { y: 10, opacity: 0 },
animate: { y: 0, opacity: 1 },
transition: { ...SPRING, delay },
});
return (
<ScenePanel>
<motion.div
initial={isStatic ? false : { scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ ...POP, delay: 0.04 }}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-documenso-200 font-semibold text-documenso-900">
{([...organisation.name][0] ?? 'D').toUpperCase()}
</div>
</motion.div>
<motion.p {...rise(0.12)} className="mt-3.5 font-semibold text-sm">
<Trans>Welcome to {organisation.name}</Trans>
</motion.p>
<motion.p {...rise(0.18)} className="mt-1 text-muted-foreground text-xs">
<Trans>Single sign-on</Trans>
</motion.p>
<div className="relative mt-4 w-full">
<motion.div
initial={isStatic ? false : { y: 10, opacity: 0, scale: 1 }}
animate={{ y: 0, opacity: 1, scale: [1, 1, 0.955, 1] }}
transition={{
y: { ...SPRING, delay: 0.24 },
opacity: { duration: 0.3, delay: 0.24 },
scale: { duration: 2.6, times: [0, 0.63, 0.72, 0.84], ease: 'easeOut' },
}}
className="relative flex h-[38px] w-full items-center justify-center overflow-hidden rounded-md bg-foreground font-semibold text-background text-sm"
>
<span>
<Trans>Continue with SSO</Trans>
</span>
<motion.div
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '150%' }}
transition={{ duration: 0.85, delay: 1.75, ease: 'easeOut' }}
className="absolute top-0 bottom-0 left-[20%] w-3/5"
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.45), transparent)' }}
/>
</motion.div>
<motion.svg
initial={isStatic ? false : { x: 30, y: 30, opacity: 0, scale: 1 }}
animate={{
x: [30, 30, 0, 0, 0],
y: [30, 30, 0, 0, 0],
opacity: [0, 1, 1, 1, 0],
scale: [1, 1, 1, 0.82, 1],
}}
transition={{ duration: 2.6, times: [0, 0.3, 0.63, 0.72, 0.94], ease: EASE }}
width={17}
height={17}
viewBox="0 0 24 24"
strokeWidth={1.4}
strokeLinejoin="round"
className="absolute right-[26px] -bottom-2.5 fill-foreground stroke-background"
>
<path d="M4 2.5 19 12l-6.6 1.4L9.7 19.6z" />
</motion.svg>
</div>
</ScenePanel>
);
};
/**
* Scene 2: redirecting to the identity provider, with a rotating ring around
* a fingerprint tile and a filling progress bar.
*/
const RedirectScene = () => {
return (
<ScenePanel>
<div className="relative flex h-[46px] w-[46px] items-center justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 0.95, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
className="absolute inset-0 rounded-full border-2"
style={{ borderTopColor: '#A2E771' }}
/>
<div className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-muted">
<FingerprintIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.6} />
</div>
</div>
<motion.p
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.08 }}
className="mt-3.5 max-w-[22ch] text-sm"
>
<Trans>Redirecting to your identity provider</Trans>
</motion.p>
<div className="mt-4 h-1 w-[140px] overflow-hidden rounded-full bg-border">
<motion.div
initial={{ width: '0%' }}
animate={{ width: '100%' }}
transition={{ duration: 1.35, ease: 'easeInOut' }}
className="h-full rounded-full bg-documenso"
/>
</div>
</ScenePanel>
);
};
/**
* Scene 3: signed in, with an expanding pulse ring, a popping green circle,
* a drawn checkmark and the signed-in member's email.
*/
const SuccessScene = () => {
const { user } = useSession();
return (
<ScenePanel>
<div className="relative h-10 w-10">
<motion.div
initial={{ scale: 0.7, opacity: 0.85 }}
animate={{ scale: 2.1, opacity: 0 }}
transition={{ duration: 1.1, ease: 'easeOut' }}
className="absolute inset-0 rounded-full border-2"
style={{ borderColor: '#A2E771' }}
/>
<motion.div
initial={{ scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={POP}
className="absolute inset-0 flex items-center justify-center rounded-full bg-documenso-200"
>
<svg
width={19}
height={19}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
className="text-documenso-900"
>
<motion.path
d="M20 6 9 17l-5-5"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.12, ease: 'easeOut' }}
/>
</svg>
</motion.div>
</div>
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.16 }}
className="mt-3.5 font-semibold text-sm"
>
<Trans>Signed in</Trans>
</motion.p>
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.24 }}
className="mt-1 font-mono text-muted-foreground text-xs"
>
{user.email}
</motion.p>
</ScenePanel>
);
};
/**
* Milliseconds each scene is shown before advancing: portal, redirect,
* success.
*/
const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000];
@@ -0,0 +1,47 @@
import { useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
/**
* Cycles an index through `durations.length` steps, waiting `durations[i]`
* milliseconds on step `i` before advancing to the next.
*
* When the cycle wraps past the last step it continues from `loopStartIndex`
* (default `0`), letting consumers play intro-only steps exactly once and
* then loop through the remaining steps forever.
*
* Under `prefers-reduced-motion` the cycle never starts and the index stays
* at 0, so consumers render their initial state statically.
*
* Pass module-level constants for `durations` and `loopStartIndex` — their
* identities are intentionally not dependencies.
*/
export const useTimedCycle = (durations: number[], loopStartIndex = 0) => {
const [index, setIndex] = useState(0);
const isReducedMotion = useReducedMotion();
useEffect(() => {
if (isReducedMotion || durations.length === 0) {
setIndex(0);
return;
}
let current = 0;
let timeout: ReturnType<typeof setTimeout>;
const tick = () => {
const next = current + 1;
current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next;
setIndex(current);
timeout = setTimeout(tick, durations[current]);
};
timeout = setTimeout(tick, durations[0]);
return () => clearTimeout(timeout);
}, [isReducedMotion]);
return index;
};
@@ -1,94 +0,0 @@
import type { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
export type TeamEmailDropdownProps = {
team: Awaited<ReturnType<typeof getTeamWithEmail>>;
};
export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
trpc.team.email.verification.resend.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Email verification has been resent`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to resend verification at this time. Please try again.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!team.teamEmail && team.emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<Loader className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
<Trans>Resend verification</Trans>
</DropdownMenuItem>
)}
{team.teamEmail && (
<TeamEmailUpdateDialog
teamEmail={team.teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
}
/>
)}
<TeamEmailDeleteDialog
team={team}
teamName={team.name}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<X className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,211 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { getSettingsNavGroups, type SettingsNavGroup, type SettingsNavItem } from '@documenso/lib/utils/settings-nav';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useEffect, useRef } from 'react';
import { Link, Outlet, useLocation } from 'react-router';
import { match } from 'ts-pattern';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useOptionalCurrentTeam } from '~/providers/team';
import { SettingsScopeBreadcrumb } from './settings-scope-breadcrumb';
import { UnifiedSettingsSidebar } from './unified-settings-sidebar';
import { UnifiedSettingsSidebarMobile } from './unified-settings-sidebar-mobile';
export type UnifiedSettingsScope = 'organisation' | 'team' | 'account';
export type UnifiedSettingsLayoutProps = {
activeScope: UnifiedSettingsScope;
/**
* The team the user last worked in, read from the `preferred-team-url` cookie by the
* layout's loader. Used to keep the sidebar's team switcher stable at organisation and
* account scope, where the URL carries no team.
*/
preferredTeamUrl?: string | null;
};
/**
* Walk a group's items and find the item (and its parent if it's a sub-nav child)
* that matches the current pathname most specifically. Returns the labels to use
* as breadcrumb crumbs (e.g. ['Preferences', 'Document'] or just ['Members']).
*/
const findActiveCrumbs = (group: SettingsNavGroup | null, pathname: string): MessageDescriptor[] => {
if (!group) {
return [];
}
let bestMatch: SettingsNavItem | null = null;
for (const item of group.items) {
if (item.isSubNavParent) {
continue;
}
if (pathname === item.path || pathname.startsWith(`${item.path}/`)) {
if (!bestMatch || item.path.length > bestMatch.path.length) {
bestMatch = item;
}
}
}
if (!bestMatch) {
return [];
}
if (bestMatch.isSubNav) {
const parent = group.items.find((it) => it.isSubNavParent);
return parent ? [parent.label, bestMatch.label] : [bestMatch.label];
}
return [bestMatch.label];
};
export const UnifiedSettingsLayout = ({ activeScope, preferredTeamUrl = null }: UnifiedSettingsLayoutProps) => {
const { _ } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const currentOrganisation = useOptionalCurrentOrganisation();
const team = useOptionalCurrentTeam();
const contentPaneRef = useRef<HTMLElement>(null);
// Scroll back to the top when navigating between settings pages.
useEffect(() => {
contentPaneRef.current?.scrollTo(0, 0);
}, [pathname]);
// An organisation is worth showing in the sidebar if it has settings the user can reach —
// either the organisation's own, or those of a team inside it.
const hasReachableSettings = (org: (typeof organisations)[number]) =>
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
org.teams.some((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
const organisation =
currentOrganisation ??
organisations.find((org) => org.teams.some((t) => t.url === preferredTeamUrl) && hasReachableSettings(org)) ??
organisations.find(hasReachableSettings) ??
null;
const manageableTeams = organisation?.teams.filter((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
const teamForSidebar =
team ?? manageableTeams?.find((t) => t.url === preferredTeamUrl) ?? manageableTeams?.[0] ?? null;
const sidebarTeamUrl = teamForSidebar?.url ?? null;
// Sync the selected team URL in the sidebar into the preferred team URL cookie.
useEffect(() => {
if (!sidebarTeamUrl) {
return;
}
const body = new FormData();
body.append('teamUrl', sidebarTeamUrl);
void fetch('/api/preferred-team', { method: 'POST', body });
}, [sidebarTeamUrl]);
const groups = getSettingsNavGroups({
organisation: organisation
? {
url: organisation.url,
currentOrganisationRole: organisation.currentOrganisationRole,
organisationClaim: organisation.organisationClaim,
}
: null,
team: teamForSidebar ? { url: teamForSidebar.url, currentTeamRole: teamForSidebar.currentTeamRole } : null,
hasManageableBillingOrgs: organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
),
});
const canManageOrg =
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
// Must be derived from the team in the URL context, NOT from `teamForSidebar` — the
// latter falls back to any manageable team in the org, which would let a team manager
// through the organisation-scope guard (and `useOptionalCurrentTeam()` resolves for any
// member regardless of role, which would let a plain member through the team guard).
const canManageCurrentTeam = team !== null && canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole);
// Account pages are available to every user. The organisation and team scopes each
// require the manage permission for THAT scope — they are not interchangeable.
const isAuthorised = match(activeScope)
.with('account', () => true)
.with('organisation', () => canManageOrg)
.with('team', () => canManageCurrentTeam)
.exhaustive();
if (!isAuthorised) {
return (
<GenericErrorLayout
errorCode={401}
errorCodeMap={{
401: {
heading: msg`Unauthorized`,
subHeading: msg`401 Unauthorized`,
message: msg`You are not authorized to access this page.`,
},
}}
primaryButton={
<Button asChild>
<Link to="/settings/profile">
<Trans>Go to your settings</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
const scopeName = match(activeScope)
.with('account', () => _(msg`Account`))
.with('organisation', () => organisation?.name ?? '')
.with('team', () => team?.name ?? organisation?.name ?? '')
.exhaustive();
const activeGroup =
activeScope === 'account' ? groups.account : activeScope === 'organisation' ? groups.organisation : groups.team;
const crumbs = findActiveCrumbs(activeGroup, pathname).map((label) => _(label));
return (
<div className="flex flex-col md:min-h-0 md:flex-1 md:flex-row">
<aside className="hover-scrollbar w-full shrink-0 border-b bg-background md:w-80 md:overflow-y-auto md:border-r md:border-b-0">
<div className="flex h-full flex-col">
<div className="hidden md:block">
<UnifiedSettingsSidebar
groups={groups}
currentOrgUrl={organisation?.url ?? null}
currentTeamUrl={teamForSidebar?.url ?? null}
/>
</div>
<div className="md:hidden">
<UnifiedSettingsSidebarMobile
groups={groups}
activeScope={activeScope}
currentOrgUrl={organisation?.url ?? null}
currentTeamUrl={teamForSidebar?.url ?? null}
/>
</div>
</div>
</aside>
<main ref={contentPaneRef} className="relative flex-1 px-4 md:overflow-y-auto md:px-12 lg:px-16">
<div className="mx-auto w-full max-w-3xl py-6 md:py-8" data-testid="unified-settings-content">
<SettingsScopeBreadcrumb scope={activeScope} scopeName={scopeName} crumbs={crumbs} />
<Outlet />
</div>
</main>
</div>
);
};
@@ -0,0 +1,165 @@
import type { SettingsNavGroups, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
import { cn } from '@documenso/ui/lib/utils';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { SettingsOrgSwitcher } from './settings-org-switcher';
import { SettingsTeamSwitcher } from './settings-team-switcher';
type MobileScope = SettingsNavScope | 'account';
export type UnifiedSettingsSidebarMobileProps = {
groups: SettingsNavGroups;
activeScope: MobileScope;
currentOrgUrl: string | null;
currentTeamUrl: string | null;
};
export const UnifiedSettingsSidebarMobile = ({
groups,
activeScope,
currentOrgUrl,
currentTeamUrl,
}: UnifiedSettingsSidebarMobileProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const navigate = useNavigate();
const visibleScopes = useMemo<MobileScope[]>(() => {
const scopes: MobileScope[] = [];
if (groups.organisation) {
scopes.push('organisation');
}
if (groups.team) {
scopes.push('team');
}
scopes.push('account');
return scopes;
}, [groups]);
// Falls back to the Account group rather than rendering nothing — an empty scope group
// would leave the mobile viewport with no settings navigation at all.
const activeGroup =
(activeScope === 'organisation' ? groups.organisation : activeScope === 'team' ? groups.team : null) ??
groups.account;
const selectableItems = useMemo(() => activeGroup.items.filter((item) => !item.isSubNavParent), [activeGroup.items]);
// The select matches on exact value, but plenty of settings pages are sub-routes of a
// nav item (`/settings/security/passkeys`, `/t/x/settings/webhooks/:id`, …). Resolving
// to the longest matching item path keeps the trigger populated on those pages instead
// of rendering an empty box — mirrors the desktop sidebar's prefix highlighting.
const selectedPath = useMemo(() => {
let bestMatch: string | undefined;
for (const item of selectableItems) {
if (pathname !== item.path && !pathname.startsWith(`${item.path}/`)) {
continue;
}
if (!bestMatch || item.path.length > bestMatch.length) {
bestMatch = item.path;
}
}
return bestMatch;
}, [selectableItems, pathname]);
const handleScopeChange = (scope: MobileScope) => {
if (scope === activeScope) {
return;
}
if (scope === 'organisation' && groups.organisation) {
void navigate(groups.organisation.items[0].path);
} else if (scope === 'team' && groups.team) {
void navigate(groups.team.items[0].path);
} else if (scope === 'account') {
void navigate(groups.account.items[0].path);
}
};
// The tab row stays full-bleed (its border-b acts as a full-width divider); the
// sections under it get `px-4` to line up with the content pane's own `px-4`
// inset, and `pb-4` keeps the last control off the aside's bottom border.
return (
<div className="flex flex-col gap-3 pb-4" data-testid="unified-settings-sidebar-mobile">
{visibleScopes.length > 1 ? (
<div className="flex border-border border-b" role="tablist">
{visibleScopes.map((scope) => {
const isActive = scope === activeScope;
const accent =
scope === 'organisation'
? 'border-emerald-500 text-emerald-700 dark:text-emerald-300'
: scope === 'team'
? 'border-blue-500 text-blue-700 dark:text-blue-300'
: 'border-foreground text-foreground';
return (
<button
key={scope}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleScopeChange(scope)}
className={cn(
'flex-1 border-b-2 py-2 text-center font-bold text-xs uppercase tracking-wide',
isActive ? accent : 'border-transparent text-muted-foreground',
)}
data-testid={`unified-settings-mobile-tab-${scope}`}
>
{scope === 'organisation' && <Trans>Organisation</Trans>}
{scope === 'team' && <Trans>Team</Trans>}
{scope === 'account' && <Trans>Account</Trans>}
</button>
);
})}
</div>
) : (
<div className="px-4 py-2 text-center font-bold text-muted-foreground text-xs uppercase tracking-wide">
{activeScope === 'organisation' && <Trans>Organisation</Trans>}
{activeScope === 'team' && <Trans>Team</Trans>}
{activeScope === 'account' && <Trans>Account</Trans>}
</div>
)}
{/* The organisation switcher is always present in a scoped view — at team scope it's
the only way for a user who can't manage the organisation to move between them,
since the Organisation tab is absent when they have no organisation pages. */}
{activeScope !== 'account' && currentOrgUrl && (
<div className="flex flex-col gap-2 px-4">
<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />
{activeScope === 'team' && (
<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />
)}
</div>
)}
<div className="px-4">
<div className="mb-1 font-bold text-[10px] text-muted-foreground uppercase tracking-wide">
<Trans>Jump to</Trans>
</div>
<Select
value={selectedPath}
onValueChange={(value) => {
void navigate(value);
}}
>
<SelectTrigger data-testid="unified-settings-mobile-section-trigger">
<SelectValue placeholder={_(msg`Select a section`)} />
</SelectTrigger>
<SelectContent>
{selectableItems.map((item) => (
<SelectItem key={item.path} value={item.path}>
{item.isSubNav ? `${_(msg`Preferences`)} ${_(item.label)}` : _(item.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
};
@@ -0,0 +1,275 @@
import type { SettingsNavGroups, SettingsNavItem, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@documenso/ui/primitives/collapsible';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronRightIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { NavLink, useLocation } from 'react-router';
import { SettingsOrgSwitcher } from './settings-org-switcher';
import { SettingsTeamSwitcher } from './settings-team-switcher';
export type UnifiedSettingsSidebarProps = {
groups: SettingsNavGroups;
currentOrgUrl: string | null;
currentTeamUrl: string | null;
};
export const UnifiedSettingsSidebar = ({ groups, currentOrgUrl, currentTeamUrl }: UnifiedSettingsSidebarProps) => {
return (
<aside className="flex w-full flex-col" data-testid="unified-settings-sidebar">
{currentOrgUrl && (
<div className="p-4">
<SidebarGroup
heading={<Trans>Organisation Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
switcher={<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />}
items={groups.organisation?.items ?? []}
scope="organisation"
emptyState={
<p
className="rounded-md border border-dashed px-3 py-2 text-muted-foreground text-xs"
data-testid="unified-settings-organisation-empty-state"
>
<Trans>
You don't have permission to manage this organisation. Switch to another one above, or continue in
your team settings below.
</Trans>
</p>
}
/>
</div>
)}
{groups.team && currentOrgUrl && (
<div className="border-t p-4">
<SidebarGroup
heading={<Trans>Team Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
switcher={<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />}
items={groups.team.items}
scope={groups.team.scope}
/>
</div>
)}
<div className={cn('p-4', currentOrgUrl && 'border-t')}>
<SidebarGroup
heading={<Trans>Account Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
items={groups.account.items}
scope={groups.account.scope}
/>
</div>
</aside>
);
};
type SidebarGroupProps = {
className?: string;
headingClassName?: string;
heading: ReactNode;
activeBgClassName: string;
switcher?: ReactNode;
items: SettingsNavItem[];
scope: SettingsNavScope;
emptyState?: ReactNode;
};
type GroupedNavEntry =
| { kind: 'flat'; item: SettingsNavItem }
| { kind: 'collapsible'; parent: SettingsNavItem; children: SettingsNavItem[] };
/**
* Walk a flat item list and group consecutive `isSubNav` items under their preceding
* `isSubNavParent`. Anything else renders as a flat entry.
*/
const groupNavEntries = (items: SettingsNavItem[]): GroupedNavEntry[] => {
const entries: GroupedNavEntry[] = [];
let currentCollapsible: { parent: SettingsNavItem; children: SettingsNavItem[] } | null = null;
for (const item of items) {
if (item.isSubNavParent) {
currentCollapsible = { parent: item, children: [] };
entries.push({ kind: 'collapsible', ...currentCollapsible });
continue;
}
if (item.isSubNav && currentCollapsible) {
currentCollapsible.children.push(item);
continue;
}
currentCollapsible = null;
entries.push({ kind: 'flat', item });
}
return entries;
};
const SidebarGroup = ({
className,
headingClassName,
heading,
activeBgClassName,
switcher,
items,
scope,
emptyState,
}: SidebarGroupProps) => {
const grouped = groupNavEntries(items);
return (
<div className={className} data-testid="unified-settings-sidebar-group">
<div
className={cn('mb-2 font-semibold text-muted-foreground text-xs uppercase tracking-widest', headingClassName)}
>
{heading}
</div>
{switcher && <div className="mb-2">{switcher}</div>}
{items.length === 0 && emptyState}
<nav className="flex flex-col gap-1">
{grouped.map((entry) => {
if (entry.kind === 'flat') {
return (
<FlatNavItem
key={`${entry.item.key}-${entry.item.path}`}
item={entry.item}
activeBgClassName={activeBgClassName}
scope={scope}
/>
);
}
return (
<CollapsibleNavSection
key={`${entry.parent.key}-${entry.parent.path}`}
parent={entry.parent}
childItems={entry.children}
activeBgClassName={activeBgClassName}
scope={scope}
/>
);
})}
</nav>
</div>
);
};
type FlatNavItemProps = {
item: SettingsNavItem;
activeBgClassName: string;
scope: SettingsNavScope;
};
const FlatNavItem = ({ item, activeBgClassName, scope }: FlatNavItemProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
// The General items link to the settings root, which prefixes every other
// item's path those require an exact match. Everything else highlights on
// its sub-routes too (e.g. Security on /settings/security/passkeys).
const isActive =
item.key === 'general' ? pathname === item.path : pathname === item.path || pathname.startsWith(`${item.path}/`);
return (
<NavLink to={item.path} className="group block" data-testid={`unified-settings-nav-${scope}-${item.key}`} end>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
isActive && cn(activeBgClassName, 'font-medium'),
)}
>
{item.icon && <item.icon className="mr-2 h-4 w-4" />}
{_(item.label)}
</Button>
</NavLink>
);
};
type CollapsibleNavSectionProps = {
parent: SettingsNavItem;
childItems: SettingsNavItem[];
activeBgClassName: string;
scope: SettingsNavScope;
};
const CollapsibleNavSection = ({ parent, childItems, activeBgClassName, scope }: CollapsibleNavSectionProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const hasActiveChild = childItems.some((child) => pathname === child.path || pathname.startsWith(`${child.path}/`));
const [isOpen, setIsOpen] = useState(hasActiveChild);
// Auto-open when navigating to a sub-route. Closing while on a sub-route is
// respected (state stays closed) until the user navigates away and back.
useEffect(() => {
if (hasActiveChild) {
setIsOpen(true);
}
}, [hasActiveChild]);
const ParentIcon = parent.icon ? parent.icon : null;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
// Emphasise the parent when one of its children is the active page.
hasActiveChild && 'font-medium text-foreground',
)}
data-testid={`unified-settings-nav-${scope}-${parent.key}`}
aria-expanded={isOpen}
>
{ParentIcon && <ParentIcon className="mr-2 h-4 w-4" />}
{_(parent.label)}
<ChevronRightIcon
className={cn('ml-auto h-4 w-4 transition-transform duration-200', isOpen && 'rotate-90')}
/>
</Button>
</CollapsibleTrigger>
{/* data-[state=closed]:hidden — the `flex` display class would otherwise
defeat the `hidden` attribute Radix sets when closed, leaving the
empty content box (and its mt-1) inflating the gap below the trigger. */}
<CollapsibleContent className="mt-1 flex flex-col gap-1 data-[state=closed]:hidden">
{childItems.map((child) => {
const isActive = pathname === child.path || pathname.startsWith(`${child.path}/`);
return (
<NavLink
key={`${child.key}-${child.path}`}
to={child.path}
className="group block pl-6"
data-testid={`unified-settings-nav-${scope}-${child.key}`}
>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
isActive && cn(activeBgClassName, 'font-medium'),
)}
>
{_(child.label)}
</Button>
</NavLink>
);
})}
</CollapsibleContent>
</Collapsible>
);
};
@@ -0,0 +1,40 @@
import { Trans } from '@lingui/react/macro';
import { CalendarIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params';
const PERIOD_OPTIONS = [
{ value: '7d', label: <Trans>Last 7 days</Trans> },
{ value: '14d', label: <Trans>Last 14 days</Trans> },
{ value: '30d', label: <Trans>Last 30 days</Trans> },
];
export const DocumentsTablePeriodFilter = () => {
const [{ period }, setSearchParams] = useQueryStates(
{
period: documentsSearchParams.period,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const onChange = (newPeriod: string | null) => {
void setSearchParams({
period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null,
page: null,
});
};
return (
<FilterPill
icon={CalendarIcon}
label={<Trans>Period</Trans>}
value={period}
onChange={onChange}
options={PERIOD_OPTIONS}
testId="documents-table-period-filter"
/>
);
};
@@ -1,63 +1,61 @@
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { trpc } from '@documenso/trpc/react';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { UserIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableSenderFilterProps = {
teamId: number;
};
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { _ } = useLingui();
const isMounted = useIsMounted();
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
const [{ senderIds }, setSearchParams] = useQueryStates(
{
senderIds: documentsSearchParams.senderIds,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString());
const { data, isLoading } = trpc.team.member.getMany.useQuery({
teamId,
});
const comboBoxOptions = (data ?? []).map((member) => ({
const options = (data ?? []).map((member) => ({
label: member.name ?? member.email,
value: member.userId.toString(),
}));
const onChange = (newSenderIds: string[]) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('senderIds', newSenderIds.join(','));
if (newSenderIds.length === 0) {
params.delete('senderIds');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
void setSearchParams({
senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null,
page: null,
});
};
return (
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="font-normal text-muted-foreground">
<Trans>
<span className="text-muted-foreground/70">Sender:</span> All
</Trans>
</p>
}
enableClearAllButton={true}
inputPlaceholder={msg`Search`}
loading={!isMounted || isLoading}
options={comboBoxOptions}
selectedValues={senderIds}
<FilterPill
multiple
icon={UserIcon}
label={<Trans>Sender</Trans>}
value={selectedSenderIds}
onChange={onChange}
options={options}
enableSearch
searchPlaceholder={_(msg`Search members...`)}
loading={!isMounted || isLoading}
testId="documents-table-sender-filter"
/>
);
};
@@ -0,0 +1,98 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationType } from '@prisma/client';
import { ListFilterIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useMemo } from 'react';
import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
stats: TFindDocumentsInternalResponse['stats'];
};
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
status: documentsSearchParams.status,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const selectableStatuses = useMemo(
() =>
SELECTABLE_STATUSES.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
[organisation.type],
);
const selectedStatus = useMemo(
() => selectableStatuses.find((value) => value === status) ?? null,
[selectableStatuses, status],
);
const onChange = (newStatus: string | null) => {
void setSearchParams({
status: selectableStatuses.find((value) => value === newStatus) ?? null,
page: null,
});
};
return (
<>
<FilterPill
icon={ListFilterIcon}
label={<Trans>Status</Trans>}
value={selectedStatus}
onChange={onChange}
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: formatStatsCount(stats[value]),
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
</span>
))}
</span>
</>
);
};
const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.EXPIRED,
];
const formatStatsCount = (count: number) => {
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
};
@@ -1,9 +1,11 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { useEffect } from 'react';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onDownloadClick?: () => void;
onMoveClick: () => void;
onDeleteClick: () => void;
onCancelClick?: () => void;
@@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = {
export const EnvelopesTableBulkActionBar = ({
selectedCount,
onDownloadClick,
onMoveClick,
onDeleteClick,
onCancelClick,
@@ -19,37 +22,106 @@ export const EnvelopesTableBulkActionBar = ({
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
useEffect(() => {
if (selectedCount === 0) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
// Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault
// when handling Escape, so this only clears the selection when nothing
// else consumed the key press.
if (event.key === 'Escape' && !event.defaultPrevented) {
onClearSelection();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [selectedCount, onClearSelection]);
if (selectedCount === 0) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="font-medium text-sm">
<Trans>{selectedCount} selected</Trans>
</span>
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
<div className="flex items-center gap-x-2 px-2">
<span className="sr-only" aria-live="polite">
<Trans>{selectedCount} selected</Trans>
</span>
<span
aria-hidden="true"
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
>
{selectedCount}
</span>
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
<Trans>selected</Trans>
</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="mx-1 h-5 w-px bg-border" />
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
<FolderInputIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onMoveClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<FolderInputIcon className="size-4 shrink-0" />
<Trans>Move</Trans>
</Button>
{onDownloadClick && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDownloadClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<DownloadIcon className="size-4 shrink-0" />
<Trans>Download</Trans>
</Button>
)}
{onCancelClick && (
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
<XCircleIcon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancelClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<XCircleIcon className="size-4 shrink-0" />
<Trans>Cancel</Trans>
</Button>
)}
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDeleteClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2Icon className="size-4 shrink-0" />
<Trans>Delete</Trans>
</Button>
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
<XIcon className="h-4 w-4" />
<div className="mx-1 h-5 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClearSelection}
aria-label={t`Clear selection`}
className="h-8 w-8 p-0"
>
<XIcon className="size-4 shrink-0" />
</Button>
</div>
);
@@ -76,7 +76,7 @@ export const OrganisationTeamsTable = () => {
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button variant="outline" asChild>
<Link to={`/t/${row.original.url}/settings`}>
<Link to={`/t/${row.original.url}/settings/general`}>
<Trans>Manage</Trans>
</Link>
</Button>
@@ -2,7 +2,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@@ -29,8 +29,6 @@ export const UserOrganisationsTable = () => {
})),
});
const isPersonalLayoutMode = isPersonalLayout(data);
const results = {
data: data || [],
perPage: 10,
@@ -44,31 +42,13 @@ export const UserOrganisationsTable = () => {
header: _(msg`Organisation`),
accessorKey: 'name',
cell: ({ row }) => (
<Link
to={isPersonalLayoutMode ? `/settings/organisations` : `/o/${row.original.url}`}
preventScrollReset={true}
>
<Link to={`/o/${row.original.url}`} preventScrollReset={true}>
<AvatarWithText
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
avatarClass="h-12 w-12"
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={
<span className="font-semibold text-foreground/80">
{isPersonalLayoutMode
? _(
msg({
message: `Personal`,
context: `Personal organisation (adjective)`,
}),
)
: row.original.name}
</span>
}
secondaryText={
isPersonalLayoutMode
? _(msg`Your personal organisation`)
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`
}
primaryText={<span className="font-semibold text-foreground/80">{row.original.name}</span>}
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`}
/>
</Link>
),
@@ -92,7 +72,7 @@ export const UserOrganisationsTable = () => {
<div className="flex justify-end space-x-2">
{canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && (
<Button variant="outline" asChild>
<Link to={`/o/${row.original.url}/settings`}>
<Link to={`/o/${row.original.url}/settings/general`}>
<Trans>Manage</Trans>
</Link>
</Button>
@@ -117,7 +97,7 @@ export const UserOrganisationsTable = () => {
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, [isPersonalLayoutMode]);
}, []);
return (
<div>
@@ -130,9 +110,6 @@ export const UserOrganisationsTable = () => {
error={{
enable: isLoadingError,
}}
columnVisibility={{
actions: !isPersonalLayoutMode,
}}
skeleton={{
enable: isLoading,
rows: 3,
@@ -154,14 +131,12 @@ export const UserOrganisationsTable = () => {
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
{!isPersonalLayoutMode && (
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
<Skeleton className="h-10 w-16 rounded" />
</div>
</TableCell>
)}
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
<Skeleton className="h-10 w-16 rounded" />
</div>
</TableCell>
</>
),
}}
+10 -7
View File
@@ -1,5 +1,6 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { getBasePath } from '@documenso/lib/constants/app';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { createPublicEnv } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
@@ -20,7 +21,6 @@ import {
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
@@ -68,6 +68,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
lang,
theme: getTheme(),
disableAnimations,
basePath: getBasePath(),
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
@@ -90,10 +91,10 @@ export async function loader({ context, request }: Route.LoaderArgs) {
}
export function Layout({ children }: { children: React.ReactNode }) {
const { theme } = useLoaderData<typeof loader>() || {};
const { theme, basePath } = useLoaderData<typeof loader>() || {};
return (
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
<ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
<LayoutContent>{children}</LayoutContent>
</ThemeProvider>
);
@@ -111,6 +112,8 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const [theme] = useTheme();
const basePath = data.basePath ?? '';
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
// <style> block from `RecipientBranding` applies to BOTH the main tree and
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
@@ -126,11 +129,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
<meta name="google" content="notranslate" />
<Meta />
<Links nonce={nonce(cspNonce)} />
@@ -1,4 +1,5 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
@@ -46,6 +47,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
const { user, organisations } = useSession();
const { layoutMode } = useChildRouteFlags();
const teamUrl = params.teamUrl;
const orgUrl = params.orgUrl;
@@ -108,23 +111,26 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
return (
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={currentTeam || null}>
<OrganisationBillingBanner />
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
<OrganisationBillingBanner />
<OrganisationQuotaBanner />
<OrganisationQuotaBanner />
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && !hideHeader && <AppBanner banner={banner} />}
{banner && !hideHeader && <AppBanner banner={banner} />}
{!hideHeader && <Header />}
{!hideHeader && <Header fullWidth={layoutMode === 'settings'} />}
<main
className={cn({
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader,
})}
>
<Outlet />
</main>
<main
className={cn({
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader && layoutMode !== 'settings',
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
})}
>
<Outlet />
</main>
</div>
</TeamProvider>
</OrganisationProvider>
);
@@ -53,7 +53,7 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
return (
<div>
<SettingsHeader title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`} hideDivider>
<SettingsHeader hideDivider title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`}>
<ClaimCreateDialog licenseFlags={licenseFlags} />
</SettingsHeader>
@@ -40,7 +40,7 @@ export default function AdminEmailTransportsPage() {
return (
<div>
<SettingsHeader title={t`Email Transports`} subtitle={t`Manage all email transports`} hideDivider>
<SettingsHeader hideDivider title={t`Email Transports`} subtitle={t`Manage all email transports`}>
<EmailTransportCreateDialog />
</SettingsHeader>
@@ -258,7 +258,11 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
return (
<div>
<SettingsHeader title={t`Manage organisation`} subtitle={t`Manage the ${organisation.name} organisation`}>
<SettingsHeader
hideDivider
title={t`Manage organisation`}
subtitle={t`Manage the ${organisation.name} organisation`}
>
<Button variant="outline" asChild>
<Link to={`/admin/organisation-insights/${organisationId}`}>
<Trans>View insights</Trans>
@@ -269,10 +273,10 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<SettingsHeader
hideDivider
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<OrganisationUsagePanel
@@ -308,6 +312,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
</div>
<SettingsHeader
hideDivider
title={t`Manage subscription`}
subtitle={t`Manage the ${organisation.name} organisation subscription`}
className="mt-16"
@@ -425,6 +430,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
</div>
<SettingsHeader
hideDivider
title={t`Danger Zone`}
subtitle={t`Irreversible actions for this organisation`}
className="mt-16"
@@ -143,7 +143,7 @@ export default function DashboardPage() {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<Link to={`/o/${org.url}/settings`}>
<Link to={`/o/${org.url}/settings/general`}>
<SettingsIcon className="h-4 w-4" />
</Link>
</div>
@@ -212,7 +212,7 @@ export default function DashboardPage() {
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<Link to={`/t/${team.url}/settings`}>
<Link to={`/t/${team.url}/settings/general`}>
<SettingsIcon className="h-4 w-4" />
</Link>
</div>
@@ -115,7 +115,7 @@ export default function OrganisationSettingsTeamsPage() {
</div>
<Button asChild>
<Link to={`/o/${organisation.url}/settings`}>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
@@ -184,7 +184,7 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/t/${team.url}/settings`}>
<Link to={`/t/${team.url}/settings/general`}>
<SettingsIcon className="mr-2 h-4 w-4" />
<Trans>Settings</Trans>
</Link>
@@ -1,13 +1,23 @@
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { cn } from '@documenso/ui/lib/utils';
import { Outlet } from 'react-router';
export default function Layout() {
const currentOrganisation = useCurrentOrganisation();
const { layoutMode } = useChildRouteFlags();
// Note: We use a key to force a re-render if the team context changes.
// This is required otherwise you would see the wrong page content.
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8" key={currentOrganisation.url}>
<div
className={cn({
'mx-auto w-full max-w-screen-xl px-4 md:px-8': layoutMode !== 'settings',
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
})}
key={currentOrganisation.url}
>
<Outlet />
</div>
);
@@ -1,11 +0,0 @@
import { redirect } from 'react-router';
import type { Route } from './+types/_layout';
export function loader({ params }: Route.LoaderArgs) {
if (params.orgUrl) {
throw redirect(`/o/${params.orgUrl}/settings/general`);
}
throw redirect('/');
}
@@ -1,173 +1,45 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import { extractCookieFromDocument } from '@documenso/lib/client-only/cookies';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import {
Building2Icon,
CreditCardIcon,
GroupIcon,
MailboxIcon,
Settings2Icon,
ShieldCheckIcon,
Users2Icon,
} from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
import { Link, NavLink, Outlet } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/o.$orgUrl.settings._layout';
export function meta() {
return appMetaTags(msg`Organisation Settings`);
}
export default function SettingsLayout() {
const { t } = useLingui();
export const handle: RouteHandle = {
layoutMode: 'settings',
};
const isBillingEnabled = IS_BILLING_ENABLED();
const organisation = useCurrentOrganisation();
const organisationSettingRoutes = [
{
path: `/o/${organisation.url}/settings/general`,
label: t`General`,
icon: Building2Icon,
},
{
path: `/o/${organisation.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
hideHighlight: true,
},
{
path: `/o/${organisation.url}/settings/document`,
label: t`Document`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/branding`,
label: t`Branding`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/email`,
label: t`Email`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/email-domains`,
label: t`Email Domains`,
icon: MailboxIcon,
},
{
path: `/o/${organisation.url}/settings/teams`,
label: t`Teams`,
icon: FaUsers,
},
{
path: `/o/${organisation.url}/settings/members`,
label: t`Members`,
icon: Users2Icon,
},
{
path: `/o/${organisation.url}/settings/groups`,
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/o/${organisation.url}/settings/sso`,
label: t`SSO`,
icon: ShieldCheckIcon,
},
{
path: `/o/${organisation.url}/settings/billing`,
label: t`Billing`,
icon: CreditCardIcon,
},
].filter((route) => {
if (!isBillingEnabled && route.path.includes('/billing')) {
return false;
}
if (
(!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) &&
route.path.includes('/email-domains')
) {
return false;
}
if (
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
route.path.includes('/sso')
) {
return false;
}
return true;
});
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
return (
<GenericErrorLayout
errorCode={401}
errorCodeMap={{
401: {
heading: msg`Unauthorized`,
subHeading: msg`401 Unauthorized`,
message: msg`You are not authorized to access this page.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/o/${organisation.url}`}>
<Trans>Go Back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div>
<h1 className="font-semibold text-4xl">
<Trans>Organisation Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
{/* Navigation */}
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{organisationSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.hideHighlight,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
/**
* Only runs on the initial document request (SSR) so the first paint has the
* correct preferred team without a hydration mismatch.
*/
export function loader({ request }: Route.LoaderArgs) {
return {
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
};
}
/**
* Runs instead of the server loader on client-side navigations, otherwise every
* settings page switch would trigger a `.data` round-trip to the server just to
* read this cookie.
*
* The cookie is not `HttpOnly` so it can be read straight from the document.
*/
export function clientLoader() {
return {
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
};
}
export default function OrganisationSettingsLayout({ loaderData }: Route.ComponentProps) {
return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />;
}
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@@ -18,6 +17,7 @@ import {
type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -26,16 +26,12 @@ export function meta() {
}
export default function OrganisationSettingsBrandingPage() {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const team = useOptionalCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
const {
@@ -122,17 +118,22 @@ export default function OrganisationSettingsBrandingPage() {
const settingsHeaderText = t`Branding Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general branding preferences.`
: team
? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
const settingsHeaderSubtitle = team
? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
const brandingPreferencesFormEnabled =
organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
return (
<div className="max-w-2xl">
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
<div>
<SettingsHeader
title={settingsHeaderText}
subtitle={settingsHeaderSubtitle}
hideDivider={!brandingPreferencesFormEnabled}
/>
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
{brandingPreferencesFormEnabled ? (
<section>
<BrandingPreferencesForm
context="Organisation"
@@ -167,6 +168,8 @@ export default function OrganisationSettingsBrandingPage() {
</Alert>
)}
</section>
) : IS_DOCUMENSO_CLOUD() ? (
<BrandingUpsell />
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -181,7 +184,7 @@ export default function OrganisationSettingsBrandingPage() {
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Update Billing</Trans>
</Link>
</Button>
@@ -0,0 +1,80 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import {
CertificatePreferencesForm,
type TCertificatePreferencesFormSchema,
} from '~/components/forms/certificate-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
export default function OrganisationSettingsCertificatesPage() {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const onCertificatePreferencesFormSubmit = async (data: TCertificatePreferencesFormSchema) => {
try {
const { includeSigningCertificate, includeAuditLog } = data;
if (includeSigningCertificate === null || includeAuditLog === null) {
throw new Error('Should not be possible.');
}
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
includeSigningCertificate,
includeAuditLog,
},
});
toast({
title: t`Certificate preferences updated`,
description: t`Your certificate preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your certificate preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<SettingsHeader
title={t`Certificates`}
subtitle={t`Here you can set certificate and audit log preferences for your organisation. Teams will inherit these settings by default.`}
/>
<section>
<CertificatePreferencesForm
canInherit={false}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onCertificatePreferencesFormSubmit}
/>
</section>
</div>
);
}
@@ -1,14 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import {
DocumentPreferencesForm,
@@ -21,23 +17,12 @@ export function meta() {
return appMetaTags(msg`Document Preferences`);
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function OrganisationSettingsDocumentPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
@@ -51,24 +36,16 @@ export default function OrganisationSettingsDocumentPage() {
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
reminderSettings,
} = data;
if (
documentVisibility === null ||
documentLanguage === null ||
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null ||
includeAuditLog === null ||
aiFeaturesEnabled === null
) {
throw new Error('Should not be possible.');
@@ -81,17 +58,12 @@ export default function OrganisationSettingsDocumentPage() {
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
defaultRecipients,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
},
});
@@ -119,18 +91,15 @@ export default function OrganisationSettingsDocumentPage() {
}
const settingsHeaderText = t`Document Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general document preferences.`
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
const settingsHeaderSubtitle = t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
<section>
<DocumentPreferencesForm
canInherit={false}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onDocumentPreferencesFormSubmit}
/>
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { trpc } from '@documenso/trpc/react';
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
@@ -27,8 +27,9 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
const { t } = useLingui();
@@ -96,10 +97,23 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
}, [organisation]);
const pageHeader = t`Email Domain Settings`;
const pageSubtitle = t`Manage your email domain settings.`;
if (!IS_BILLING_ENABLED()) {
return null;
}
if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) {
return (
<div>
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
<EmailDomainsUpsell />
</div>
);
}
if (isLoadingEmailDomain) {
return <SpinnerBox className="py-32" />;
}
@@ -132,7 +146,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
return (
<div>
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
</SettingsHeader>
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -10,6 +9,7 @@ import { Link } from 'react-router';
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
import { appMetaTags } from '~/utils/meta';
@@ -19,12 +19,9 @@ export function meta() {
export default function OrganisationSettingsEmailDomains() {
const { t } = useLingui();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains;
if (!IS_BILLING_ENABLED()) {
@@ -33,7 +30,11 @@ export default function OrganisationSettingsEmailDomains() {
return (
<div>
<SettingsHeader title={t`Email Domains`} subtitle={t`Here you can add email domains to your organisation.`}>
<SettingsHeader
hideDivider
title={t`Email Domains`}
subtitle={t`Here you can add email domains to your organisation.`}
>
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
</SettingsHeader>
@@ -41,6 +42,8 @@ export default function OrganisationSettingsEmailDomains() {
<section>
<OrganisationEmailDomainsDataTable />
</section>
) : IS_DOCUMENSO_CLOUD() ? (
<EmailDomainsUpsell />
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -55,7 +58,7 @@ export default function OrganisationSettingsEmailDomains() {
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Update Billing</Trans>
</Link>
</Button>
@@ -27,7 +27,7 @@ export default function OrganisationSettingsGeneral() {
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
try {
const { emailId, emailReplyTo, emailDocumentSettings } = data;
const { emailId, emailReplyTo, emailDocumentSettings, includeSenderDetails } = data;
await updateOrganisationSettings({
organisationId: organisation.id,
@@ -36,6 +36,7 @@ export default function OrganisationSettingsGeneral() {
emailReplyTo: emailReplyTo || null,
// emailReplyToName,
emailDocumentSettings,
includeSenderDetails: includeSenderDetails ?? undefined,
},
});
@@ -59,7 +60,7 @@ export default function OrganisationSettingsGeneral() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
<section>
@@ -21,7 +21,7 @@ export default function OrganisationSettingsGeneral() {
const organisation = useCurrentOrganisation();
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={_(msg`General`)} subtitle={_(msg`Here you can edit your organisation details.`)} />
<div className="space-y-8">
@@ -30,23 +30,19 @@ export default function OrganisationSettingsGeneral() {
</div>
{canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
<>
<hr className="my-4" />
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Delete organisation</Trans>
</AlertTitle>
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Delete organisation</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>This organisation, and any associated data will be permanently deleted.</Trans>
</AlertDescription>
</div>
<AlertDescription className="mr-2">
<Trans>This organisation, and any associated data will be permanently deleted.</Trans>
</AlertDescription>
</div>
<OrganisationDeleteDialog />
</Alert>
</>
<OrganisationDeleteDialog />
</Alert>
)}
</div>
);
@@ -94,7 +94,11 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
return (
<div>
<SettingsHeader title={t`Organisation Group Settings`} subtitle={t`Manage your organisation group settings.`}>
<SettingsHeader
hideDivider
title={t`Organisation Group Settings`}
subtitle={t`Manage your organisation group settings.`}
>
<OrganisationGroupDeleteDialog
organisationGroupId={groupId}
organisationGroupName={group.name || ''}
@@ -10,6 +10,7 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader
hideDivider
title={t`Custom Organisation Groups`}
subtitle={t`Manage the custom groups of members for your organisation.`}
>
@@ -46,7 +46,11 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader title={_(msg`Organisation Members`)} subtitle={_(msg`Manage the members or invite new members.`)}>
<SettingsHeader
hideDivider
title={_(msg`Organisation Members`)}
subtitle={_(msg`Manage the members or invite new members.`)}
>
<OrganisationMemberInviteDialog />
</SettingsHeader>
@@ -0,0 +1,76 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import {
ReminderPreferencesForm,
type TReminderPreferencesFormSchema,
} from '~/components/forms/reminder-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
export default function OrganisationSettingsRemindersPage() {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const onReminderPreferencesFormSubmit = async (data: TReminderPreferencesFormSchema) => {
try {
const { envelopeExpirationPeriod, reminderSettings } = data;
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
},
});
toast({
title: t`Reminder preferences updated`,
description: t`Your reminder preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your reminder preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<SettingsHeader
title={t`Reminders`}
subtitle={t`Here you can set expiration and signing reminder preferences for your organisation. Teams will inherit these settings by default.`}
/>
<section>
<ReminderPreferencesForm
canInherit={false}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onReminderPreferencesFormSubmit}
/>
</section>
</div>
);
}
@@ -1,4 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import {
@@ -28,6 +29,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { SettingsHeader } from '~/components/general/settings-header';
import { SsoPortalUpsell } from '~/components/general/settings-upsell/sso-portal-upsell';
import { appMetaTags } from '~/utils/meta';
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
@@ -63,18 +65,42 @@ export default function OrganisationSettingSSOLoginPage() {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
const isAuthenticationPortalEnabled = organisation.organisationClaim.flags.authenticationPortal === true;
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
organisationId: organisation.id,
});
trpc.enterprise.organisation.authenticationPortal.get.useQuery(
{
organisationId: organisation.id,
},
{
// The endpoint rejects orgs without the claim flag, so don't fire
// requests that are guaranteed to error.
enabled: isAuthenticationPortalEnabled,
},
);
if (!isAuthenticationPortalEnabled && IS_DOCUMENSO_CLOUD()) {
return (
<div>
<SettingsHeader
hideDivider
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
<SsoPortalUpsell />
</div>
);
}
if (isLoadingAuthenticationPortal || !authenticationPortal) {
return <SpinnerBox className="py-32" />;
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
hideDivider
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
@@ -35,7 +35,7 @@ export default function OrganisationSettingsTeamsPage() {
return (
<div>
<SettingsHeader title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
<SettingsHeader hideDivider title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
<TeamCreateDialog />
</SettingsHeader>
@@ -1,54 +0,0 @@
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { TrpcProvider } from '@documenso/trpc/react';
import { useEffect } from 'react';
import { Outlet, useNavigate } from 'react-router';
import { TeamProvider } from '~/providers/team';
/**
* These routes should only render if the user has:
*
* - 1 Personal organisation
* - Nothing else
*
* This removes the UX complexity for users who only have a single personal organisation, instead of showing them multiple settings pages:
*
* - Organisation settings
* - Teams settings
*/
export default function Layout() {
const { organisations } = useSession();
const navigate = useNavigate();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const currentOrganisation = organisations[0];
const team = currentOrganisation?.teams[0] || null;
useEffect(() => {
if (!isPersonalLayoutMode || !team) {
void navigate('/settings/profile');
}
}, []);
if (!isPersonalLayoutMode || !team) {
return null;
}
const trpcHeaders = {
'x-team-Id': team.id.toString(),
};
return (
<TrpcProvider headers={trpcHeaders}>
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={team}>
<Outlet />
</TeamProvider>
</OrganisationProvider>
</TrpcProvider>
);
}
@@ -1,5 +0,0 @@
import BillingPage, { meta } from '../../o.$orgUrl.settings.billing';
export { meta };
export default BillingPage;
@@ -1,5 +0,0 @@
import BrandingPage, { meta } from '../../o.$orgUrl.settings.branding';
export { meta };
export default BrandingPage;
@@ -1,5 +0,0 @@
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
export { loader, meta };
export default DocumentPage;
@@ -1,5 +0,0 @@
import EmailPage, { meta } from '../../o.$orgUrl.settings.email';
export { meta };
export default EmailPage;
@@ -1,5 +0,0 @@
import PublicProfilePage, { loader, meta } from '../../t.$teamUrl+/settings.public-profile';
export { loader, meta };
export default PublicProfilePage;
@@ -1,5 +0,0 @@
import TokensPage, { meta } from '../../t.$teamUrl+/settings.tokens';
export { meta };
export default TokensPage;
@@ -1,5 +0,0 @@
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id._index';
export { meta };
export default WebhookPage;
@@ -1,5 +0,0 @@
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks._index';
export { meta };
export default WebhookPage;
@@ -1,5 +0,0 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/settings/profile');
}
@@ -1,30 +1,45 @@
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import { extractCookieFromDocument } from '@documenso/lib/client-only/cookies';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Outlet } from 'react-router';
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Settings`);
}
export default function SettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="font-semibold text-4xl">
<Trans>Settings</Trans>
</h1>
export const handle: RouteHandle = {
layoutMode: 'settings',
};
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
/**
* Only runs on the initial document request (SSR) so the first paint has the
* correct preferred team without a hydration mismatch.
*/
export function loader({ request }: Route.LoaderArgs) {
return {
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
};
}
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
/**
* Runs instead of the server loader on client-side navigations, otherwise every
* settings page switch would trigger a `.data` round-trip to the server just to
* read this cookie.
*
* The cookie is not `HttpOnly` so it can be read straight from the document.
*/
export function clientLoader() {
return {
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
};
}
export default function SettingsLayout({ loaderData }: Route.ComponentProps) {
return <UnifiedSettingsLayout activeScope="account" preferredTeamUrl={loaderData.preferredTeamUrl} />;
}
@@ -17,6 +17,7 @@ export default function SettingsBilling() {
<SettingsHeader
title={t`Billing`}
subtitle={t`Manage billing and subscriptions for organisations where you have billing management permissions.`}
hideDivider
/>
<UserBillingOrganisationsTable />
@@ -14,6 +14,7 @@ export default function TeamsSettingsPage() {
<SettingsHeader
title={_(msg`Organisations`)}
subtitle={_(msg`Manage all organisations you are currently associated with.`)}
hideDivider
>
<OrganisationCreateDialog />
</SettingsHeader>
@@ -32,8 +32,6 @@ export default function SettingsProfile() {
<AvatarImageForm className="mb-8 max-w-xl" />
<ProfileForm className="mb-8 max-w-xl" />
<hr className="my-4 max-w-xl" />
<div className="max-w-xl space-y-8">
<AnimatePresence>
{(!isPersonalLayoutMode || user.email !== teamEmail?.email) && teamEmail && (
@@ -66,13 +66,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{hasEmailPasswordAccount && (
<>
<PasswordForm user={user} />
<hr className="mt-6 border-border/50" />
</>
)}
{hasEmailPasswordAccount && <PasswordForm user={user} />}
<Alert className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -15,9 +15,9 @@ export default function SettingsSecurityActivity() {
return (
<div>
<SettingsHeader
hideDivider
title={_(msg`Security activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
/>
<div className="mt-4">
@@ -15,7 +15,7 @@ export default function SettingsPasskeys() {
return (
<div>
<SettingsHeader title={_(msg`Passkeys`)} subtitle={_(msg`Manage your passkeys.`)} hideDivider={true}>
<SettingsHeader hideDivider title={_(msg`Passkeys`)} subtitle={_(msg`Manage your passkeys.`)}>
<PasskeyCreateDialog />
</SettingsHeader>
@@ -1,8 +1,10 @@
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
@@ -17,6 +19,8 @@ export default function Layout() {
const team = useOptionalCurrentTeam();
const organisation = useOptionalCurrentOrganisation();
const { layoutMode } = useChildRouteFlags();
const limits = useMemo(() => {
if (!organisation) {
return undefined;
@@ -78,7 +82,7 @@ export default function Layout() {
// Note: We use a key to force a re-render if the team context changes.
// This is required otherwise you would see the wrong page content.
return (
<div key={team.url}>
<div key={team.url} className={cn({ 'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings' })}>
<TrpcProvider headers={trpcHeaders}>
<LimitsProvider initialValue={limits} teamId={team.id}>
<Outlet />
@@ -72,7 +72,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
updatedAt: envelope.updatedAt,
documentMeta: envelope.documentMeta,
},
recipients: envelope.recipients,
recipients: envelope.recipients.map((recipient) => ({
id: recipient.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
})),
documentRootPath,
userId: user.id,
};
@@ -118,7 +123,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
},
];
const formatRecipientText = (recipient: Recipient) => {
const formatRecipientText = (recipient: Pick<Recipient, 'email' | 'name' | 'role'>) => {
let text = recipient.email;
if (recipient.name) {
@@ -1,59 +1,57 @@
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
import { XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { useNavigate, useParams } from 'react-router';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import {
type EnvelopeBulkDownloadItem,
EnvelopesBulkDownloadDialog,
} from '~/components/dialogs/envelopes-bulk-download-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { useCurrentTeam } from '~/providers/team';
import { documentsSearchParams } from '~/utils/documents-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Documents`);
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
// Stable initial values: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable, and the metadata cache
// effect below depends on that setter.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
export default function DocumentsPage() {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const documentsPath = formatDocumentsPath(team.url);
@@ -61,9 +59,18 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`documents-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
`documents-bulk-selection-meta-${team.id}`,
EMPTY_ENVELOPE_META_CACHE,
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
@@ -81,14 +88,18 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
history: 'push',
});
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
{
...findDocumentSearchParams,
status: findDocumentSearchParams.status ?? undefined,
period: findDocumentSearchParams.period ?? undefined,
senderIds: findDocumentSearchParams.senderIds ?? undefined,
page: findDocumentSearchParams.page ?? undefined,
perPage: findDocumentSearchParams.perPage ?? undefined,
query: findDocumentSearchParams.query ?? undefined,
folderId,
},
{
@@ -96,34 +107,66 @@ export default function DocumentsPage() {
},
);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
useEffect(() => {
setEnvelopeMetaCache((prev) => {
const next: EnvelopeMetaCache = {};
params.set('status', value);
for (const id of Object.keys(prev)) {
if (rowSelection[id]) {
next[id] = prev[id];
}
}
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
for (const document of data?.data ?? []) {
if (rowSelection[document.envelopeId]) {
next[document.envelopeId] = {
title: document.title,
status: document.status,
isLegacy: document.internalVersion === 1,
};
}
}
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
params.delete('status');
}
return next;
});
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
if (params.has('page')) {
params.delete('page');
}
const selectedEnvelopesForDownload = useMemo(() => {
return selectedEnvelopeIds
.map((id): EnvelopeBulkDownloadItem | null => {
const meta = envelopeMetaCache[id];
let path = formatDocumentsPath(team.url);
if (!meta) {
return null;
}
if (folderId) {
path += `/f/${folderId}`;
}
return {
id,
title: meta.title,
status: meta.status,
// Stale cache entries predating this field are treated as legacy so
// the Partial option is never offered without certainty.
isLegacy: meta.isLegacy ?? true,
};
})
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
}, [selectedEnvelopeIds, envelopeMetaCache]);
if (params.toString()) {
path += `?${params.toString()}`;
}
const hasActiveFilters = useMemo(() => {
return Boolean(
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
findDocumentSearchParams.senderIds?.length ||
findDocumentSearchParams.period,
);
}, [findDocumentSearchParams]);
return path;
const onResetFilters = () => {
void setFindDocumentSearchParams({
status: null,
senderIds: null,
period: null,
page: null,
});
};
useEffect(() => {
@@ -137,69 +180,40 @@ export default function DocumentsPage() {
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="mt-8 flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
</div>
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<DocumentSearch />
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.EXPIRED,
ExtendedDocumentStatus.ALL,
]
.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
<DocumentsTableStatusFilter stats={stats} />
return true;
})
.map((value) => (
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<DocumentsTablePeriodFilter />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
) : (
<DocumentsTable
data={data}
@@ -238,12 +252,28 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={(successfulEnvelopeIds) => {
setRowSelection((prev) => {
const next = { ...prev };
for (const id of successfulEnvelopeIds) {
delete next[id];
}
return next;
});
}}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -1,162 +0,0 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Trans } from '@lingui/react/macro';
import { CheckCircle2, Clock } from 'lucide-react';
import { match, P } from 'ts-pattern';
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamUpdateForm } from '~/components/forms/team-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/settings._index';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamWithEmail({
userId: user.id,
teamUrl: params.teamUrl,
});
return {
team,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { team } = loaderData;
const currentTeam = useCurrentTeam();
return (
<div className="max-w-2xl">
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
<AvatarImageForm team={currentTeam} className="mb-8" />
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(team.teamEmail || team.emailVerification) && (
<Alert className="p-6" variant="neutral">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
You can view documents associated with this email and use this identity when sending documents.
</Trans>
</AlertDescription>
<hr className="mt-2 border-border/50" />
<div className="flex flex-row items-center justify-between pt-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
primaryText={
<span className="font-semibold text-foreground/80 text-sm">
{team.teamEmail?.name || team.emailVerification?.name}
</span>
}
secondaryText={
<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>
}
/>
<div className="flex flex-row items-center pr-2">
<div className="mr-4 flex flex-row items-center text-muted-foreground text-sm xl:mr-8">
{match({
teamEmail: team.teamEmail,
emailVerification: team.emailVerification,
})
.with({ teamEmail: P.not(null) }, () => (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
<Trans>Active</Trans>
</>
))
.with(
{
emailVerification: P.when(
(emailVerification) => emailVerification && emailVerification?.expiresAt < new Date(),
),
},
() => (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
<Trans>Expired</Trans>
</>
),
)
.with({ emailVerification: P.not(null) }, () => (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
<Trans>Awaiting email confirmation</Trans>
</>
))
.otherwise(() => null)}
</div>
<TeamEmailDropdown team={team} />
</div>
</div>
</Alert>
)}
{!team.teamEmail && !team.emailVerification && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<ul className="mt-0.5 list-inside list-disc text-muted-foreground text-sm">
{/* Feature not available yet. */}
{/* <li>Display this name and email when sending documents</li> */}
{/* <li>View documents associated with this email</li> */}
<span>
<Trans>View documents associated with this email</Trans>
</span>
</ul>
</AlertDescription>
</div>
<TeamEmailAddDialog teamId={team.id} />
</Alert>
)}
{canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Delete team</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
This team, and any associated data excluding billing invoices will be permanently deleted.
</Trans>
</AlertDescription>
</div>
<TeamDeleteDialog teamId={team.id} teamName={team.name} redirectTo="/dashboard" />
</Alert>
)}
</section>
</div>
);
}
@@ -1,15 +1,11 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { BracesIcon, Globe2Icon, GroupIcon, Settings2Icon, SettingsIcon, Users2Icon, WebhookIcon } from 'lucide-react';
import { Link, NavLink, Outlet, redirect } from 'react-router';
import { redirect } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team';
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/settings._layout';
@@ -18,6 +14,10 @@ export function meta() {
return appMetaTags(msg`Team Settings`);
}
export const handle: RouteHandle = {
layoutMode: 'settings',
};
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
@@ -35,123 +35,6 @@ export async function clientLoader() {
// Do nothing, we only want the loader to run on SSR.
}
export default function TeamsSettingsLayout() {
const { t } = useLingui();
const team = useCurrentTeam();
const teamSettingRoutes = [
{
path: `/t/${team.url}/settings`,
label: t`General`,
icon: SettingsIcon,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
isSubNavParent: true,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Document`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/branding`,
label: t`Branding`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/email`,
label: t`Email`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/public-profile`,
label: t`Public Profile`,
icon: Globe2Icon,
},
{
path: `/t/${team.url}/settings/members`,
label: t`Members`,
icon: Users2Icon,
},
{
path: `/t/${team.url}/settings/groups`,
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/t/${team.url}/settings/tokens`,
label: t`API Tokens`,
icon: BracesIcon,
},
{
path: `/t/${team.url}/settings/webhooks`,
label: t`Webhooks`,
icon: WebhookIcon,
},
];
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
return (
<GenericErrorLayout
errorCode={401}
errorCodeMap={{
401: {
heading: msg`Unauthorized`,
subHeading: msg`401 Unauthorized`,
message: msg`You are not authorized to access this page.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}`}>
<Trans>Go Back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="font-semibold text-4xl">
<Trans>Team Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{teamSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.isSubNavParent,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
export default function TeamSettingsLayout() {
return <UnifiedSettingsLayout activeScope="team" />;
}
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -17,6 +17,7 @@ import {
type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {
@@ -115,7 +116,7 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Branding Preferences`}
subtitle={t`Here you can set preferences and defaults for branding.`}
@@ -155,6 +156,8 @@ export default function TeamsSettingsPage() {
</Alert>
)}
</section>
) : IS_DOCUMENSO_CLOUD() ? (
<BrandingUpsell />
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">

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