mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd2ee16fc4 | ||
|
|
688ef2fdf3 | ||
|
|
a5e37af3e8 | ||
|
|
617f8cc204 | ||
|
|
1bd09480e6 | ||
|
|
962cffc9f5 | ||
|
|
820b319474 | ||
|
|
797f5c0e79 | ||
|
|
fc95ee9ead | ||
|
|
d6cf3fec4b | ||
|
|
f0ab7c112e | ||
|
|
8bfcec8ee6 | ||
|
|
9c27ce6d18 | ||
|
|
b3c609a549 | ||
|
|
29020bcbed | ||
|
|
6ec67d1c4d | ||
|
|
a457e1ef7d | ||
|
|
e4897fa686 | ||
|
|
c02dfaba1a | ||
|
|
54befb5962 | ||
|
|
26f0c4c5b7 | ||
|
|
7f85388eb7 | ||
|
|
3cf2963cd0 | ||
|
|
cc5ef3df16 | ||
|
|
4b72e7d546 | ||
|
|
ba0dead96f | ||
|
|
40472bc26c | ||
|
|
3ff7f70a7d | ||
|
|
5c41740859 | ||
|
|
d6268b1d7d | ||
|
|
12223c79cb | ||
|
|
b16f979eb3 | ||
|
|
db031e2865 | ||
|
|
4e0038f2e8 | ||
|
|
c5efd34e95 | ||
|
|
21cff7a727 | ||
|
|
400b6a24f1 | ||
|
|
1b1e3d197b | ||
|
|
a276e18e1f |
@@ -0,0 +1,146 @@
|
||||
---
|
||||
date: 2026-05-28
|
||||
title: Rejected Expired Recipient Filters
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Customers need to find (a) envelopes/documents in the `REJECTED` state and (b) envelopes
|
||||
with at least one recipient whose signing link has **expired**. Today the UI only exposes
|
||||
`INBOX / PENDING / COMPLETED / DRAFT / ALL` tabs, and the public API has no way to filter by
|
||||
expired recipient links — forcing a fetch-all-`PENDING`-then-inspect-each-recipient workaround.
|
||||
|
||||
Two key facts from exploration shaped this plan:
|
||||
|
||||
- **`REJECTED` is already fully wired in the backend** — the where-clause (`find-documents.ts`),
|
||||
stats counts (`get-stats.ts`), tRPC response schema, `ExtendedDocumentStatus` enum, and the
|
||||
`FRIENDLY_STATUS_MAP` display all handle it. It is simply absent from the UI tab array.
|
||||
- **Renewing expired links already works.** `resendDocument` refreshes `expiresAt` and clears
|
||||
`expirationNotifiedAt` for unsigned, non-CC recipients (`resend-document.ts:98-121`), exposed
|
||||
publicly via `POST /api/v2/document/redistribute` and `/api/v2/envelope/redistribute` and via the
|
||||
resend/redistribute UI dialogs. No new renew mechanism is needed — only documentation/wording.
|
||||
|
||||
Expiration is a per-recipient condition (not an envelope status). The approved design models it
|
||||
in the UI as an `EXPIRED` **pseudo-status tab** (reusing the existing tab machinery, mirroring how
|
||||
`REJECTED` works) and in the public API as an orthogonal boolean `hasExpiredRecipients`. Both share
|
||||
one EXISTS predicate.
|
||||
|
||||
Definition of "expired recipient" (matches `isRecipientExpired`, `packages/lib/utils/recipients.ts:118`):
|
||||
a `Recipient` with `expiresAt IS NOT NULL AND expiresAt <= now() AND signingStatus = NOT_SIGNED AND role != CC`.
|
||||
|
||||
## Approach
|
||||
|
||||
### A. Shared EXISTS predicate (reused 4x, justified)
|
||||
Add a local `hasExpiredRecipient(eb)` helper — modeled on the existing per-file `recipientExists` /
|
||||
`senderEmailIs` helpers — to `find-documents.ts`, `get-stats.ts`, and `find-envelopes.ts`. It is the
|
||||
single source of truth for the expired condition above (using `new Date()` for `now`, matching the
|
||||
`period` filter's `.toJSDate()` style).
|
||||
|
||||
### B. REJECTED tab (UI only — backend already done)
|
||||
- `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx`: add
|
||||
`ExtendedDocumentStatus.REJECTED` to the tab array (lines 149-155). Count badge, highlight, and
|
||||
`?status=REJECTED` filtering already work via existing machinery.
|
||||
|
||||
### C. EXPIRED pseudo-status (UI + internal stats)
|
||||
1. `packages/prisma/types/extended-document-status.ts`: add `EXPIRED: 'EXPIRED'`. Internal-only —
|
||||
the public `DocumentStatus` enum is unaffected. This intentionally surfaces TS errors at the three
|
||||
exhaustive/`Record<ExtendedDocumentStatus>` sites below, forcing them to be handled.
|
||||
2. `packages/lib/server-only/document/find-documents.ts`:
|
||||
- Add `.with(ExtendedDocumentStatus.EXPIRED, ...)` to **both** `applyPersonalFilters` and
|
||||
`applyTeamFilters`, mirroring the `COMPLETED` branch's access control (deleted + visibility +
|
||||
owner/recipient access) with `hasExpiredRecipient(eb)` AND-ed in. Do **not** constrain
|
||||
`Envelope.status` — the EXISTS already restricts to unsigned recipients.
|
||||
3. `packages/lib/server-only/document/get-stats.ts`:
|
||||
- Add an `expiredQuery` mirroring `pendingQuery`'s access control + `hasExpiredRecipient(eb)`.
|
||||
- Add it to the `Promise.all`, add `[ExtendedDocumentStatus.EXPIRED]: expired` to the `stats`
|
||||
record. **Do not** add `expired` to the `all` sum (it overlaps `PENDING`).
|
||||
4. `packages/trpc/server/document-router/find-documents-internal.types.ts`: add
|
||||
`[ExtendedDocumentStatus.EXPIRED]: z.number()` to the `stats` response object. (`status` already
|
||||
accepts the extended enum via `z.nativeEnum(ExtendedDocumentStatus)`.)
|
||||
5. `apps/remix/app/components/general/document/document-status.tsx`: add an `EXPIRED` entry to
|
||||
`FRIENDLY_STATUS_MAP` — `label: msg` Expired, an icon (e.g. lucide `TimerOff`, matching the
|
||||
`/sign/$token/expired` page), and a distinct color (e.g. `text-orange-500`) to differentiate from
|
||||
`REJECTED` (red).
|
||||
6. `documents._index.tsx`: add `[ExtendedDocumentStatus.EXPIRED]: 0` to the `stats` `useState`
|
||||
initializer and `ExtendedDocumentStatus.EXPIRED` to the tab array. Final order:
|
||||
`INBOX, PENDING, COMPLETED, DRAFT, REJECTED, EXPIRED, ALL`.
|
||||
7. (Optional, recommended) `apps/remix/app/components/tables/documents-table-empty-state.tsx`: add
|
||||
tailored `EXPIRED` and `REJECTED` empty-state copy (currently both fall through to `.otherwise()`).
|
||||
|
||||
### D. Public API boolean `hasExpiredRecipients` (document + envelope, v2)
|
||||
1. `packages/lib/server-only/document/find-documents.ts`: add `hasExpiredRecipients?: boolean` to
|
||||
`FindDocumentsOptions`; when true, apply `.where((eb) => hasExpiredRecipient(eb))` inside
|
||||
`buildBaseQuery` (orthogonal/additive to any `status`).
|
||||
2. `packages/trpc/server/document-router/find-documents.types.ts`: add a query-safe boolean
|
||||
`hasExpiredRecipients` to `ZFindDocumentsRequestSchema` with a `.describe(...)`. Mirror the
|
||||
existing boolean-query-param handling in `find-document-audit-logs.types.ts`
|
||||
(`filterForRecentActivity`) — avoid raw `z.coerce.boolean()` (the "false" -> true footgun); use a
|
||||
string transform if needed. Pass it through in `find-documents.ts` (public handler).
|
||||
3. `packages/lib/server-only/envelope/find-envelopes.ts`: add `hasExpiredRecipients?: boolean` to
|
||||
`FindEnvelopesOptions` + the `hasExpiredRecipient(eb)` helper + the additive `.where`.
|
||||
4. `packages/trpc/server/envelope-router/find-envelopes.types.ts`: add the same param to
|
||||
`ZFindEnvelopesRequestSchema`; pass it through in the envelope-router find handler.
|
||||
The param auto-appears in the generated `/api/v2/openapi.json`.
|
||||
|
||||
Note: REST v1 `GET /api/v1/documents` is deprecated and lacks status filtering — left unchanged.
|
||||
`REJECTED` is already a valid public `status` value (`DocumentStatus.REJECTED`), so no API change is
|
||||
needed for rejected filtering.
|
||||
|
||||
### E. Renew expired links — documentation only
|
||||
No functional change. Document that resending renews expired links:
|
||||
- Update the `.description` in `packages/trpc/server/document-router/redistribute-document.types.ts`
|
||||
and `packages/trpc/server/envelope-router/redistribute-envelope.types.ts` to state that
|
||||
redistributing refreshes the signing-link expiration for unsigned recipients.
|
||||
- Optionally adjust resend/redistribute dialog copy
|
||||
(`apps/remix/app/components/dialogs/document-resend-dialog.tsx`,
|
||||
`envelope-redistribute-dialog.tsx`) to mention it renews expired links.
|
||||
|
||||
## Files To Modify (summary)
|
||||
|
||||
| Area | File |
|
||||
|------|------|
|
||||
| Enum | `packages/prisma/types/extended-document-status.ts` |
|
||||
| Where-clause + API option | `packages/lib/server-only/document/find-documents.ts` |
|
||||
| Stats counts | `packages/lib/server-only/document/get-stats.ts` |
|
||||
| Envelope find (API) | `packages/lib/server-only/envelope/find-envelopes.ts` |
|
||||
| Internal tRPC stats schema | `packages/trpc/server/document-router/find-documents-internal.types.ts` |
|
||||
| Public doc API schema + handler | `packages/trpc/server/document-router/find-documents.types.ts`, `find-documents.ts` |
|
||||
| Public envelope API schema + handler | `packages/trpc/server/envelope-router/find-envelopes.types.ts`, `find-envelopes.ts` |
|
||||
| Status display | `apps/remix/app/components/general/document/document-status.tsx` |
|
||||
| Tabs + stats init | `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx` |
|
||||
| Empty state (optional) | `apps/remix/app/components/tables/documents-table-empty-state.tsx` |
|
||||
| Renew docs | `redistribute-document.types.ts`, `redistribute-envelope.types.ts` (+ resend dialogs, optional) |
|
||||
|
||||
## Reused Utilities / Patterns
|
||||
- `recipientExists` / `senderEmailIs` (per-file Kysely EXISTS helpers) — the template for the new
|
||||
`hasExpiredRecipient` helper.
|
||||
- `REJECTED` branches in `find-documents.ts` (lines 279, 416) and `rejectedQuery` in `get-stats.ts`
|
||||
(line 227) — the template for the `EXPIRED` branches / `expiredQuery`.
|
||||
- `isRecipientExpired` (`packages/lib/utils/recipients.ts:118`) — defines the `expiresAt <= now`
|
||||
semantics to match.
|
||||
- Existing tab machinery in `documents._index.tsx` (`getTabHref`, count badge, personal-org `.filter`)
|
||||
— works unchanged for the new tabs.
|
||||
- `resendDocument` / `trpc.document.redistribute` / `trpc.envelope.redistribute` — existing renew path.
|
||||
|
||||
## Verification
|
||||
1. **Typecheck** (the enum change forces all exhaustive/Record sites): `npm run typecheck -w @documenso/remix`.
|
||||
2. **Seed + UI** (dev server already running): seed a team via `seedTeam`, send a document, then:
|
||||
- Reject one as a recipient -> it appears under the new **Rejected** tab with a count.
|
||||
- Force expiry (set a recipient `expiresAt` in the past, e.g. via Prisma Studio or a short
|
||||
`envelopeExpirationPeriod`) -> the doc appears under the new **Expired** tab with a count, and the
|
||||
count excludes signed/CC recipients.
|
||||
3. **Public API**: `GET /api/v2/document?hasExpiredRecipients=true` and
|
||||
`GET /api/v2/envelope?hasExpiredRecipients=true` (Bearer API token) return only envelopes with >=1
|
||||
expired unsigned recipient; confirm `GET /api/v2/document?status=REJECTED` works. Verify the param
|
||||
appears in `/api/v2/openapi.json`.
|
||||
4. **Renew**: on an expired doc, run resend/redistribute (UI dialog or
|
||||
`POST /api/v2/document/redistribute`) -> recipient `expiresAt` is refreshed, the doc leaves the
|
||||
Expired tab, and the signing link no longer redirects to `/sign/$token/expired`.
|
||||
5. **E2E** (optional): extend `packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts`
|
||||
with an Expired-tab assertion.
|
||||
6. Do **not** modify/commit `packages/lib/translations/*.po`; run `npm run translate` only if needed
|
||||
for new `msg`/`Trans` strings, and keep generated `.po` files out of the branch.
|
||||
|
||||
## Open Questions
|
||||
- Exact icon/color for the `EXPIRED` tab (proposed: `TimerOff`, `text-orange-500`).
|
||||
- Whether to add the optional tailored empty-state copy now or defer.
|
||||
@@ -1,3 +1,3 @@
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
min-release-age = 7
|
||||
# min-release-age = 7
|
||||
|
||||
+1
-1
@@ -42,8 +42,8 @@ Documenso is an open-source document signing platform built as a **monorepo** us
|
||||
| Package | Description | Port |
|
||||
| -------------------------- | -------------------------------------------------------- | ---- |
|
||||
| `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 |
|
||||
| `@documenso/documentation` | Documentation site (Next.js + Nextra) | 3002 |
|
||||
| `@documenso/openpage-api` | Public analytics API | 3003 |
|
||||
| `@documenso/docs` | Documentation site | 3004 |
|
||||
|
||||
### Core Packages (`packages/`)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ description: Create, manage, and send documents for signing via the API.
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Callout type="warn">
|
||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||
|
||||
@@ -5,6 +5,8 @@ description: Complete reference for the Documenso REST API.
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Callout type="warn">
|
||||
The guides below cover common API patterns but may not reflect the latest endpoints or parameters.
|
||||
For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"teams",
|
||||
"rate-limits",
|
||||
"versioning",
|
||||
"migrate-to-envelopes",
|
||||
"developer-mode",
|
||||
"common-errors"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
title: Migrating to Envelopes
|
||||
description: Why Documenso unified documents and templates into envelopes, and how to migrate from the deprecated document and template create endpoints.
|
||||
---
|
||||
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
## Summary
|
||||
|
||||
The following items have been deprecated and will be removed on the <strong>1st of March 2027</strong>:
|
||||
|
||||
- <strong>API V1</strong>
|
||||
- <strong>A subset of SDK/API V2 endpoints</strong>
|
||||
- <strong>Legacy documents and templates</strong>
|
||||
- <strong>EmbedCreateDocumentV1</strong>
|
||||
- <strong>EmbedCreateTemplateV1</strong>
|
||||
- <strong>EmbedUpdateDocumentV1</strong>
|
||||
- <strong>EmbedUpdateTemplateV1</strong>
|
||||
|
||||
The beta endpoint `/api/v2-beta` will also be removed. Use `/api/v2` instead, which is a drop-in replacement.
|
||||
|
||||
Nothing breaks before 1st of March 2027, so you can migrate at your own pace.
|
||||
|
||||
## What are legacy documents and templates
|
||||
|
||||
These are documents and templates created by the following endpoints:
|
||||
|
||||
- `POST /api/v2/document/create`
|
||||
- `POST /api/v2/document/create/beta`
|
||||
- `POST /api/v2/template/create`
|
||||
- `POST /api/v2/template/create/beta`
|
||||
- `POST /api/v1/documents`
|
||||
- `POST /api/v1/templates`
|
||||
- `POST /api/v1/templates/create-document`
|
||||
- `POST /api/v1/templates/generate-document`
|
||||
|
||||
## What replaces legacy documents and templates
|
||||
|
||||
At the end of 2025 we introduced a unified system for documents and templates, called <strong>envelopes</strong>.
|
||||
|
||||
We still reference documents and templates throughout the documentation and application to distinguish them, but internally they are envelopes.
|
||||
|
||||
Moving to the envelope system gives you:
|
||||
|
||||
- **Multiple PDFs in one envelope.** Send several documents to sign in a single request.
|
||||
- **One API for documents and templates.** Learn one set of endpoints instead of two misaligned ones.
|
||||
- **A better editor and signing experience** for you and your recipients.
|
||||
|
||||
## How to migrate
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Switch to the envelope endpoints
|
||||
|
||||
Replace each deprecated endpoint with its `/api/v2/envelope/*` equivalent from the [mapping tables](#endpoint-mapping-reference) below.
|
||||
</Step>
|
||||
<Step>
|
||||
### Set the envelope `type` on create
|
||||
|
||||
A single endpoint, `POST /api/v2/envelope/create`, can create both documents and templates. Set `type` to `DOCUMENT` or `TEMPLATE`. You can now upload more than one PDF using the `files` field.
|
||||
</Step>
|
||||
<Step>
|
||||
### Update how you store IDs
|
||||
|
||||
Envelope IDs are **strings** (for example `envelope_abc123`), not numbers. Update any code that stores, parses, or compares IDs.
|
||||
</Step>
|
||||
<Step>
|
||||
### Test, then remove the old calls
|
||||
|
||||
Verify the new flow against your account, then delete the deprecated calls.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The main data differences are as follows:
|
||||
- ID format changed from number to string (e.g. `42` to `envelope_abc123`)
|
||||
- pageNumber becomes page
|
||||
- pageX becomes positionX
|
||||
- pageY becomes positionY
|
||||
|
||||
See the [Documents API](/docs/developers/api/documents) and [Templates API](/docs/developers/api/templates) for the full envelope reference.
|
||||
|
||||
### Deprecated V1 API Endpoints
|
||||
|
||||
Full reference in the [V1 OpenAPI reference](https://openapi-v1.documenso.com).
|
||||
|
||||
| Deprecated endpoint | Replacement |
|
||||
| -------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `GET /api/v1/documents` | `GET /api/v2/envelope` |
|
||||
| `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v1/documents` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/distribute` |
|
||||
| `POST /api/v1/documents/{id}/resend` | `POST /api/v2/envelope/redistribute` |
|
||||
| `DELETE /api/v1/documents/{id}` | `POST /api/v2/envelope/delete` |
|
||||
| `GET /api/v1/documents/{id}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||
| `POST /api/v1/documents/{id}/recipients` | `POST /api/v2/envelope/recipient/create-many` |
|
||||
| `PATCH /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/update-many` |
|
||||
| `DELETE /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/delete` |
|
||||
| `POST /api/v1/documents/{id}/fields` | `POST /api/v2/envelope/field/create-many` |
|
||||
| `PATCH /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/update-many` |
|
||||
| `DELETE /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/delete` |
|
||||
| `GET /api/v1/templates` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||
| `GET /api/v1/templates/{id}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v1/templates` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `DELETE /api/v1/templates/{id}` | `POST /api/v2/envelope/delete` |
|
||||
| `POST /api/v1/templates/{templateId}/create-document` | `POST /api/v2/envelope/use` |
|
||||
| `POST /api/v1/templates/{templateId}/generate-document` | `POST /api/v2/envelope/use` |
|
||||
|
||||
### Deprecated V2 API Endpoints
|
||||
|
||||
Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||
|
||||
#### Documents
|
||||
|
||||
| Deprecated endpoint | Replacement |
|
||||
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
||||
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
|
||||
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
||||
| `POST /api/v2/document/delete` | `POST /api/v2/envelope/delete` |
|
||||
| `POST /api/v2/document/duplicate` | `POST /api/v2/envelope/duplicate` |
|
||||
| `POST /api/v2/document/distribute` | `POST /api/v2/envelope/distribute` |
|
||||
| `POST /api/v2/document/redistribute` | `POST /api/v2/envelope/redistribute` |
|
||||
| `GET /api/v2/document/attachment` | `GET /api/v2/envelope/attachment` |
|
||||
| `POST /api/v2/document/attachment/create` | `POST /api/v2/envelope/attachment/create` |
|
||||
| `POST /api/v2/document/attachment/update` | `POST /api/v2/envelope/attachment/update` |
|
||||
| `POST /api/v2/document/attachment/delete` | `POST /api/v2/envelope/attachment/delete` |
|
||||
| `GET /api/v2/document/{documentId}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||
| `GET /api/v2/document/{documentId}/download-beta` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||
|
||||
#### Templates
|
||||
|
||||
| Deprecated endpoint | Replacement |
|
||||
| ------------------------------------- | ------------------------------------------------ |
|
||||
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
|
||||
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
||||
| `POST /api/v2/template/duplicate` | `POST /api/v2/envelope/duplicate` |
|
||||
| `POST /api/v2/template/delete` | `POST /api/v2/envelope/delete` |
|
||||
| `POST /api/v2/template/use` | `POST /api/v2/envelope/use` |
|
||||
| `POST /api/v2/template/direct/create` | **Pending replacement** |
|
||||
| `POST /api/v2/template/direct/delete` | **Pending replacement** |
|
||||
| `POST /api/v2/template/direct/toggle` | **Pending replacement** |
|
||||
|
||||
#### Document fields
|
||||
|
||||
| Deprecated endpoint | Replacement |
|
||||
| ----------------------------------------- | ----------------------------------------- |
|
||||
| `GET /api/v2/document/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
|
||||
| `POST /api/v2/document/field/create` | `POST /api/v2/envelope/field/create-many` |
|
||||
| `POST /api/v2/document/field/create-many` | `POST /api/v2/envelope/field/create-many` |
|
||||
| `POST /api/v2/document/field/update` | `POST /api/v2/envelope/field/update-many` |
|
||||
| `POST /api/v2/document/field/update-many` | `POST /api/v2/envelope/field/update-many` |
|
||||
| `POST /api/v2/document/field/delete` | `POST /api/v2/envelope/field/delete` |
|
||||
|
||||
#### Template fields
|
||||
|
||||
| Deprecated endpoint | Replacement |
|
||||
| ----------------------------------------- | ----------------------------------------- |
|
||||
| `GET /api/v2/template/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
|
||||
| `POST /api/v2/template/field/create` | `POST /api/v2/envelope/field/create-many` |
|
||||
| `POST /api/v2/template/field/create-many` | `POST /api/v2/envelope/field/create-many` |
|
||||
| `POST /api/v2/template/field/update` | `POST /api/v2/envelope/field/update-many` |
|
||||
| `POST /api/v2/template/field/update-many` | `POST /api/v2/envelope/field/update-many` |
|
||||
| `POST /api/v2/template/field/delete` | `POST /api/v2/envelope/field/delete` |
|
||||
|
||||
#### Document recipients
|
||||
|
||||
| Deprecated endpoint | Replacement |
|
||||
| ---------------------------------------------- | ---------------------------------------------- |
|
||||
| `GET /api/v2/document/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
|
||||
| `POST /api/v2/document/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
|
||||
| `POST /api/v2/document/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
|
||||
| `POST /api/v2/document/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
|
||||
| `POST /api/v2/document/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
|
||||
| `POST /api/v2/document/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
|
||||
|
||||
#### Template recipients
|
||||
|
||||
| Deprecated endpoint | Replacement |
|
||||
| ---------------------------------------------- | ---------------------------------------------- |
|
||||
| `GET /api/v2/template/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
|
||||
| `POST /api/v2/template/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
|
||||
| `POST /api/v2/template/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
|
||||
| `POST /api/v2/template/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
|
||||
| `POST /api/v2/template/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
|
||||
| `POST /api/v2/template/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
|
||||
|
||||
### Embedding components
|
||||
|
||||
| Deprecated component | Replacement |
|
||||
| ----------------------- | --------------------- |
|
||||
| `EmbedCreateDocumentV1` | `EmbedCreateEnvelope` |
|
||||
| `EmbedCreateTemplateV1` | `EmbedCreateEnvelope` |
|
||||
| `EmbedUpdateDocumentV1` | `EmbedUpdateEnvelope` |
|
||||
| `EmbedUpdateTemplateV1` | `EmbedUpdateEnvelope` |
|
||||
|
||||
See the [embedding guide](/docs/developers/embedding) for the envelope components.
|
||||
|
||||
## FAQ
|
||||
|
||||
<Accordions>
|
||||
<Accordion title="What happens on 1 March 2027?">
|
||||
The deprecated V1 API, the V2 endpoints listed above, and the V1 embedding components are removed.
|
||||
Requests to them will fail, so migrate to the envelope API before that date.
|
||||
</Accordion>
|
||||
<Accordion title="Will my existing documents and templates keep working?">
|
||||
Yes. Documents and templates you already created remain in your account and continue to work. They will automatically be converted to envelopes. Only
|
||||
the deprecated endpoints you call are going away. Your data is not deleted.
|
||||
</Accordion>
|
||||
<Accordion title="Do I need a new API token?">
|
||||
No. Authentication is unchanged. The same API token works for the envelope endpoints under
|
||||
`https://app.documenso.com/api/v2`.
|
||||
</Accordion>
|
||||
<Accordion title="What is the difference between a document and a template now?">
|
||||
Both are envelopes, distinguished by a `type` field of `DOCUMENT` or `TEMPLATE`. They share the same
|
||||
endpoints, recipients, fields, and attachments.
|
||||
</Accordion>
|
||||
<Accordion title="I use an official SDK, what should I do?">
|
||||
The function calls to the legacy endpoints will break on the 1st of March 2027. Update to the latest SDK version and switch to its envelope methods.
|
||||
The deprecated document and template methods map to the envelope endpoints in the tables above.
|
||||
</Accordion>
|
||||
<Accordion title="I need more time or help migrating">
|
||||
Reach out to [support@documenso.com](mailto:support@documenso.com) with your use case and we will
|
||||
help you plan the migration.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
## Getting help
|
||||
|
||||
- [V2 OpenAPI reference](https://openapi.documenso.com): the up-to-date envelope API.
|
||||
- [V1 OpenAPI reference](https://openapi-v1.documenso.com): the deprecated V1 API.
|
||||
- [support@documenso.com](mailto:support@documenso.com): migration questions and extensions.
|
||||
|
||||
## See also
|
||||
|
||||
- [Documents API](/docs/developers/api/documents): create and manage envelopes
|
||||
- [Templates API](/docs/developers/api/templates): work with templates and direct links
|
||||
- [Fields API](/docs/developers/api/fields) and [Recipients API](/docs/developers/api/recipients)
|
||||
- [API Versioning](/docs/developers/api/versioning): how Documenso versions the public API
|
||||
@@ -11,9 +11,14 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
|
||||
## HTTP Rate Limits
|
||||
|
||||
**Limit:** 100 requests per minute per IP address
|
||||
**Limit:** 1000 requests per minute per IP address
|
||||
**Response:** 429 Too Many Requests
|
||||
|
||||
<Callout type="info">
|
||||
This is the global per-IP ceiling. Your organisation may have its own rate limits configured below
|
||||
this value, in which case you can be rate-limited before reaching the global limit.
|
||||
</Callout>
|
||||
|
||||
### Rate Limit Response
|
||||
|
||||
```json
|
||||
@@ -65,3 +70,4 @@ When you exceed a resource limit:
|
||||
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
||||
- [API Versioning](/docs/developers/api/versioning) - API version management
|
||||
- [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API
|
||||
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Admins: set per-organisation resource quotas and rate limits (the HTTP rate limit above is separate and not admin-settable)
|
||||
|
||||
@@ -6,6 +6,8 @@ description: Create documents from reusable templates via API.
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Callout type="warn">
|
||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||
|
||||
@@ -5,6 +5,8 @@ description: Versioning information for the Documenso public API.
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
## Overview
|
||||
|
||||
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
|
||||
@@ -19,7 +21,16 @@ Also, we may deprecate certain features or endpoints in the API. When we depreca
|
||||
|
||||
---
|
||||
|
||||
## Documents, Templates, and Envelopes
|
||||
|
||||
Documenso has unified documents and templates into a single resource called an **envelope**. New integrations should create documents and templates through the `/envelope/*` endpoints. The `POST /document/create` and `POST /template/create` endpoints (including their `/beta` variants) are deprecated in favor of `POST /envelope/create`.
|
||||
|
||||
See [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) for the rationale and step-by-step migration examples.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) - Move from the document and template create endpoints
|
||||
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
||||
- [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
## Workflow 1: Send a Document for Signature
|
||||
|
||||
The most common workflow: upload a PDF, add recipients with signature fields, and send for signing.
|
||||
@@ -472,7 +474,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
|
||||
<code>distributeDocument: true</code>
|
||||
</Step>
|
||||
<Step>
|
||||
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
|
||||
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -638,8 +640,8 @@ done
|
||||
</Tabs>
|
||||
|
||||
<Callout type="info">
|
||||
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
|
||||
between requests to avoid hitting limits.
|
||||
The API allows 1000 requests per minute (your organisation may have its own lower limit). For large
|
||||
batches, implement rate limiting with delays between requests to avoid hitting limits.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Examples
|
||||
description: Common integration patterns and end-to-end workflows.
|
||||
---
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title="Common Workflows"
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Documenso account (cloud or self-hosted)
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, you need:
|
||||
@@ -483,7 +485,7 @@ The API returns standard HTTP status codes and JSON error responses:
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
|
||||
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
|
||||
|
||||
```javascript
|
||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Getting Started
|
||||
description: Get your API key and make your first API call.
|
||||
---
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title="Authentication"
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Developer Guide
|
||||
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
|
||||
---
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
## Getting Started
|
||||
|
||||
<Cards>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -76,6 +76,8 @@ The Enterprise Edition is required when you:
|
||||
4. Restart your Documenso instance
|
||||
5. Verify the license is active in the **Admin Panel** under the **Stats** section
|
||||
|
||||
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
|
||||
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
|
||||
1. Sign the Enterprise license agreement
|
||||
2. Receive license key and access credentials
|
||||
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
|
||||
4. Configure Enterprise features with support assistance
|
||||
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
|
||||
|
||||
## Related
|
||||
|
||||
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
|
||||
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
|
||||
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
|
||||
- [Support](/docs/policies/support) - Support channels and response times
|
||||
|
||||
@@ -41,12 +41,17 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
|
||||
|
||||
| Action | Limit | Window |
|
||||
| --- | --- | --- |
|
||||
| API requests (v1 and v2) | 100 requests | 1 minute |
|
||||
| API requests (v1 and v2) | 1000 requests | 1 minute |
|
||||
| File uploads | 20 requests | 1 minute |
|
||||
| AI features | 3 requests | 1 minute |
|
||||
|
||||
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
|
||||
|
||||
<Callout type="info">
|
||||
The API request limit above is the global per-IP ceiling. Individual organisations also have their
|
||||
own rate limits, which may be configured below this value.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
|
||||
[sales](https://documen.so/sales) for details.
|
||||
|
||||
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
|
||||
|
||||
## Enterprise Features
|
||||
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------ | ------------------------------------------------ |
|
||||
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
|
||||
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
|
||||
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
|
||||
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
|
||||
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
|
||||
@@ -510,4 +510,5 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
||||
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
|
||||
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
|
||||
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
|
||||
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Set per-organisation document, email, and API limits from the admin panel
|
||||
- [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues
|
||||
|
||||
@@ -29,6 +29,11 @@ description: Configure your self-hosted Documenso instance with environment vari
|
||||
description="Digital signature certificate setup."
|
||||
href="/docs/self-hosting/configuration/signing-certificate"
|
||||
/>
|
||||
<Card
|
||||
title="Organisation Limits"
|
||||
description="Set per-organisation document, email, and API limits via the admin panel."
|
||||
href="/docs/self-hosting/configuration/organisation-limits"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
## Required Configuration
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Apply Your License Key
|
||||
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
|
||||
---
|
||||
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
|
||||
|
||||
<Callout type="info">
|
||||
The license key applies to your **whole instance**, not an individual user account. There's one
|
||||
key per deployment.
|
||||
</Callout>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
|
||||
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
|
||||
- A running self-hosted Documenso instance that you're able to restart
|
||||
|
||||
## Step 1: Set the environment variable
|
||||
|
||||
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
|
||||
|
||||
<Tabs items={['Docker Compose', 'docker run', '.env']}>
|
||||
<Tab value="Docker Compose">
|
||||
|
||||
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
|
||||
```
|
||||
|
||||
Then apply it:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value="docker run">
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name documenso \
|
||||
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
|
||||
documenso/documenso:latest
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value=".env">
|
||||
|
||||
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Step 2: Restart the instance
|
||||
|
||||
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose restart documenso
|
||||
|
||||
# Docker
|
||||
docker restart documenso
|
||||
```
|
||||
|
||||
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
|
||||
|
||||
## What the license enables
|
||||
|
||||
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
|
||||
|
||||
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
|
||||
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordions type="multiple">
|
||||
<Accordion title="Enterprise features are still unavailable after applying the key">
|
||||
- Confirm the key is present in the environment the running process actually reads — `docker
|
||||
exec` into the container and check `env | grep LICENSE` if unsure.
|
||||
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
|
||||
- Re-copy the key to rule out truncation or accidental whitespace.
|
||||
</Accordion>
|
||||
<Accordion title="A specific feature still isn't working">
|
||||
Instance-wide features (like CSC signing) also need their own configuration — an active license
|
||||
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
|
||||
Per-organisation features additionally need to be provisioned for the organisation that's using
|
||||
them.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
## See Also
|
||||
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
|
||||
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
|
||||
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
|
||||
@@ -2,12 +2,14 @@
|
||||
"title": "Configuration",
|
||||
"pages": [
|
||||
"environment",
|
||||
"license",
|
||||
"database",
|
||||
"email",
|
||||
"storage",
|
||||
"background-jobs",
|
||||
"signing-certificate",
|
||||
"telemetry",
|
||||
"organisation-limits",
|
||||
"advanced"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Organisation Limits
|
||||
description: View and set per-organisation document, email, and API limits on a self-hosted Documenso instance using the admin panel's subscription claims.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
Per-organisation limits — document, email, and API usage, plus feature toggles and team/member caps — are controlled by **subscription claims**. You configure them in the admin panel, not through environment variables.
|
||||
|
||||
There are three distinct kinds of limit:
|
||||
|
||||
| Limit | Caps | Admin-settable |
|
||||
| ---------------------- | ------------------------------------------------- | ----------------------- |
|
||||
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
|
||||
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
|
||||
| Global HTTP rate limit | API requests per IP (1000/min, hardcoded) | No — see [Limitations](#limitations) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running self-hosted Documenso instance.
|
||||
- An account with the **`ADMIN`** role — an account-level role, separate from organisation and team roles. New accounts are created with the `USER` role only. Grant the first admin by adding `ADMIN` to that user's `roles` directly in the database; after that, an existing admin can grant the role to others under **Admin Panel > Users > _(user)_ > Roles > Update user**.
|
||||
|
||||
Open the admin panel at `/admin`. The sidebar sections used below are **Claims**, **Organisations**, and **Organisation Stats**.
|
||||
|
||||
## Viewing usage
|
||||
|
||||
**One organisation:** open **Admin Panel > Organisations** and select it. The **Organisation usage** section shows the current period's document, email, and API usage against its quotas.
|
||||
|
||||
**All organisations:** open **Admin Panel > Organisation Stats** to sort and filter monthly usage. Filter by **claim** and by **period** (a UTC calendar month, shown as `YYYY-MM`), and switch between **Show usage**, **Show usage with quotas**, and **Show daily averages**.
|
||||
|
||||
<Callout type="warn">
|
||||
Usage counts **attempts**, not only successful actions. A request that exceeds a quota is still counted before it is rejected, so displayed usage can read higher than the number of actions that succeeded.
|
||||
</Callout>
|
||||
|
||||
## Subscription claims
|
||||
|
||||
A subscription claim is a named bundle of limits and feature flags (for example `Free`, `Individual`, `Teams`, `Platform`, or `Enterprise`). Claims are **templates**: when an organisation is created it receives a private copy of its claim and reads from that copy afterwards. Editing a claim template therefore affects organisations created later, not existing ones — to change an existing organisation, [edit it directly](#change-limits-for-one-organisation).
|
||||
|
||||
### Claim fields
|
||||
|
||||
Under **Admin Panel > Claims** (`/admin/claims`), each claim has:
|
||||
|
||||
| Field | Controls |
|
||||
| ----------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Name** | The claim's display name. |
|
||||
| **Team Count** | Teams allowed. `0` = unlimited. |
|
||||
| **Member Count** | Members allowed. `0` = unlimited. |
|
||||
| **Envelope Item Count** | Uploaded files allowed per envelope. Minimum `1`. |
|
||||
| **Recipient Count** | Recipients allowed per document. `0` = unlimited. |
|
||||
| **Feature Flags** | Feature toggles (see [Feature flags](#feature-flags)). |
|
||||
| **Limits** | Monthly quota and rate-limit windows for Documents, Emails, and API. |
|
||||
| **Email transport** | Transport the claim uses. *Default (system mailer)* uses the instance default. |
|
||||
|
||||
### Quotas and rate limits
|
||||
|
||||
The **Limits** section has a column for **Documents**, **Emails**, and **API**, each with two controls:
|
||||
|
||||
- **Monthly quota** — how many of that resource are allowed per calendar month. An **empty** field is unlimited; **`0`** blocks the resource entirely.
|
||||
- **Rate limit windows** — optional short-window caps, each a duration and a maximum. A window is a number and a unit (`s`, `m`, `h`, `d`), such as `5m`, `1h`, or `24h`, and must be unique within the resource.
|
||||
|
||||
<Callout type="warn">
|
||||
Quotas and counts use opposite conventions for "unlimited": an **empty** quota is unlimited (and `0` blocks the resource), whereas `0` in the **Team**, **Member**, and **Recipient Count** fields means unlimited.
|
||||
</Callout>
|
||||
|
||||
### Feature flags
|
||||
|
||||
The **Feature Flags** section toggles capabilities such as Unlimited documents, Branding, Hide Documenso branding, Email domains, Embed authoring, Embed signing, White label for embed authoring/signing, 21 CFR, HIPAA, Authentication portal, Allow Legacy Envelopes, Signing reminders, QES signing, and Disable emails.
|
||||
|
||||
Some flags are Enterprise features. If your license does not include one, it is marked and cannot be enabled (you can still turn it off). See [Enterprise Edition](/docs/policies/enterprise-edition).
|
||||
|
||||
### Create or edit a claim template
|
||||
|
||||
1. Go to **Admin Panel > Claims**.
|
||||
2. Select **New claim**, or select an existing claim to edit it.
|
||||
3. Set the counts, feature flags, and the **Limits** section.
|
||||
4. Save. Changes apply to organisations created afterwards, not existing ones.
|
||||
|
||||
### Change limits for one organisation
|
||||
|
||||
To change limits for an existing organisation, edit it directly rather than its claim template.
|
||||
|
||||
1. Go to **Admin Panel > Organisations** and open the organisation.
|
||||
2. Adjust its quota, rate-limit, feature-flag, or email-transport fields.
|
||||
3. Save. Changes take effect immediately.
|
||||
|
||||
The organisation also shows the **Inherited subscription claim** it was created from.
|
||||
|
||||
## Usage reset
|
||||
|
||||
Monthly quota usage is keyed to the **UTC calendar month**. There is no scheduled reset job — when the month rolls over, the new period's counter starts at `0`.
|
||||
|
||||
## Limitations
|
||||
|
||||
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **1000 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| An organisation hit its limit unexpectedly | Usage counts rejected over-quota attempts. Compare usage against the quota under **Organisation Stats > Show usage with quotas**. |
|
||||
| A resource is blocked entirely, not just capped | The **Monthly quota** is `0`, which blocks the resource. Leave it empty for unlimited. |
|
||||
| Emails are not sending for an organisation | Check whether the **Disable emails** flag is enabled on the organisation's claim — it blocks all emails regardless of quota. |
|
||||
| A claim template edit had no effect | Template edits are not retroactive. Edit the organisation directly under **Admin Panel > Organisations**. |
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) - All configuration options
|
||||
- [Rate Limits](/docs/developers/api/rate-limits) - The global HTTP API rate limit (separate from claims)
|
||||
- [Enterprise Edition](/docs/policies/enterprise-edition) - Features unlocked by license flags
|
||||
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
|
||||
|
||||
### Enterprise Edition license
|
||||
|
||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
|
||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
|
||||
|
||||
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
|
||||
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
--accent: hsl(0 0% 27.8431%);
|
||||
--accent-foreground: hsl(95.0847 71.0843% 67.451%);
|
||||
--destructive: hsl(0 86.5979% 61.9608%);
|
||||
--destructive-foreground: hsl(0 87.6289% 19.0196%);
|
||||
--destructive-foreground: hsl(0 0% 98.0392%);
|
||||
--border: hsl(0 0% 27.8431%);
|
||||
--input: hsl(0 0% 27.8431%);
|
||||
--ring: hsl(95.0847 71.0843% 67.451%);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes';
|
||||
|
||||
/**
|
||||
* Deprecation banner steering API consumers away from the legacy document and
|
||||
* template create endpoints and towards the unified Envelope API.
|
||||
*
|
||||
* Registered globally in `mdx-components.tsx`, so it can be used in any MDX page
|
||||
* as `<EnvelopeWarning />` without an explicit import.
|
||||
*/
|
||||
export function EnvelopeWarning() {
|
||||
return (
|
||||
<Callout type="error">
|
||||
<strong>Documents and templates are being deprecated and replaced by envelopes.</strong>{' '}
|
||||
<a href={MIGRATION_GUIDE_HREF}>Read the migration guide here.</a>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as TabsComponents from 'fumadocs-ui/components/tabs';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { EnvelopeWarning } from '@/components/mdx/envelope-warning';
|
||||
import { Mermaid } from '@/components/mdx/mermaid';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -9,6 +10,7 @@ export function getMDXComponents(components?: MDXComponents): any {
|
||||
...defaultMdxComponents,
|
||||
...TabsComponents,
|
||||
Mermaid,
|
||||
EnvelopeWarning,
|
||||
...components,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type BrandingPreferencesResetDialogProps = {
|
||||
hasAdvancedBranding: boolean;
|
||||
isSubmitting: boolean;
|
||||
onReset: () => Promise<void>;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BrandingPreferencesResetDialog = ({
|
||||
hasAdvancedBranding,
|
||||
isSubmitting,
|
||||
onReset,
|
||||
trigger,
|
||||
}: BrandingPreferencesResetDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const isLoading = isSubmitting || isResetting;
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
setIsResetting(true);
|
||||
|
||||
try {
|
||||
await onReset();
|
||||
setOpen(false);
|
||||
} catch {
|
||||
// The submit handler surfaces its own error toast. Keep the dialog open
|
||||
// so the user can retry.
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
|
||||
<Trans>Reset to defaults</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Reset branding preferences</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
This will reset all branding preferences to their default values and save the changes immediately.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>Once confirmed, the following will be reset:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Custom branding enabled setting</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Branding logo</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Brand website and brand details</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Brand colours, including background, foreground, primary, and border colours</Trans>
|
||||
</li>
|
||||
|
||||
{hasAdvancedBranding && (
|
||||
<>
|
||||
<li>
|
||||
<Trans>Border radius</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Custom CSS</Trans>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isLoading}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
|
||||
<Trans>Reset to defaults</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type DocumentPreferencesResetDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
onReset: () => Promise<void>;
|
||||
showAiFeatures?: boolean;
|
||||
showDocumentVisibility?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentPreferencesResetDialog = ({
|
||||
isSubmitting,
|
||||
onReset,
|
||||
showAiFeatures = false,
|
||||
showDocumentVisibility = false,
|
||||
}: DocumentPreferencesResetDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const isLoading = isSubmitting || isResetting;
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
setIsResetting(true);
|
||||
|
||||
try {
|
||||
await onReset();
|
||||
setOpen(false);
|
||||
} catch {
|
||||
// The submit handler surfaces its own error toast. Keep the dialog open
|
||||
// so the user can retry.
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
|
||||
<Trans>Reset to defaults</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Reset document preferences</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
This will reset all document preferences to their default values and save the changes immediately.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>Once confirmed, the following will be reset:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
{showDocumentVisibility && (
|
||||
<li>
|
||||
<Trans>Default document visibility</Trans>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Trans>Default document language</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default date format</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default time zone</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default signature settings</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default recipients</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Delegate document ownership</Trans>
|
||||
</li>
|
||||
{showAiFeatures && (
|
||||
<li>
|
||||
<Trans>AI features</Trans>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isLoading}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
|
||||
<Trans>Reset to defaults</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -122,7 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
||||
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
@@ -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';
|
||||
@@ -336,7 +336,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
||||
>
|
||||
<div className="w-full text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-medium">
|
||||
<p className="font-medium">
|
||||
<Trans context="Plan price">Free</Trans>
|
||||
</p>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -115,7 +115,7 @@ export const OrganisationEmailDomainDeleteDialog = ({
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing{' '}
|
||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
||||
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
@@ -370,7 +370,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'inline-flex h-10 w-10 items-center justify-start text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
index === 0 ? 'mt-8' : 'mt-0',
|
||||
)}
|
||||
disabled={organisationMemberInvites.length === 1}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const NEVER_EXPIRE = 'NEVER' as const;
|
||||
|
||||
export const EXPIRATION_DATES = {
|
||||
ONE_WEEK: msg`7 days`,
|
||||
ONE_MONTH: msg`1 month`,
|
||||
THREE_MONTHS: msg`3 months`,
|
||||
SIX_MONTHS: msg`6 months`,
|
||||
ONE_YEAR: msg`12 months`,
|
||||
[NEVER_EXPIRE]: msg`Never`,
|
||||
} as const;
|
||||
|
||||
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
|
||||
tokenName: true,
|
||||
expirationDate: true,
|
||||
});
|
||||
|
||||
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
||||
|
||||
export type TokenCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const TokenCreateDialog = ({ trigger, ...props }: TokenCreateDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<TCreateTokenFormSchema>({
|
||||
resolver: zodResolver(ZCreateTokenFormSchema),
|
||||
defaultValues: {
|
||||
tokenName: '',
|
||||
expirationDate: 'THREE_MONTHS',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createToken } = trpc.apiToken.create.useMutation();
|
||||
|
||||
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
|
||||
try {
|
||||
const { token } = await createToken({
|
||||
teamId: team.id,
|
||||
tokenName,
|
||||
expirationDate: expirationDate === NEVER_EXPIRE ? null : expirationDate,
|
||||
});
|
||||
|
||||
setCreatedToken(token);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
|
||||
.otherwise(() => msg`Something went wrong. Please try again later.`);
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset();
|
||||
setCreatedToken(null);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0">
|
||||
<Trans>Create token</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
className="max-w-lg"
|
||||
position="center"
|
||||
onInteractOutside={(event) => {
|
||||
// Prevent losing the created token by accidentally clicking outside the dialog.
|
||||
if (createdToken) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{createdToken ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Token created</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Copy your token now. For security reasons you will not be able to see it again.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="pr-12 font-mono text-sm"
|
||||
aria-label={_(msg`Your new API token`)}
|
||||
name="createdToken"
|
||||
readOnly
|
||||
value={createdToken}
|
||||
/>
|
||||
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={createdToken}
|
||||
onCopySuccess={() => toast({ title: _(msg`Token copied to clipboard`) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
<Trans>Done</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create API token</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Use API tokens to authenticate with the Documenso API.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>A name to help you identify this token later.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expirationDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Expires in</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select value={field.value ?? NEVER_EXPIRE} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{_(date)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create token</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure you want to delete this token?</Trans>
|
||||
<Trans>Delete token</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
@@ -126,7 +126,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
||||
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
@@ -139,21 +139,18 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-nowrap gap-4">
|
||||
<Button type="button" variant="secondary" className="flex-1" onClick={() => setIsOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setIsOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>I'm sure! Delete it</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -117,7 +117,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
||||
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
@@ -503,7 +503,7 @@ export const ConfigureFieldsView = ({
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted-background',
|
||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted',
|
||||
selectedRecipientStyles.base,
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@documenso/lib/constants/branding';
|
||||
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -23,10 +24,12 @@ import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BrandingPreferencesResetDialog } from '~/components/dialogs/branding-preferences-reset-dialog';
|
||||
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(),
|
||||
@@ -74,6 +77,7 @@ export function BrandingPreferencesForm({
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
const [colorPickerKey, setColorPickerKey] = useState(0);
|
||||
|
||||
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
||||
const initialColors = parsedColors.success ? parsedColors.data : {};
|
||||
@@ -96,6 +100,42 @@ export function BrandingPreferencesForm({
|
||||
|
||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||
|
||||
const hasResetBrandingColors =
|
||||
settings.brandingColors === null ||
|
||||
settings.brandingColors === undefined ||
|
||||
(parsedColors.success && normalizeBrandingColors(parsedColors.data) === null);
|
||||
|
||||
// Only show the reset action when the saved settings actually differ from the
|
||||
// defaults, so it never renders as a pointless disabled button.
|
||||
const isResetToDefaultsVisible =
|
||||
settings.brandingEnabled !== (canInherit ? null : false) ||
|
||||
!!settings.brandingLogo ||
|
||||
!!settings.brandingUrl ||
|
||||
!!settings.brandingCompanyDetails ||
|
||||
!!settings.brandingCss ||
|
||||
!hasResetBrandingColors;
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
const data: TBrandingPreferencesFormSchema = {
|
||||
brandingEnabled: canInherit ? null : false,
|
||||
brandingLogo: null,
|
||||
brandingUrl: '',
|
||||
brandingCompanyDetails: '',
|
||||
brandingColors: {},
|
||||
brandingCss: '',
|
||||
};
|
||||
|
||||
await onFormSubmit(data);
|
||||
|
||||
if (previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
|
||||
setPreviewUrl('');
|
||||
setColorPickerKey((key) => key + 1);
|
||||
form.reset(data);
|
||||
};
|
||||
|
||||
const getSavedLogoPreviewUrl = () => {
|
||||
if (!settings.brandingLogo) {
|
||||
return '';
|
||||
@@ -171,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}
|
||||
@@ -213,7 +255,7 @@ export function BrandingPreferencesForm({
|
||||
<Trans>Enable custom branding for all documents in this organisation</Trans>
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -224,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 ? (
|
||||
@@ -306,7 +350,7 @@ export function BrandingPreferencesForm({
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -314,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>
|
||||
@@ -333,7 +379,7 @@ export function BrandingPreferencesForm({
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -341,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`}
|
||||
@@ -365,7 +413,7 @@ export function BrandingPreferencesForm({
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -397,6 +445,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`background-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.background}
|
||||
@@ -420,6 +469,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`foreground-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.foreground}
|
||||
@@ -443,6 +493,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`primary-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.primary}
|
||||
@@ -466,6 +517,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`primary-foreground-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
|
||||
@@ -489,6 +541,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`border-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.border}
|
||||
@@ -512,6 +565,7 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`ring-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.ring}
|
||||
@@ -593,6 +647,15 @@ export function BrandingPreferencesForm({
|
||||
isDirty={hasUnsavedChanges}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={handleReset}
|
||||
resetToDefaults={
|
||||
isResetToDefaultsVisible ? (
|
||||
<BrandingPreferencesResetDialog
|
||||
hasAdvancedBranding={hasAdvancedBranding}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={handleResetToDefaults}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -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,51 +1,35 @@
|
||||
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';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
|
||||
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
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 type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType, type RecipientRole } 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,80 +52,85 @@ 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>;
|
||||
};
|
||||
|
||||
export const DocumentPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
canInherit,
|
||||
isAiFeaturesConfigured = false,
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
|
||||
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
|
||||
|
||||
return {
|
||||
documentVisibility: settings.documentVisibility,
|
||||
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
||||
documentTimezone: settings.documentTimezone,
|
||||
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
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: ZDocumentMetaTimezoneSchema.nullable(),
|
||||
includeSenderDetails: z.boolean().nullable(),
|
||||
includeSigningCertificate: z.boolean().nullable(),
|
||||
includeAuditLog: z.boolean().nullable(),
|
||||
documentDateFormat: ZDocumentMetaDateFormatSchema.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);
|
||||
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
|
||||
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
|
||||
const resetValues = {
|
||||
...baseResetValues,
|
||||
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
|
||||
};
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
documentVisibility: settings.documentVisibility,
|
||||
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
||||
documentTimezone: settings.documentTimezone,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | 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,
|
||||
},
|
||||
defaultValues,
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
||||
// Parse both sides through the schema so we compare canonical representations
|
||||
const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues);
|
||||
const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues);
|
||||
|
||||
const isResetToDefaultsVisible =
|
||||
!parsedCurrentValues.success ||
|
||||
!parsedResetValues.success ||
|
||||
JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data);
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
await onFormSubmit(resetValues);
|
||||
form.reset(resetValues);
|
||||
};
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await onFormSubmit(data);
|
||||
@@ -162,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}
|
||||
@@ -209,7 +195,7 @@ export const DocumentPreferencesForm = ({
|
||||
<FormDescription>
|
||||
<Trans>Controls the default visibility of an uploaded document.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -218,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}
|
||||
@@ -256,7 +244,7 @@ export const DocumentPreferencesForm = ({
|
||||
communications with the recipients.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -264,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}
|
||||
@@ -295,7 +284,7 @@ export const DocumentPreferencesForm = ({
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -303,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`}
|
||||
@@ -320,7 +310,7 @@ export const DocumentPreferencesForm = ({
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -328,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) => ({
|
||||
@@ -356,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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -539,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'}
|
||||
@@ -611,7 +437,7 @@ export const DocumentPreferencesForm = ({
|
||||
<FormDescription>
|
||||
<Trans>Recipients that will be automatically added to new documents.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -620,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()}
|
||||
@@ -654,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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -721,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}
|
||||
@@ -763,7 +535,7 @@ export const DocumentPreferencesForm = ({
|
||||
prefer European regions where available.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -772,6 +544,16 @@ export const DocumentPreferencesForm = ({
|
||||
isDirty={form.formState.isDirty}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={() => form.reset()}
|
||||
resetToDefaults={
|
||||
isResetToDefaultsVisible ? (
|
||||
<DocumentPreferencesResetDialog
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={handleResetToDefaults}
|
||||
showAiFeatures={isAiFeaturesConfigured}
|
||||
showDocumentVisibility={!isPersonalLayoutMode}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,12 +3,33 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from '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;
|
||||
onReset: () => void;
|
||||
/**
|
||||
* Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while
|
||||
* the bar is floating so it never appears in the unsaved-changes island.
|
||||
*/
|
||||
resetToDefaults?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,7 +45,7 @@ export type FormStickySaveBarProps = {
|
||||
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
|
||||
* the pill chrome.
|
||||
*/
|
||||
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
|
||||
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -38,14 +59,18 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormSticky
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
@@ -100,6 +125,8 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormSticky
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
|
||||
{!isFloating && resetToDefaults}
|
||||
|
||||
{isDirty && (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
|
||||
<Trans>Undo</Trans>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -96,7 +96,7 @@ export const SignUpForm = ({
|
||||
password: '',
|
||||
signature: '',
|
||||
},
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(ZSignUpFormSchema),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,254 +0,0 @@
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { ApiToken } from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export const EXPIRATION_DATES = {
|
||||
ONE_WEEK: msg`7 days`,
|
||||
ONE_MONTH: msg`1 month`,
|
||||
THREE_MONTHS: msg`3 months`,
|
||||
SIX_MONTHS: msg`6 months`,
|
||||
ONE_YEAR: msg`12 months`,
|
||||
} as const;
|
||||
|
||||
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
|
||||
tokenName: true,
|
||||
expirationDate: true,
|
||||
});
|
||||
|
||||
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
||||
|
||||
type NewlyCreatedToken = {
|
||||
id: number;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type ApiTokenFormProps = {
|
||||
className?: string;
|
||||
tokens?: Pick<ApiToken, 'id'>[];
|
||||
};
|
||||
|
||||
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
|
||||
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
||||
|
||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
|
||||
onSuccess(data) {
|
||||
setNewlyCreatedToken(data);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<TCreateTokenFormSchema>({
|
||||
resolver: zodResolver(ZCreateTokenFormSchema),
|
||||
defaultValues: {
|
||||
tokenName: '',
|
||||
expirationDate: '',
|
||||
},
|
||||
});
|
||||
|
||||
const copyToken = async (token: string) => {
|
||||
try {
|
||||
const copied = await copy(token);
|
||||
|
||||
if (!copied) {
|
||||
throw new Error('Unable to copy the token');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Token copied to clipboard`),
|
||||
description: _(msg`The token was copied to your clipboard.`),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`Unable to copy token`),
|
||||
description: _(msg`We were unable to copy the token to your clipboard. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
|
||||
try {
|
||||
await createTokenMutation({
|
||||
teamId: team.id,
|
||||
tokenName,
|
||||
expirationDate: noExpirationDate ? null : expirationDate,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Token created`),
|
||||
description: _(msg`A new token was created successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
|
||||
.otherwise(() => msg`Something went wrong. Please try again later.`);
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="mt-6 flex w-full flex-col gap-4" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Token name</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<FormControl className="flex-1">
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription className="text-xs italic">
|
||||
<Trans>Please enter a meaningful name for your token. This will help you identify it later.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expirationDate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Token expiration date</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<FormControl className="flex-1">
|
||||
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={_(msg`Choose...`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{_(date)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel className="mt-2 text-muted-foreground">
|
||||
<Trans>Never expire</Trans>
|
||||
</FormLabel>
|
||||
<div className="block md:py-1.5">
|
||||
<Switch
|
||||
className="mt-2 bg-background"
|
||||
checked={noExpirationDate}
|
||||
onCheckedChange={setNoExpirationDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="hidden md:inline-flex" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create token</Trans>
|
||||
</Button>
|
||||
|
||||
<div className="md:hidden">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create token</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<AnimatePresence>
|
||||
{newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && (
|
||||
<motion.div
|
||||
className="mt-8"
|
||||
initial={{ opacity: 0, y: -40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 40 }}
|
||||
>
|
||||
<Card gradient>
|
||||
<CardContent className="p-4">
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Your token was created successfully! Make sure to copy it because you won't be able to see it again!
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
|
||||
{newlyCreatedToken.token}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken.token)}>
|
||||
<Trans>Copy token</Trans>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import type { TCachedLicense } from '@documenso/lib/types/license';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -9,6 +10,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyRoundIcon,
|
||||
@@ -29,6 +31,8 @@ type AdminLicenseCardProps = {
|
||||
|
||||
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
|
||||
|
||||
const { license } = licenseData || {};
|
||||
@@ -87,7 +91,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">
|
||||
<h3 className="mb-2 flex items-end font-medium text-foreground text-sm leading-tight">
|
||||
<Trans>Documenso License</Trans>
|
||||
</h3>
|
||||
|
||||
@@ -147,6 +151,24 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
>
|
||||
{isLicenseKeyVisible ? <EyeOffIcon className="h-3.5 w-3.5" /> : <EyeIcon className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
aria-label={t`Copy license key`}
|
||||
onClick={async () =>
|
||||
copy(license.licenseKey).then(() => {
|
||||
toast({
|
||||
title: t`Copied to clipboard`,
|
||||
description: t`The license key has been copied to your clipboard`,
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<CopyIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type PromptItem = {
|
||||
id: string;
|
||||
label: string | MessageDescriptor;
|
||||
sublabel?: string;
|
||||
path?: string;
|
||||
onAction?: () => void;
|
||||
icon?: LucideIcon;
|
||||
initials?: string;
|
||||
shortcut?: string;
|
||||
isChecked?: boolean;
|
||||
};
|
||||
|
||||
export type PromptCategory = {
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
items: PromptItem[];
|
||||
/**
|
||||
* The number of actual results, excluding utility rows such as the
|
||||
* "View all results" link.
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* The count shown on the category chip, or null to not show a chip at all.
|
||||
* Categories which only contain hardcoded page links have no chip.
|
||||
*/
|
||||
chipCount: number | null;
|
||||
isCapped: boolean;
|
||||
/**
|
||||
* Global admin categories are marked with a globe icon to distinguish them
|
||||
* from the equally named personal categories.
|
||||
*/
|
||||
isGlobal: boolean;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
|
||||
export const DirectTemplateInvalidPageView = () => {
|
||||
return (
|
||||
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||
<div>
|
||||
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
|
||||
|
||||
<h1 className="mt-4 font-semibold text-3xl">
|
||||
<Trans>Invalid direct link template</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
This direct link template cannot be used because one or more signers do not have a signature field assigned.
|
||||
Please contact the sender to update the template.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Clock, File, TimerOff, XCircle } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
@@ -46,6 +46,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 dark:text-red-300',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: msg`Expired`,
|
||||
labelExtended: msg`Document expired`,
|
||||
icon: TimerOff,
|
||||
color: 'text-orange-500 dark:text-orange-300',
|
||||
},
|
||||
INBOX: {
|
||||
label: msg`Inbox`,
|
||||
labelExtended: msg`Document inbox`,
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted-background',
|
||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
|
||||
selectedRecipientStyles.base,
|
||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||
{
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
||||
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||
|
||||
@@ -238,6 +239,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
{envelope.recipients.length === 0 && (
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Warns that a direct link template cannot be used because one or more signers
|
||||
* are missing a signature field.
|
||||
*/
|
||||
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
|
||||
className,
|
||||
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
|
||||
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
|
||||
|
||||
const signersMissingSignatureFields = useMemo(() => {
|
||||
if (!isTemplate || !envelope.directLink?.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
|
||||
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
|
||||
|
||||
if (signersMissingSignatureFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
|
||||
>
|
||||
<AlertTitle>
|
||||
<Trans>Invalid direct link template</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Recipients cannot use this direct link template because the following signers are missing a signature field
|
||||
</Trans>
|
||||
|
||||
<ul className="list-disc pl-5">
|
||||
{signersMissingSignatureFields.map((recipient, i) => (
|
||||
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import { match } from 'ts-pattern';
|
||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
|
||||
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
@@ -228,6 +229,8 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
||||
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
|
||||
|
||||
<Alert variant="warning" className="mx-auto max-w-[800px]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
|
||||
+71
-44
@@ -7,7 +7,12 @@ import { useOptionalSession } from '@documenso/lib/client-only/providers/session
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import {
|
||||
isAssistantLastSigner,
|
||||
isCcRecipient,
|
||||
normalizeRecipientSigningOrders,
|
||||
canRecipientBeModified as utilCanRecipientBeModified,
|
||||
} from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
||||
import {
|
||||
@@ -156,16 +161,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}, [watchedSigners]);
|
||||
|
||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||
return signers
|
||||
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
|
||||
.map((signer, index) => ({ ...signer, signingOrder: index + 1 }));
|
||||
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
||||
};
|
||||
|
||||
const {
|
||||
append: appendSigner,
|
||||
fields: signers,
|
||||
remove: removeSigner,
|
||||
} = useFieldArray({
|
||||
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
|
||||
|
||||
const { fields: signers, remove: removeSigner } = useFieldArray({
|
||||
control,
|
||||
name: 'signers',
|
||||
keyName: 'nativeId',
|
||||
@@ -208,14 +209,31 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
return utilCanRecipientBeModified(recipient, fields);
|
||||
};
|
||||
|
||||
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
|
||||
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (shouldFocus) {
|
||||
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
|
||||
|
||||
if (signerIndex !== -1) {
|
||||
requestAnimationFrame(() => form.setFocus(`signers.${signerIndex}.email`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
appendNormalizedSigner({
|
||||
formId: nanoid(12),
|
||||
name: '',
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
signingOrder: activeRecipientCount + 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -323,18 +341,16 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
form.setFocus(`signers.${emptySignerIndex}.email`);
|
||||
} else {
|
||||
appendSigner(
|
||||
appendNormalizedSigner(
|
||||
{
|
||||
formId: nanoid(12),
|
||||
name: currentEditorName ?? '',
|
||||
email: currentEditorEmail ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
},
|
||||
{
|
||||
shouldFocus: true,
|
||||
signingOrder: activeRecipientCount + 1,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
void form.trigger('signers');
|
||||
@@ -369,18 +385,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
items.splice(insertIndex, 0, reorderedSigner);
|
||||
|
||||
const updatedSigners = items.map((signer, index) => ({
|
||||
...signer,
|
||||
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : index + 1,
|
||||
}));
|
||||
const updatedSigners = normalizeSigningOrders(items);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||
if (isAssistantLastSigner(updatedSigners)) {
|
||||
toast({
|
||||
title: t`Warning: Assistant as last signer`,
|
||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
@@ -411,18 +423,19 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSigners = currentSigners.map((signer, idx) => ({
|
||||
...signer,
|
||||
role: idx === index ? role : signer.role,
|
||||
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : idx + 1,
|
||||
}));
|
||||
const updatedSigners = normalizeSigningOrders(
|
||||
currentSigners.map((signer, idx) => ({
|
||||
...signer,
|
||||
role: idx === index ? role : signer.role,
|
||||
})),
|
||||
);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
|
||||
toast({
|
||||
title: t`Warning: Assistant as last signer`,
|
||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
@@ -447,22 +460,30 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signer = currentSigners[index];
|
||||
|
||||
// Remove signer from current position and insert at new position
|
||||
const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||
remainingSigners.splice(newPosition, 0, signer);
|
||||
if (isCcRecipient(signer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSigners = remainingSigners.map((s, idx) => ({
|
||||
...s,
|
||||
signingOrder: !canRecipientBeModified(s.id) ? s.signingOrder : idx + 1,
|
||||
}));
|
||||
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
|
||||
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
|
||||
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
|
||||
|
||||
if (currentSigningOrderIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
|
||||
nonCcSigners.splice(newPosition, 0, reorderedSigner);
|
||||
|
||||
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||
if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
|
||||
toast({
|
||||
title: t`Warning: Assistant as last signer`,
|
||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
@@ -476,10 +497,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
setShowSigningOrderConfirmation(false);
|
||||
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
const updatedSigners = normalizeSigningOrders(
|
||||
currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
})),
|
||||
);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
@@ -796,6 +819,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
isDragDisabled={
|
||||
!isSigningOrderSequential ||
|
||||
isSubmitting ||
|
||||
isCcRecipient(signer) ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
!signer.signingOrder
|
||||
}
|
||||
@@ -819,7 +843,11 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
{isSigningOrderSequential && (
|
||||
{isSigningOrderSequential && isCcRecipient(signer) && (
|
||||
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{isSigningOrderSequential && !isCcRecipient(signer) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.signingOrder`}
|
||||
@@ -835,7 +863,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
max={signers.length}
|
||||
max={activeRecipientCount}
|
||||
data-testid="signing-order-input"
|
||||
className={cn(
|
||||
'w-10 text-center',
|
||||
@@ -976,7 +1004,6 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
onValueChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole);
|
||||
field.onChange(value);
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
|
||||
|
||||
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
||||
|
||||
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
|
||||
@@ -449,6 +450,9 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||
<input {...getReplaceInputProps()} />
|
||||
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
|
||||
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
|
||||
+21
-7
@@ -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}
|
||||
|
||||
@@ -174,7 +174,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
{type === EnvelopeType.DOCUMENT ? <Trans>Upload Document</Trans> : <Trans>Upload Template</Trans>}
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-md text-muted-foreground">
|
||||
<p className="mt-4 text-base text-muted-foreground">
|
||||
<Trans>Drag and drop your document here</Trans>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+2
-6
@@ -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,144 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import type { TAdminSearchResultType } from '@documenso/trpc/server/admin-router/admin-search.types';
|
||||
import { ADMIN_SEARCH_MAX_QUERY_LENGTH } from '@documenso/trpc/server/admin-router/admin-search.types';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ArrowRightIcon, Building2Icon, CreditCardIcon, FileTextIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import type { PromptCategory, PromptItem } from './app-command-menu.types';
|
||||
|
||||
/**
|
||||
* The maximum number of results the admin search returns per resource type.
|
||||
*/
|
||||
const ADMIN_SEARCH_RESULTS_CAP = 5;
|
||||
|
||||
const ADMIN_GROUP_LABELS: Record<TAdminSearchResultType, MessageDescriptor> = {
|
||||
document: msg`Documents`,
|
||||
user: msg`Users`,
|
||||
organisation: msg`Organisations`,
|
||||
team: msg`Teams`,
|
||||
recipient: msg`Recipients`,
|
||||
subscription: msg`Subscriptions`,
|
||||
};
|
||||
|
||||
const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
|
||||
document: FileTextIcon,
|
||||
user: UserIcon,
|
||||
organisation: Building2Icon,
|
||||
team: UsersIcon,
|
||||
recipient: UserIcon,
|
||||
subscription: CreditCardIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin list pages which support prefilling their search from the URL, used
|
||||
* for the "View all results" links on capped groups. Teams, recipients and
|
||||
* subscriptions have no admin list pages.
|
||||
*/
|
||||
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
||||
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
||||
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
||||
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
||||
};
|
||||
|
||||
export type UseAdminSearchCategoriesOptions = {
|
||||
/**
|
||||
* The trimmed, debounced search query.
|
||||
*/
|
||||
query: string;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The isolated admin portion of the command prompt: searches every admin
|
||||
* resource and maps the results to prompt categories marked as global.
|
||||
*
|
||||
* Returns no categories and never queries for non admin users. The admin
|
||||
* search endpoint is additionally guarded server side by the admin procedure.
|
||||
*/
|
||||
export const useAdminSearchCategories = ({ query, open }: UseAdminSearchCategoriesOptions) => {
|
||||
const { user } = useSession();
|
||||
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
// Admin searches hit every resource table, so require a longer query unless
|
||||
// it is a number, which could be a resource ID of any length. Queries over
|
||||
// the endpoint's length limit are skipped entirely instead of being sent
|
||||
// and rejected.
|
||||
const hasValidAdminSearch =
|
||||
isUserAdmin && query.length <= ADMIN_SEARCH_MAX_QUERY_LENGTH && (query.length > 3 || /^\d+$/.test(query));
|
||||
|
||||
const {
|
||||
data: adminSearchData,
|
||||
isFetching,
|
||||
isError,
|
||||
} = trpcReact.admin.search.useQuery(
|
||||
{
|
||||
query,
|
||||
},
|
||||
{
|
||||
enabled: open && hasValidAdminSearch,
|
||||
placeholderData: keepPreviousData,
|
||||
// Retyping is the retry in a search-as-you-type flow: fail fast so the
|
||||
// prompt can surface an honest error state instead of retrying.
|
||||
retry: false,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const categories = useMemo((): PromptCategory[] => {
|
||||
if (!hasValidAdminSearch || !adminSearchData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return adminSearchData.groups.map((group) => {
|
||||
const isCapped = group.results.length >= ADMIN_SEARCH_RESULTS_CAP;
|
||||
const buildListPath = ADMIN_GROUP_LIST_PATHS[group.type];
|
||||
|
||||
const items: PromptItem[] = group.results.map((result) => ({
|
||||
id: `admin-${group.type}-${result.value}`,
|
||||
label: result.label,
|
||||
sublabel: result.sublabel,
|
||||
path: result.path,
|
||||
icon: ADMIN_GROUP_ICONS[group.type],
|
||||
initials: group.type === 'user' || group.type === 'recipient' ? extractInitials(result.label) : undefined,
|
||||
}));
|
||||
|
||||
// Capped groups link to the full admin list page with the search
|
||||
// prefilled so the cap is never a dead end.
|
||||
if (isCapped && buildListPath) {
|
||||
items.push({
|
||||
id: `admin-${group.type}-view-all`,
|
||||
label: msg`View all results`,
|
||||
path: buildListPath(query),
|
||||
icon: ArrowRightIcon,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: `admin-${group.type}`,
|
||||
label: ADMIN_GROUP_LABELS[group.type],
|
||||
items,
|
||||
count: group.results.length,
|
||||
chipCount: group.results.length,
|
||||
isCapped,
|
||||
isGlobal: true,
|
||||
};
|
||||
});
|
||||
}, [hasValidAdminSearch, adminSearchData, query]);
|
||||
|
||||
return {
|
||||
isUserAdmin,
|
||||
categories,
|
||||
isFetching,
|
||||
isError,
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user