Compare commits

..
Author SHA1 Message Date
David Nguyen 91f9a1c3e5 fix: wip 2026-08-05 14:13:22 +10:00
David Nguyen 7cdc423c42 docs: amend signing groups spec for drag-only grouping 2026-08-04 20:11:25 +10:00
David Nguyen 34f6102a3f test: drive recipient grouping e2e through drag and drop 2026-08-04 20:11:22 +10:00
David Nguyen 6a1236cac2 feat: make recipient grouping drag-only with group badges 2026-08-04 20:11:19 +10:00
David Nguyen f47c9a7402 test: update signing order swap to use out-of-bounds extraction 2026-08-04 18:43:51 +10:00
David Nguyen 635332da2c fix: advance signing groups by step completion instead of send status 2026-08-04 18:43:49 +10:00
David Nguyen 83eb53fffb fix: mount signers field array so setValue reconciles reordered arrays 2026-08-04 18:43:46 +10:00
David Nguyen 9db2014b5f test: add signing group flow e2e 2026-08-04 18:22:19 +10:00
David Nguyen e0d8146a93 test: add envelope editor recipient grouping e2e 2026-08-04 18:20:58 +10:00
David Nguyen fc68e31d3d fix: guard transient partial signer entries during array reorder 2026-08-04 18:20:55 +10:00
David Nguyen cf26f30330 feat: render envelope editor recipients as signing step cards 2026-08-04 17:48:47 +10:00
David Nguyen cf78dc9d10 feat: add envelope editor recipient step list with group drag and drop 2026-08-04 17:41:18 +10:00
David Nguyen b852e36f3c feat: add envelope editor recipient step card 2026-08-04 17:39:41 +10:00
David Nguyen 119b77b508 feat: extract envelope editor recipient row component 2026-08-04 17:38:28 +10:00
David Nguyen ba1e720dcc feat: load editor recipients with group-preserving normalization 2026-08-04 17:37:06 +10:00
David Nguyen 8d48c92a27 feat: gate dictation UI to single-recipient next steps 2026-08-04 17:35:24 +10:00
David Nguyen 19514b182b feat: scope assistants to strictly later signing steps 2026-08-04 17:33:05 +10:00
David Nguyen 129833d6bb feat: gate next-signer dictation to single-recipient steps 2026-08-04 17:31:55 +10:00
David Nguyen e8484c4405 feat: activate whole signing group on sequential advance 2026-08-04 17:30:46 +10:00
David Nguyen bbe03bb83d feat: notify all members of the first signing group on send 2026-08-04 17:29:31 +10:00
David Nguyen 9c2f28a4f4 feat: use group-aware turn checks for sequential signing 2026-08-04 17:28:32 +10:00
David Nguyen d52000f648 feat: make assistant-last-signer check signing-group aware 2026-08-04 17:25:37 +10:00
David Nguyen 74c1752853 feat: add group-aware turn and dictation helpers 2026-08-04 17:24:53 +10:00
David Nguyen 9d0e61716e feat: add signing group editor operations 2026-08-04 17:22:56 +10:00
David Nguyen 8d82b1fae8 feat: add recipient signing group derivation and normalization 2026-08-04 17:20:42 +10:00
David Nguyen 03809d3231 docs: add recipient signing groups implementation plan 2026-08-04 17:15:58 +10:00
David Nguyen dbac6a787a docs: add recipient signing groups design spec 2026-08-04 15:08:59 +10:00
Lucas Smith 9c27ce6d18 feat: replace document status tabs with filter pills (#3145)
Swaps the tab row and dropdowns for faceted filter pills (status,
sender, period) with a shared reset, and moves URL param handling
to nuqs.

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

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

## Changes Made

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

## Testing Performed

Docs-only change. Every claim verified against the implementation
(`execute-webhook-call.ts`, job clients, `webhook-payload.ts`,
`assert-webhook-url.ts`, webhook-router schema).
2026-08-03 12:16:39 +10:00
Ephraim Duncan 6ec67d1c4d feat: rejected and expired recipient filters (#2889) 2026-07-30 10:41:45 +10:00
Ephraim Duncan a457e1ef7d feat: add copy button for license key in admin panel (#3123)
## Description

Adds a copy button next to the show/hide toggle for the license key in
the admin panel license card (Admin Panel → Stats).

- Ghost button matching the existing eye-toggle styling (`h-6 w-6`,
`CopyIcon`)
- Uses the standard `useCopyToClipboard` + toast pattern (same as
`template-direct-link-badge.tsx`)
- Copies the key regardless of masked/visible state

New translation strings will be picked up by the next `chore: extract
translations` run.

## Before / After

![Before and after: copy button added next to the license key show/hide
toggle](https://artifacts.duncan.land/documenso-pr3123-license-copy)

> Screenshots taken against a locally mocked ACTIVE license (the mock is
not part of this PR).

## Testing

- `npx tsc --noEmit -p apps/remix` clean
- `biome check` clean
- Smoke-tested in browser: clicking the button fires the "Copied to
clipboard" toast (visible in the screenshot above)
2026-07-27 02:18:03 +00:00
Catalin Pit e4897fa686 fix: sort CC recipients last (#2930) 2026-07-24 16:29:46 +10:00
David Nguyen c02dfaba1a feat: rework command search (#3109) 2026-07-23 13:16:44 +09:00
David Nguyen 54befb5962 fix: update stripe team member billing (#2991) 2026-07-23 14:09:06 +10:00
David Nguyen 26f0c4c5b7 chore: deprecate endpoints (#3022) 2026-07-23 13:57:09 +10:00
Lucas Smith 7f85388eb7 fix: increase global API rate limits to 1000/min (#3081) 2026-07-21 15:58:36 +10:00
Lucas Smith 3cf2963cd0 v2.16.0 2026-07-21 15:06:36 +10:00
Catalin Pit cc5ef3df16 feat: add branding preferences reset dialog (#3032) 2026-07-20 16:40:25 +09:00
Catalin Pit 4b72e7d546 feat: add document preferences reset dialog (#3039) 2026-07-20 16:02:39 +09:00
David Nguyen ba0dead96f fix: render error messages for invalid templates (#3088)
Currently direct templates can be created without the required
signatures fields for signers.

This means that the document can be fully signed by everyone but will
ultimately fail the sealing step which leaves the document in an
unrecoverable state.
2026-07-20 16:57:38 +10:00
Lucas Smith 40472bc26c fix: react hydration errors (#3099)
Move PostHog init out of the React tree so the client tree matches
the server render (mismatched useIds could abort hydration, leaving
dead event handlers). Also expose the CSP nonce so Radix scroll-lock
styles aren't blocked.
2026-07-16 12:21:43 +10:00
Ephraim Duncan 3ff7f70a7d feat: redesign api tokens settings page (#3076) 2026-07-15 12:42:13 +09:00
Lucas Smith 5c41740859 chore: deps upgrade 2026-07-14 (#3095) 2026-07-14 23:15:45 +10:00
David Nguyen d6268b1d7d fix: missing noreply email for resend (#3094) 2026-07-14 20:13:40 +09:00
Ephraim Duncan 12223c79cb fix(ui): improve destructive button contrast in dark mode (#3071) 2026-07-14 18:40:40 +09:00
David Nguyen b16f979eb3 fix: improve editor autosave (#3057) 2026-07-14 17:44:22 +09:00
Ephraim Duncan db031e2865 fix: replace tailwind class typos 2026-07-14 17:21:30 +09:00
github-actions[bot] 4e0038f2e8 chore: extract translations (#3060) 2026-07-14 17:16:21 +09:00
Lucas Smith c5efd34e95 v2.15.0 2026-07-13 12:43:22 +10:00
roshboi 21cff7a727 docs: update rate limit doc for organisation limit for self hosters (#3089) 2026-07-12 14:13:23 +08:00
Tanushree AhirandCatalin Pit 400b6a24f1 feat: enable real-time validation for email and password fields (#2204)
## Description

This MR improves the real-time validation experience on the signup page.
Currently, both the email and password fields validate only on blur,
causing users to continue seeing error messages even after correcting
their input until they click outside the field.

This update switches the React Hook Form configuration from mode:
'onBlur' to mode: 'onChange', ensuring validations update immediately as
the user types.
This results in a smoother and less confusing signup experience.

## Related Issue

Fixes #2200

## Changes Made

- Updated React Hook Form configuration in the signup form from:
- mode: 'onBlur' ➜ mode: 'onChange'
- Ensures real-time validation for email and password fields
- No schema or logic changes — only validation trigger updated


## Testing Performed

- Manually tested the signup page for:
- Real-time password rule updates
- Real-time email validation feedback
- Error disappearance immediately after correcting input

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

This is a small UX-focused improvement; the validation schema remains
unchanged.

Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2026-07-10 13:41:31 +03:00
roshboi 1b1e3d197b Add "Apply Your License Key" self-hosting guide (#3077)
Adds a dedicated self-hosting page documenting how to apply an
Enterprise license key, and cross-links it from the related docs.

New page: self-hosting/configuration/license.mdx — "Apply Your License
Key"
Set NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY (Docker Compose / docker run /
.env tabs)
2026-07-08 16:11:49 +10:00
Lucas Smith a276e18e1f chore: upgrade deps (#3065) 2026-07-08 16:11:00 +10:00
231 changed files with 20340 additions and 4985 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,175 @@
---
date: 2026-08-04
title: Recipient Signing Groups
---
## Summary
Allow recipients to be **grouped into a single signing step** when "Enable signing order" (SEQUENTIAL) is on. Grouped recipients share the same `signingOrder` number and may act **in any order among themselves**; the next step only unlocks once **every** member of the group has completed their required action.
- A **step** = all non-CC recipients sharing one `signingOrder` value.
- A **group** = a step with 2+ members.
- Feature surface: **V2 envelope editor only** (`apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx`). Backend enforcement is global (any document with duplicate orders behaves correctly, including API-created ones).
No database schema changes: `Recipient.signingOrder` is already a nullable, non-unique `Int` (`packages/prisma/schema.prisma:647`), and all tRPC/REST schemas already accept duplicate values (`z.number().optional()` everywhere). Today duplicates are only destroyed by client-side normalization.
## Amendments (2026-08-04, post-implementation)
- The step badge copy is **"Group N"** (not "Step N").
- The signing-order number input was **removed entirely** — grouping, joining,
extraction and reordering are **drag-and-drop only**. The "type-to-join" and
"out-of-bounds number extraction" decisions below are superseded; the
`Ungroup` link remains as the non-drag affordance for dissolving a group.
- Known limitation: gap drop-zones keep a constant hit area (drop-target
geometry is captured at drag start, so drag-dependent resizing would
desynchronise the visible strip from the actual hit area).
## Product decisions (agreed)
| Topic | Decision |
| --- | --- |
| Group representation | Derived from duplicate `signingOrder` values. No new tables/columns. |
| Whole-group drag | Required. Step cards are draggable as a unit (nested Kanban DnD). |
| Grouping gestures | Drag a recipient/step onto a card (combine) **or** type an existing step number into the order input (type-to-join, DocuSign style). |
| Removing one member | Drag the member row out to a gap zone, **or** type an out-of-bounds number (> step count) to become a standalone step at the end. |
| Ungroup link | Dissolves the whole group into consecutive standalone steps, preserving relative order. |
| Dictate next signer | Coexists with groups. Dictation UI/rewrite applies **only** when the completing signer is the last unsigned member of their step **and** the next step has exactly one member. Otherwise dictation is silently skipped for that transition. |
| Assistants | May be grouped. A grouped assistant can only assist recipients in **strictly higher** steps (never group peers). |
| CSC/TSP (AES/QES) instances | Groups are blocked (editor validation). TSP signing path unchanged. |
| V1 editors | Unchanged. Editing recipients of a grouped document in a V1 surface flattens groups (accepted limitation). |
## Current-state reference
Key decision points that assume a single "next recipient":
- `packages/lib/server-only/document/send-document.ts:150-157` — SEQUENTIAL initial send notifies `.slice(0, 1)` of pending recipients.
- `packages/lib/server-only/document/complete-document-with-token.ts:368-459` — after completion, `const [nextRecipient] = pendingRecipients` is activated (sendStatus SENT) and emailed; dictation rewrites that single recipient.
- `packages/lib/server-only/recipient/get-is-recipient-turn.ts:40-49`**index-based** loop: everyone earlier in the sorted array must be SIGNED. Two recipients sharing an order would block each other.
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts:263-279` — duplicated inline copy of the same index-based loop (feeds V2 signing `isRecipientsTurn`).
- `packages/lib/server-only/recipient/get-next-pending-recipient.ts` — returns `recipients[currentIndex + 1]` for the dictate-next-signer form (V1 sign loader).
- `packages/lib/server-only/template/create-document-from-direct-template.ts:674-742` — same single-next assumption for direct-template dictation.
- Assistant scope: `packages/lib/server-only/recipient/get-recipients-for-assistant.ts` and the assistant branch of `packages/trpc/server/envelope-router/sign-envelope-field.ts` use `signingOrder: { gte: ... }`.
- Client normalization: `normalizeRecipientSigningOrders` (`packages/lib/utils/recipients.ts:50-68`) force-renumbers non-CC recipients `index + 1`, destroying duplicates. Used by V2 editor (via `packages/lib/client-only/hooks/use-editor-recipients.ts`) and V1 editors.
- Editor autosave: watch-effect in `envelope-editor-recipient-form.tsx:524-588` diffs signers (incl. `signingOrder`) and calls `setRecipientsDebounced``trpc.envelope.recipient.set` (1000 ms debounce); meta changes go through `envelope.update`.
- The canonical sort everywhere: `orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }]`.
## 1. Data model & ordering semantics
- Orders stay dense `1..K` (K = number of steps): a grouped document looks like `[1, 2, 3, 3, 4]`.
- CC recipients keep `signingOrder: undefined` and always sort after steps (unchanged).
- REJECTED semantics unchanged: turn checks treat `signingStatus !== SIGNED` (including REJECTED) as blocking; rejection independently cancels the document via the existing flow.
- Null orders (legacy/API data) sort last (treated as `+Infinity` in comparisons).
## 2. Shared pure utilities — `packages/lib/utils/recipient-groups.ts` (new)
Client-safe pure functions, fully unit-tested:
- `groupRecipientsBySigningOrder(signers)``{ steps: Array<{ order: number; members: T[] }>, ccRecipients: T[] }`. Steps sorted ascending; members keep array order.
- `normalizeGroupedSigningOrders(signers, canUpdate?)` → dense-renumbers steps **preserving duplicates**. Contract mirrors the flat normalizer: steps sort by current order and are renumbered by sequence position; a step containing a locked recipient (per `canUpdate`) keeps the locked member's persisted order; editable steps never collide into a locked step's number (no accidental grouping — they take the next free position). In practice locked recipients occupy a prefix of the sequence (sequential signing means earlier steps signed first), so positions and persisted orders agree; API-created oddities degrade gracefully like today. CC recipients get `undefined` and move to the tail.
- Editor operations (each returns a new, normalized signers array; no mutation):
- `reorderStep(signers, fromStepIndex, toStepIndex)`
- `mergeSteps(signers, sourceStepIndex, targetStepIndex)` — all source members adopt the target step's order
- `moveRecipientToStep(signers, formId, targetStepIndex)` — join a group
- `extractRecipientToNewStep(signers, formId, insertStepIndex)` — become a standalone step at that gap position
- `ungroupStep(signers, stepIndex)` — members become consecutive standalone steps
- `getDictatableNextRecipient(recipients, currentRecipientId)` → the single next-step recipient, or `null` when the current signer isn't the last unsigned member of their step or the next step has ≠ 1 member. Shared by server dictation logic and client dictate-form mirrors.
`normalizeRecipientSigningOrders` (flat) is left untouched for V1 surfaces. `isAssistantLastSigner` (`packages/lib/utils/recipients.ts:25-30`) becomes group-aware: warns when any ASSISTANT sits in the **last step** (equivalent behavior for ungrouped documents).
## 3. Editor UI — structure & visuals
Component split under `apps/remix/app/components/general/envelope-editor/`:
- `envelope-editor-recipient-form.tsx` — retains header actions (AI detect, Add Myself, Add Signer), signing-order/dictate checkboxes, autosave watch-effect (unchanged logic), dialogs, limits alert.
- `recipient-step-list.tsx` (new) — `DragDropContext`, outer step `Droppable`, gap drop-zones, step derivation via `groupRecipientsBySigningOrder(watchedSigners)`.
- `recipient-step-card.tsx` (new) — card chrome: card-level grip, `Step {n}` badge (`Badge variant="neutral"`), group header row (`Users2Icon` + `{n} signers · any order` via `plural()` + right-aligned `Ungroup` link `Button variant="link"`), inner member `Droppable`.
- `recipient-row.tsx` (new) — moved row internals: row grip, order input, email/name `RecipientAutoCompleteInput`, `RecipientRoleSelect`, delete button, advanced `RecipientActionAuthSelect`.
Rendering rules:
- Form state remains the single flat `signers` field array (react-hook-form indices = flat array positions); steps are derived at render time only.
- Sequential mode: every step renders as a bordered card. Group cards (2+ members) get the green accent treatment: `border-primary`-tinted border, light green background, green-tinted order inputs, group header row visible.
- Single-member steps: card with `Step {n}` badge, card grip, and the member row (with its own row grip) — no group header.
- CC recipients: plain non-draggable cards without badge/order input, rendered after the last step.
- Parallel mode (signing order off): render today's flat rows — no cards, badges, or grouping UI.
- All new strings use `<Trans>`/`t`/`plural` macros.
## 4. Editor UI — interactions
### Drag & drop (nested Kanban, `@hello-pangea/dnd`)
- Outer `Droppable` `type="STEP"` (vertical) contains one `Draggable` per step; drag handle = card grip. `isCombineEnabled` on.
- Each step card contains an inner `Droppable` `type="RECIPIENT"` with one `Draggable` per member; drag handle = row grip.
- Gap zones: slim `Droppable`s of `type="RECIPIENT"` rendered between cards and at both ends. Collapsed (`h-2`, invisible) normally; while a RECIPIENT drag is active (tracked via `onBeforeCapture`), they expand to dashed strips (per mock image 2).
| Gesture | DnD result | Operation |
| --- | --- | --- |
| Card grip → drop between cards | `type=STEP`, `destination` | `reorderStep` |
| Card grip → drop onto another card's center | `type=STEP`, `combine` | `mergeSteps` |
| Row grip → drop onto another step card | `type=RECIPIENT`, destination = that card's inner droppable | `moveRecipientToStep` |
| Row grip → drop on a gap zone | `type=RECIPIENT`, destination = gap droppable | `extractRecipientToNewStep` |
| Row grip → drop within own step | destination = own inner droppable | no-op |
Hover affordances: target card shows a green ring + floating `Release to sign together` badge (with users icon) when it is a combine target (`snapshot.combineTargetFor`) **or** an inner-droppable hover target (`snapshot.isDraggingOver`). Existing drag styling (widget background, pointer-events) carries over.
After every operation: normalize → `form.setValue('signers', ...)` (validate + dirty) → assistant-last-step warning toast when applicable → `form.trigger('signers')`. The existing watch-effect autosaves.
### Signing-order number input
- `min=1`, `max=stepCount + 1` (spinner + typed, `data-testid="signing-order-input"` kept).
- Value `N` where `N` = own step → no-op.
- `N` in `1..K`, other step → `moveRecipientToStep` (type-to-join; also merges two solo steps into a group).
- `N > K``extractRecipientToNewStep` at the end (out-of-bounds extraction).
- Invalid input (empty, non-integer, `< 1`) → ignored (current behavior).
### Other interactions
- **Ungroup** link → `ungroupStep`.
- **Add Signer / Add Myself / AI detection** → new standalone step at the end (`signingOrder = stepCount + 1`).
- **Remove signer** → existing flow + group-aware normalize (a group of 2 losing a member dissolves into a plain step; empty steps disappear).
- **Role change to CC** → member leaves its step (normalize moves it to the tail). Role change to ASSISTANT inside a group is allowed.
- **Locked recipients** (signed or inserted fields, per `canRecipientBeModified`): row controls disabled as today. A step containing a locked member cannot have its order changed — card grip disabled (no drag/reorder, no combining it *into* another step) and Ungroup disabled. It **may** still receive new members (inner drop, combine-as-target, type-to-join), since that never alters the locked member's order; editable peers may still be dragged out individually.
- **Drag disabled** entirely when: parallel mode, submitting, or (per draggable) CC/locked — matching current `isDragDisabled` rules.
## 5. Backend signing flow (group-aware)
Single shared predicate (pure, in `recipient-groups.ts`): *a recipient may act iff no non-CC recipient with `signingStatus !== SIGNED` has a strictly lower `signingOrder` (null = ∞)*.
1. **Turn check**`get-is-recipient-turn.ts` replaces its index loop with the predicate; `get-envelope-for-recipient-signing.ts:263-279` deletes its inline copy and calls the same helper. Both keep their existing queries (add the `nulls: 'last'` + `id` tiebreaker to the sort in both for consistency).
2. **Initial send**`send-document.ts`: SEQUENTIAL now notifies **all** pending non-CC recipients holding the minimum pending order (replaces `.slice(0, 1)`).
3. **Completion advance**`complete-document-with-token.ts`: compute `nextGroup` = pending (non-SIGNED, non-CC) recipients at the minimum order. Activate (sendStatus SENT + sentAt) and email **only members with `sendStatus !== SENT`**, each via the existing `send.signing.requested.email` job. This one rule covers both cases: mid-group completion (remaining peers already SENT → nothing sent, no advance) and step transition (all next-step members activated together). The "waiting for others" pending email to the just-signed recipient is unchanged. Mirror the same logic in `create-document-from-direct-template.ts`.
4. **Dictate next signer** — rewrite (`nextSigner` name/email + RECIPIENT_UPDATED audit log) applies only when `allowDictateNextSigner && nextGroup.length === 1` and that member is freshly activated (`sendStatus !== SENT`). Server form source `get-next-pending-recipient.ts` and the client mirrors (`envelope-signing-provider.tsx`, `document-signing-page-view-v1.tsx`, `direct-template-signing-form.tsx`) all switch to `getDictatableNextRecipient` — the form only renders when the completing signer is the last unsigned member of their step and the next step has exactly one member.
5. **Assistants**`get-recipients-for-assistant.ts` and the assistant branch of `sign-envelope-field.ts` change `gte` → strictly-greater semantics so grouped assistants cannot act for group peers. Implementation must verify whether the current `gte` lists include the assistant themself and preserve that self-inclusion explicitly (`OR id = assistant.id`) if so.
6. **CSC/TSP** — no changes to `execute-tsp-sign.ts` (head-of-queue advance stays safe even if duplicates arrive via API). The editor blocks group creation on CSC instances via the existing CSC `superRefine` in `ZEditorRecipientsFormSchema`: add an issue when any non-CC duplicate `signingOrder` exists.
No email template, webhook, audit-log, or job-definition changes: activation emails, events, and logs are already per-recipient.
## 6. Validation & compatibility
- `ZEditorRecipientsFormSchema` gains the CSC no-duplicates issue only; tRPC/REST schemas stay `z.number().optional()` (duplicates are now legitimate).
- Templates: groups carry into created documents (`signingOrder` copies verbatim in template → document creation and document duplication). Verified by unit/E2E coverage.
- V1 editors and embed authoring keep the flat normalizer: opening & saving recipients there flattens groups into consecutive steps (accepted, documented limitation).
- API consumers that already send duplicate orders gain correct parallel-group behavior automatically.
## 7. Testing
**Unit (vitest, `packages/lib`)** — new `recipient-groups.test.ts` (+ extend `recipients.test.ts`):
- Step derivation (duplicates, nulls, CC exclusion, stable member order).
- `normalizeGroupedSigningOrders`: preserves groups, compacts gaps, locked-recipient anchoring, CC tail.
- Each editor operation: merge, join, extract (incl. out-of-bounds), reorder, ungroup, dissolve-on-removal.
- Turn predicate: group member allowed when lower steps signed; blocked by any lower unsigned/REJECTED; parallel mode; null orders; single-member equivalence with old behavior.
- `getDictatableNextRecipient`: eligibility matrix (mid-group vs last-of-group × next step size 1/2+/none).
**E2E (Playwright, `packages/app-tests`, per the envelope-editor-v2-e2e skill)**:
- Editor: type-to-join creates a group (badge `Step 3`, header `2 signers · any order`, green styling), persistence after reload, Ungroup restores sequential steps, out-of-bounds extraction.
- Signing flow: document `[1, (2,2), 3]` — after step 1 signs, both group members can access signing (either order); step 3 is blocked (waiting page) until both complete, then unlocks; activation emails fire once per member.
- One best-effort drag smoke test (combine two solo steps); drag logic correctness is otherwise covered by unit tests.
## 8. Out of scope
- Named groups, quorum ("k of n") semantics, or per-group metadata (would require an explicit group entity — future migration if ever needed).
- Whole-group drag *into* another group via card grip is a merge (`mergeSteps`); there is no "insert group inside group" concept.
- V1 editor group awareness.
- TSP/CSC parallel signing.
@@ -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 -1
View File
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
min-release-age = 7
# min-release-age = 7
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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>
@@ -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).
---
+1 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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>
);
}
+2
View File
@@ -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,141 @@
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;
showIncludeSenderDetails?: boolean;
};
export const DocumentPreferencesResetDialog = ({
isSubmitting,
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
showIncludeSenderDetails = 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>
{showIncludeSenderDetails && (
<li>
<Trans>Send on behalf of team</Trans>
</li>
)}
<li>
<Trans>Include the signing certificate in the document</Trans>
</li>
<li>
<Trans>Include the audit logs in the document</Trans>
</li>
<li>
<Trans>Default recipients</Trans>
</li>
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Default envelope expiration</Trans>
</li>
<li>
<Trans>Default signing reminders</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
</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>
@@ -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>
@@ -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}
@@ -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,6 +24,7 @@ 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';
@@ -74,6 +76,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 +99,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 '';
@@ -397,6 +436,7 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`background-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.background}
@@ -420,6 +460,7 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`foreground-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.foreground}
@@ -443,6 +484,7 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`primary-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primary}
@@ -466,6 +508,7 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`primary-foreground-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
@@ -489,6 +532,7 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`border-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.border}
@@ -512,6 +556,7 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`ring-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.ring}
@@ -593,6 +638,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>
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
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';
@@ -37,11 +37,11 @@ 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, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
@@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = {
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
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,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
};
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
@@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
@@ -127,26 +147,33 @@ export const DocumentPreferencesForm = ({
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);
@@ -772,6 +799,17 @@ 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}
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
) : undefined
}
/>
</fieldset>
</form>
@@ -3,12 +3,17 @@ 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';
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 +29,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);
@@ -100,6 +105,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>
+1 -1
View File
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onBlur',
mode: 'onChange',
resolver: zodResolver(ZSignUpFormSchema),
});
-254
View File
@@ -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;
};
@@ -9,7 +9,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus } from '@prisma/client';
import { CopyIcon } from 'lucide-react';
import { StackAvatar } from './stack-avatar';
@@ -41,8 +40,8 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
return (
<div
className={cn('-mx-2 flex items-center gap-2.5 rounded-md px-2 py-1.5', {
'group cursor-pointer hover:bg-muted': signingToken,
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken,
})}
role={signingToken ? 'button' : undefined}
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
@@ -53,19 +52,15 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
className="h-8 w-8 shrink-0 border-0 text-xs"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="truncate text-muted-foreground text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
<div
className="text-muted-foreground text-sm"
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
{signingToken && (
<CopyIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</div>
);
}
@@ -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>
);
};
@@ -11,6 +11,7 @@ import {
import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
@@ -223,27 +224,12 @@ export const DirectTemplateSigningForm = ({
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
return (
getDictatableNextRecipient({
recipients: template.recipients,
currentRecipientId: directRecipient.id,
}) ?? undefined
);
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return (
@@ -15,6 +15,7 @@ import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
@@ -143,31 +144,11 @@ export const DocumentSigningPageViewV1 = ({
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
if (documentMeta?.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
const sortedRecipients = [...allRecipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
return getDictatableNextRecipient({ recipients: allRecipients, currentRecipientId: recipient.id }) ?? undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
@@ -6,6 +6,7 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
@@ -290,32 +291,14 @@ export const EnvelopeSigningProvider = ({
.filter((field) => field.inserted);
const nextRecipient = useMemo(() => {
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
return null;
}
const sortedRecipients = [...envelope.recipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
return getDictatableNextRecipient({
recipients: envelope.recipients,
currentRecipientId: recipient.id,
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (
@@ -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`,
@@ -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 && (
@@ -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>
@@ -1,37 +1,30 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import {
updateEditorSigners,
ZEditorRecipientsFormSchema,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
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 { trpc } from '@documenso/trpc/react';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DragDropContext, Draggable, Droppable, type DropResult, type SensorAPI } from '@hello-pangea/dnd';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
@@ -41,6 +34,8 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { RecipientStepList } from './recipient-step-list';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
useCurrentEnvelopeEditor();
@@ -48,7 +43,6 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { sessionData } = useOptionalSession();
@@ -56,7 +50,6 @@ export const EnvelopeEditorRecipientForm = () => {
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
@@ -102,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients, fields } = envelope;
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const { recipients } = envelope;
const { form } = editorRecipients;
@@ -156,21 +134,20 @@ 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 normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
};
const {
append: appendSigner,
fields: signers,
remove: removeSigner,
} = useFieldArray({
// Keep a mounted field array for `signers` so react-hook-form reconciles
// whole-array `setValue` calls atomically. Without it, reordering the array
// leaves stale partial entries in watched values (missing email/name/role),
// which breaks validation and the autosave sync.
useFieldArray({
control,
name: 'signers',
keyName: 'nativeId',
});
const stepCount = useMemo(() => groupRecipientsBySigningOrder(watchedSigners).steps.length, [watchedSigners]);
const emptySignerIndex = watchedSigners.findIndex(
(signer) =>
!signer.name && !signer.email && envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
@@ -182,40 +159,40 @@ export const EnvelopeEditorRecipientForm = () => {
const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName);
// Note: Watched signer entries can be transiently partial while react-hook-form
// re-registers reordered array fields, so guard optional access here.
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
(signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
);
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
);
const canRecipientBeModified = (recipientId?: number) => {
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
updateEditorSigners(form, updatedSigners);
if (shouldFocus) {
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
if (signerIndex !== -1) {
requestAnimationFrame(() => form.setFocus(`signers.${signerIndex}.email`));
}
}
if (recipientId === undefined) {
return true;
}
const recipient = recipients.find((recipient) => recipient.id === recipientId);
if (!recipient) {
return false;
}
return utilCanRecipientBeModified(recipient, fields);
};
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: stepCount + 1,
});
};
@@ -227,8 +204,8 @@ export const EnvelopeEditorRecipientForm = () => {
// If the only signer is the default empty signer lets just replace it with the detected recipients
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
form.setValue(
'signers',
updateEditorSigners(
form,
detectedRecipients.map((recipient, index) => ({
formId: nanoid(12),
name: recipient.name,
@@ -237,10 +214,6 @@ export const EnvelopeEditorRecipientForm = () => {
actionAuth: [],
signingOrder: index + 1,
})),
{
shouldValidate: true,
shouldDirty: true,
},
);
return;
@@ -267,10 +240,7 @@ export const EnvelopeEditorRecipientForm = () => {
nextSigningOrder += 1;
}
form.setValue('signers', normalizeSigningOrders(currentSigners), {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
toast({
title: plural(detectedRecipients.length, {
@@ -284,32 +254,6 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
if (!canRecipientBeModified(signer.id)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) {
removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
}
};
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
@@ -323,168 +267,35 @@ 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: stepCount + 1,
},
true,
);
void form.trigger('signers');
}
};
const handleRecipientAutoCompleteSelect = (index: number, suggestion: RecipientAutoCompleteOption) => {
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
};
const onDragEnd = useCallback(
async (result: DropResult) => {
if (!result.destination) {
return;
}
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
// Find next valid position
let insertIndex = result.destination.index;
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) {
insertIndex++;
}
items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = items.map((signer, index) => ({
...signer,
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : index + 1,
}));
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
const lastSigner = updatedSigners[updatedSigners.length - 1];
if (lastSigner.role === RecipientRole.ASSISTANT) {
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.`,
});
}
await form.trigger('signers');
},
[form, canRecipientBeModified, watchedSigners, toast],
);
const handleRoleChange = useCallback(
(index: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
// Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = currentSigners.map((signer, idx) => ({
...signer,
role: idx === index ? role : signer.role,
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : idx + 1,
}));
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
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.`,
});
}
},
[form, toast, canRecipientBeModified],
);
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
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);
const updatedSigners = remainingSigners.map((s, idx) => ({
...s,
signingOrder: !canRecipientBeModified(s.id) ? s.signingOrder : idx + 1,
}));
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
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.`,
});
}
},
[form, canRecipientBeModified, toast],
);
const handleSigningOrderDisable = useCallback(() => {
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,
})),
);
updateEditorSigners(form, updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
@@ -565,7 +376,7 @@ export const EnvelopeEditorRecipientForm = () => {
}, [formValues]);
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
return (
<Card backdropBlur={false} className="border">
@@ -621,7 +432,7 @@ export const EnvelopeEditorRecipientForm = () => {
type="button"
className="flex-1"
size="sm"
disabled={isSubmitting || signers.length >= remaining.recipients}
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
@@ -771,283 +582,7 @@ export const EnvelopeEditorRecipientForm = () => {
)}
</div>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2': snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4': showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
})}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSigningOrderSequential}
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)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
<FormErrorMessage
className="mt-2"
@@ -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>
@@ -0,0 +1,233 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isCcRecipient } from '@documenso/lib/utils/recipients';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client';
import { GripVerticalIcon, TrashIcon } from 'lucide-react';
import { memo } from 'react';
import { useFormContext } from 'react-hook-form';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientRowProps = {
signerIndex: number;
signer: TEditorSigner;
isSequential: boolean;
isInputDisabled: boolean;
canBeModified: boolean;
isRemoveDisabled: boolean;
showAdvancedSettings: boolean;
dragHandleProps?: DraggableProvidedDragHandleProps | null;
recipientSuggestions: RecipientAutoCompleteOption[];
isLoadingSuggestions: boolean;
onRoleChange: (signerIndex: number, role: RecipientRole) => void;
onRemove: (signerIndex: number) => void;
onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void;
onSearchQueryChange: (query: string) => void;
};
const RecipientRowInner = ({
signerIndex,
signer,
isSequential,
isInputDisabled,
canBeModified,
isRemoveDisabled,
showAdvancedSettings,
dragHandleProps,
recipientSuggestions,
isLoadingSuggestions,
onRoleChange,
onRemove,
onAutoCompleteSelect,
onSearchQueryChange,
}: RecipientRowProps) => {
const { t } = useLingui();
const { envelope, editorConfig } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const form = useFormContext<TEditorRecipientsFormSchema>();
const { isSubmitting } = form.formState;
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified;
const rowErrors = form.formState.errors.signers?.[signerIndex];
return (
<fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
<div className="flex flex-row items-center gap-x-2">
{isSequential && !isCcRecipient(signer) && (
<span
{...(dragHandleProps ?? {})}
data-testid="recipient-row-drag-handle"
className={cn(
'mt-auto -ml-1.5 flex h-10 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{
'mb-6': rowErrors,
'cursor-default hover:bg-transparent': !dragHandleProps,
},
)}
>
<GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
'opacity-10': !dragHandleProps,
})}
/>
</span>
)}
<FormField
control={form.control}
name={`signers.${signerIndex}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6': rowErrors && !rowErrors.email,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6': rowErrors && !rowErrors.name,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${signerIndex + 1}`}
{...field}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6': rowErrors && !rowErrors.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onRoleChange(signerIndex, value as RecipientRole);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': rowErrors,
})}
data-testid="remove-signer-button"
disabled={isFieldDisabled || isRemoveDisabled || isDirectRecipient}
onClick={() => onRemove(signerIndex)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${signerIndex}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6': rowErrors && !rowErrors.actionAuth,
'pl-6': isSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect {...field} onValueChange={field.onChange} disabled={isFieldDisabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</fieldset>
);
};
/**
* Memoized: rows contain heavy inputs (autocomplete, role select) and would
* otherwise re-render on every drag state change, making drags feel sluggish.
* All callback props are stable (useCallback in the list) and `signer` object
* identities only change when form values actually change.
*/
export const RecipientRow = memo(RecipientRowInner);
@@ -0,0 +1,247 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import type { RecipientStep } from '@documenso/lib/utils/recipient-groups';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd';
import { Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans } from '@lingui/react/macro';
import { GripVerticalIcon, Users2Icon } from 'lucide-react';
import { RecipientRow, type RecipientRowProps } from './recipient-row';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type DraggingType = 'STEP' | 'RECIPIENT' | null;
/**
* Skips the drop animation. The post-drop state update re-sorts and renumbers
* the groups anyway, so gliding to the predicted slot first makes every drop
* feel like it settles twice — snapping hands control to the real re-render
* immediately instead.
*/
const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {
if (!snapshot.isDropAnimating) {
return provided.draggableProps.style;
}
return {
...provided.draggableProps.style,
transitionDuration: '0.001s',
};
};
export type RecipientStepCardSharedRowProps = Pick<
RecipientRowProps,
| 'showAdvancedSettings'
| 'recipientSuggestions'
| 'isLoadingSuggestions'
| 'onRoleChange'
| 'onRemove'
| 'onAutoCompleteSelect'
| 'onSearchQueryChange'
>;
export type RecipientStepCardProps = {
stepIndex: number;
step: RecipientStep<TEditorSigner>;
isLastStep: boolean;
draggableProvided: DraggableProvided;
draggableSnapshot: DraggableStateSnapshot;
draggingType: DraggingType;
isStepLocked: boolean;
isRemoveDisabled: boolean;
flatIndexByFormId: Map<string, number>;
canSignerBeModified: (signer: TEditorSigner) => boolean;
isSubmitting: boolean;
onUngroup: (stepIndex: number) => void;
rowProps: RecipientStepCardSharedRowProps;
};
/**
* The drop-zone strip rendered above each group card (and below the last one)
* that receives recipient-row drops. Invisible until a dragged row hovers it,
* then it shows a full-width green line marking the insertion point.
*
* Notes:
* - It lives INSIDE the step's Draggable so it shifts together with the card
* while groups are being reordered — a static strip between draggables
* would stay behind while the cards around it are displaced, making group
* drags look broken.
* - Its `droppableId` must stay STABLE while mounted (anchored to a formId,
* never a positional index): @hello-pangea/dnd does not support changing
* ids on mounted droppables/draggables, which silently breaks them.
* - `type="RECIPIENT"` already scopes it to recipient-row drags, and
* `isDropDisabled` must not be toggled based on the active drag, as
* @hello-pangea/dnd snapshots it at drag start (before state updates land).
* - It must keep a CONSTANT size: droppable geometry is captured when a drag
* starts, so resizing during the drag would leave the visible strip and the
* actual hit area in different places. Only colors may change mid-drag.
*/
const RecipientStepGap = ({ droppableId }: { droppableId: string }) => (
<Droppable droppableId={droppableId} type="RECIPIENT">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn('flex h-6 items-center', {
'gap-active': snapshot.isDraggingOver,
})}
>
<div
className={cn('h-[3px] w-full rounded-full bg-primary opacity-0 transition-opacity duration-100', {
'opacity-100': snapshot.isDraggingOver,
})}
/>
{provided.placeholder}
</div>
)}
</Droppable>
);
export const RecipientStepCard = ({
stepIndex,
step,
isLastStep,
draggableProvided,
draggableSnapshot,
draggingType,
isStepLocked,
isRemoveDisabled,
flatIndexByFormId,
canSignerBeModified,
isSubmitting,
onUngroup,
rowProps,
}: RecipientStepCardProps) => {
const isGroup = step.members.length > 1;
const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor);
// All droppable ids are anchored to the first member's formId (never a
// positional index) so they stay stable while cards are reordered —
// @hello-pangea/dnd does not support changing ids on mounted elements.
const stepAnchor = step.members[0].formId;
return (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
style={getDraggableStyle(draggableProvided, draggableSnapshot)}
className={cn({
'pointer-events-none': draggableSnapshot.isDragging,
})}
>
<RecipientStepGap droppableId={`gap-${stepAnchor}`} />
<Droppable droppableId={`step-members-${stepAnchor}`} type="RECIPIENT">
{(droppableProvided, droppableSnapshot) => {
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
const isHighlighted = isCombineTarget || isJoinTarget;
return (
<div
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
data-testid="recipient-step-card"
className={cn('relative rounded-lg border bg-background px-3 pt-2 pb-1 transition-shadow', {
'border-primary/60 bg-primary/5': isGroup,
'bg-widget-foreground shadow-lg': draggableSnapshot.isDragging,
'border-primary ring-1 ring-primary': isHighlighted,
})}
>
{isHighlighted && (
<Badge
variant="default"
size="small"
className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm"
>
<Users2Icon className="h-3 w-3" />
<Trans>Release to group</Trans>
</Badge>
)}
<div className="flex flex-row items-center gap-x-1">
<span
{...(draggableProvided.dragHandleProps ?? {})}
data-testid="step-drag-handle"
className={cn(
'-my-1 -ml-1.5 flex h-8 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{ 'pointer-events-none opacity-30': isStepLocked },
)}
>
<GripVerticalIcon className="h-4 w-4 opacity-60" />
</span>
<Badge variant={isGroup ? 'default' : 'neutral'} size="small">
<Trans>Group {step.order}</Trans>
</Badge>
{isGroup && (
<>
<span className="ml-1 flex items-center gap-x-1.5 text-green-700 text-xs dark:text-green-400">
<Users2Icon className="h-3.5 w-3.5" />
<Trans>{step.members.length} recipients · any order</Trans>
</span>
<Button
type="button"
variant="link"
size="sm"
data-testid="ungroup-step-button"
className="ml-auto h-auto p-0 text-xs"
disabled={isStepLocked || isSubmitting}
onClick={() => onUngroup(stepIndex)}
>
<Trans>Ungroup</Trans>
</Button>
</>
)}
</div>
{step.members.map((member, memberIndex) => {
const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
const canBeModified = canSignerBeModified(member);
return (
<Draggable
key={member.formId}
draggableId={`recipient-${member.formId}`}
index={memberIndex}
isDragDisabled={isSubmitting || !canBeModified}
>
{(memberProvided, memberSnapshot) => (
<div
ref={memberProvided.innerRef}
{...memberProvided.draggableProps}
style={getDraggableStyle(memberProvided, memberSnapshot)}
className={cn({
'rounded-md bg-widget-foreground shadow-lg': memberSnapshot.isDragging,
})}
>
<RecipientRow
signerIndex={signerIndex}
signer={member}
isSequential={true}
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
canBeModified={canBeModified}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={memberProvided.dragHandleProps}
{...rowProps}
/>
</div>
)}
</Draggable>
);
})}
{droppableProvided.placeholder}
</div>
);
}}
</Droppable>
{isLastStep && <RecipientStepGap droppableId="gap-end" />}
</div>
);
};
@@ -0,0 +1,359 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import {
type TEditorRecipientsFormSchema,
updateEditorSigners,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
extractRecipientToNewStep,
groupRecipientsBySigningOrder,
mergeSteps,
moveRecipientToStep,
normalizeGroupedSigningOrders,
reorderStep,
ungroupStep,
} from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { Badge } from '@documenso/ui/primitives/badge';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useCallback, useMemo, useState } from 'react';
import { RecipientRow } from './recipient-row';
import { type DraggingType, RecipientStepCard } from './recipient-step-card';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientStepListProps = {
showAdvancedSettings: boolean;
};
export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { envelope, editorRecipients, isEmbedded } = useCurrentEnvelopeEditor();
const { form } = editorRecipients;
const [draggingType, setDraggingType] = useState<DraggingType>(null);
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const watchedSigners = form.watch('signers');
const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
const { isSubmitting } = form.formState;
const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]);
const isRemoveDisabled = watchedSigners.length === 1;
const flatIndexByFormId = useMemo(
() => new Map(watchedSigners.map((signer, index) => [signer.formId, index])),
[watchedSigners],
);
const canSignerBeModified = useCallback(
(signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id),
[envelope],
);
const applySigners = useCallback(
(updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => {
const { warnWhenAssistantLast = true } = options;
updateEditorSigners(form, updatedSigners);
if (warnWhenAssistantLast && 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.`,
});
}
void form.trigger('signers');
},
[form, t, toast],
);
const handleRoleChange = useCallback(
(signerIndex: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
currentSigners.map((signer, index) => ({
...signer,
role: index === signerIndex ? role : signer.role,
})),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleRemove = useCallback(
(signerIndex: number) => {
const signer = form.getValues('signers')[signerIndex];
if (!signer) {
return;
}
if (!canSignerBeModified(signer)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
form.getValues('signers').filter((s) => s.formId !== signer.formId),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: false });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleUngroup = useCallback(
(stepIndex: number) => {
applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified));
},
[form, canSignerBeModified, applySigners],
);
const handleAutoCompleteSelect = useCallback(
(signerIndex: number, suggestion: RecipientAutoCompleteOption) => {
form.setValue(`signers.${signerIndex}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
},
[form],
);
const onBeforeCapture = useCallback((before: BeforeCapture) => {
setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT');
}, []);
const onDragEnd = useCallback(
(result: DropResult) => {
setDraggingType(null);
const currentSigners = form.getValues('signers');
// Drag-and-drop ids are anchored to the first member's formId so they
// stay stable across reorders; resolve them back to step indexes here.
const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners);
const findStepIndexByAnchor = (anchorFormId: string) =>
currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId);
if (result.type === 'STEP') {
if (result.combine) {
const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified));
return;
}
if (result.destination) {
applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified));
}
return;
}
if (result.type === 'RECIPIENT' && result.destination) {
const formId = result.draggableId.slice('recipient-'.length);
const { droppableId } = result.destination;
if (droppableId === 'gap-end') {
applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified));
return;
}
if (droppableId.startsWith('gap-')) {
const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length));
if (insertStepIndex === -1) {
return;
}
applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified));
return;
}
if (droppableId.startsWith('step-members-')) {
const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified));
}
}
},
[form, canSignerBeModified, applySigners],
);
const sharedRowProps = {
showAdvancedSettings,
recipientSuggestions,
isLoadingSuggestions: isLoading,
onRoleChange: handleRoleChange,
onRemove: handleRemove,
onAutoCompleteSelect: handleAutoCompleteSelect,
onSearchQueryChange: setRecipientSearchQuery,
};
return (
<div>
{!showAdvancedSettings && !isSequential && (
<div className="mb-1 flex flex-row gap-x-2 text-sm">
<span className="w-full">
<Trans>Email</Trans>
</span>
<span className="w-full">
<Trans>Name</Trans>
</span>
<span className="w-[7.5rem] flex-shrink-0" />
</div>
)}
{!isSequential ? (
<div className="flex w-full flex-col">
{watchedSigners.map((signer, index) => (
<RecipientRow
key={signer.formId}
signerIndex={index}
signer={signer}
isSequential={false}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
))}
</div>
) : (
<>
<DragDropContext onBeforeCapture={onBeforeCapture} onDragEnd={onDragEnd}>
<Droppable droppableId="recipient-steps" type="STEP" isCombineEnabled>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col">
{steps.map((step, stepIndex) => {
const isStepLocked = step.members.some((member) => !canSignerBeModified(member));
return (
<Draggable
key={`step-${step.members[0].formId}`}
draggableId={`step-${step.members[0].formId}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
isLastStep={stepIndex === steps.length - 1}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{ccRecipients.length > 0 && (
<div className="my-1 rounded-lg border px-3 py-1.5">
<Badge variant="neutral" size="small">
<Trans>Receives Copy</Trans>
</Badge>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
@@ -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,5 +1,4 @@
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
const ZIndexes: { [key: string]: string } = {
@@ -15,10 +14,9 @@ export type StackAvatarProps = {
zIndex?: string;
fallbackText?: string;
type: RecipientStatusType;
className?: string;
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
let classes = '';
let zIndexClass = '';
const firstClass = first ? '' : '-ml-3';
@@ -48,14 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className
}
return (
<Avatar
className={cn(
zIndexClass,
firstClass,
'h-10 w-10 border-2 border-white border-solid dark:border-border',
className,
)}
>
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
</Avatar>
);
@@ -2,7 +2,6 @@ import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-onl
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@@ -59,90 +58,94 @@ export const StackAvatarsWithTooltip = ({
];
}, [recipients]);
const sections = [
{
key: 'completed',
label: <Trans>Completed</Trans>,
dotClassName: 'bg-green-500',
labelClassName: 'text-green-600 dark:text-green-400',
recipients: completedRecipients,
isInteractive: false,
},
{
key: 'rejected',
label: <Trans>Rejected</Trans>,
dotClassName: 'bg-red-500',
labelClassName: 'text-red-600 dark:text-red-400',
recipients: rejectedRecipients,
isInteractive: false,
},
{
key: 'waiting',
label: <Trans>Waiting</Trans>,
dotClassName: 'bg-blue-500',
labelClassName: 'text-blue-600 dark:text-blue-400',
recipients: waitingRecipients,
isInteractive: true,
},
{
key: 'opened',
label: <Trans>Opened</Trans>,
dotClassName: 'bg-amber-400',
labelClassName: 'text-amber-600 dark:text-amber-400',
recipients: openedRecipients,
isInteractive: true,
},
{
key: 'uncompleted',
label: <Trans>Uncompleted</Trans>,
dotClassName: 'bg-gray-400',
labelClassName: 'text-muted-foreground',
recipients: uncompletedRecipients,
isInteractive: true,
},
].filter((section) => section.recipients.length > 0);
return (
<PopoverHover
trigger={children || <StackAvatars recipients={sortedRecipients} />}
contentProps={{
className: 'w-64 divide-y divide-border/50 overflow-hidden p-0 text-sm',
className: 'flex flex-col gap-y-5 py-2',
side: position,
}}
>
{sections.map((section) => (
<div key={section.key} className="px-3 pt-2.5 pb-1">
<div className="flex items-center gap-1.5 text-xs">
<span className={cn('h-1.5 w-1.5 rounded-full', section.dotClassName)} />
<span className={section.labelClassName}>{section.label}</span>
<span className="text-muted-foreground">{section.recipients.length}</span>
</div>
<div className="mt-1.5">
{section.isInteractive
? section.recipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))
: section.recipients.map((recipient) => (
<div key={recipient.id} className="-mx-2 flex items-center gap-2.5 rounded-md px-2 py-1.5">
<StackAvatar
first={true}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
className="h-8 w-8 shrink-0 border-0 text-xs"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{recipient.email || recipient.name}</p>
<p className="truncate text-muted-foreground text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
{completedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Completed</Trans>
</h1>
{completedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
))}
)}
{rejectedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Rejected</Trans>
</h1>
{rejectedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Waiting</Trans>
</h1>
{waitingRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Opened</Trans>
</h1>
{openedRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Uncompleted</Trans>
</h1>
{uncompletedRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
</PopoverHover>
);
};
@@ -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,
};
};
@@ -1,7 +1,7 @@
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
import { Bird, CheckCircle2, TimerOff, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
@@ -29,6 +29,16 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
icon: XCircle,
}))
.with(ExtendedDocumentStatus.REJECTED, () => ({
title: msg`No rejected documents`,
message: msg`There are no rejected documents. Documents that a recipient declines to sign will appear here.`,
icon: XCircle,
}))
.with(ExtendedDocumentStatus.EXPIRED, () => ({
title: msg`No expired documents`,
message: msg`There are no documents with expired signing links. You can redistribute a document to renew its expiration.`,
icon: TimerOff,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: msg`We're all empty`,
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
@@ -0,0 +1,40 @@
import { Trans } from '@lingui/react/macro';
import { CalendarIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params';
const PERIOD_OPTIONS = [
{ value: '7d', label: <Trans>Last 7 days</Trans> },
{ value: '14d', label: <Trans>Last 14 days</Trans> },
{ value: '30d', label: <Trans>Last 30 days</Trans> },
];
export const DocumentsTablePeriodFilter = () => {
const [{ period }, setSearchParams] = useQueryStates(
{
period: documentsSearchParams.period,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const onChange = (newPeriod: string | null) => {
void setSearchParams({
period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null,
page: null,
});
};
return (
<FilterPill
icon={CalendarIcon}
label={<Trans>Period</Trans>}
value={period}
onChange={onChange}
options={PERIOD_OPTIONS}
testId="documents-table-period-filter"
/>
);
};
@@ -1,63 +1,61 @@
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { trpc } from '@documenso/trpc/react';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { UserIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableSenderFilterProps = {
teamId: number;
};
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { _ } = useLingui();
const isMounted = useIsMounted();
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
const [{ senderIds }, setSearchParams] = useQueryStates(
{
senderIds: documentsSearchParams.senderIds,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString());
const { data, isLoading } = trpc.team.member.getMany.useQuery({
teamId,
});
const comboBoxOptions = (data ?? []).map((member) => ({
const options = (data ?? []).map((member) => ({
label: member.name ?? member.email,
value: member.userId.toString(),
}));
const onChange = (newSenderIds: string[]) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('senderIds', newSenderIds.join(','));
if (newSenderIds.length === 0) {
params.delete('senderIds');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
void setSearchParams({
senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null,
page: null,
});
};
return (
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="font-normal text-muted-foreground">
<Trans>
<span className="text-muted-foreground/70">Sender:</span> All
</Trans>
</p>
}
enableClearAllButton={true}
inputPlaceholder={msg`Search`}
loading={!isMounted || isLoading}
options={comboBoxOptions}
selectedValues={senderIds}
<FilterPill
multiple
icon={UserIcon}
label={<Trans>Sender</Trans>}
value={selectedSenderIds}
onChange={onChange}
options={options}
enableSearch
searchPlaceholder={_(msg`Search members...`)}
loading={!isMounted || isLoading}
testId="documents-table-sender-filter"
/>
);
};
@@ -0,0 +1,98 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationType } from '@prisma/client';
import { ListFilterIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useMemo } from 'react';
import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
stats: TFindDocumentsInternalResponse['stats'];
};
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
status: documentsSearchParams.status,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const selectableStatuses = useMemo(
() =>
SELECTABLE_STATUSES.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
[organisation.type],
);
const selectedStatus = useMemo(
() => selectableStatuses.find((value) => value === status) ?? null,
[selectableStatuses, status],
);
const onChange = (newStatus: string | null) => {
void setSearchParams({
status: selectableStatuses.find((value) => value === newStatus) ?? null,
page: null,
});
};
return (
<>
<FilterPill
icon={ListFilterIcon}
label={<Trans>Status</Trans>}
value={selectedStatus}
onChange={onChange}
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: formatStatsCount(stats[value]),
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
</span>
))}
</span>
</>
);
};
const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.EXPIRED,
];
const formatStatsCount = (count: number) => {
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
};
@@ -1,9 +1,11 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { useEffect } from 'react';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onDownloadClick?: () => void;
onMoveClick: () => void;
onDeleteClick: () => void;
onCancelClick?: () => void;
@@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = {
export const EnvelopesTableBulkActionBar = ({
selectedCount,
onDownloadClick,
onMoveClick,
onDeleteClick,
onCancelClick,
@@ -19,37 +22,106 @@ export const EnvelopesTableBulkActionBar = ({
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
useEffect(() => {
if (selectedCount === 0) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
// Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault
// when handling Escape, so this only clears the selection when nothing
// else consumed the key press.
if (event.key === 'Escape' && !event.defaultPrevented) {
onClearSelection();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [selectedCount, onClearSelection]);
if (selectedCount === 0) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="font-medium text-sm">
<Trans>{selectedCount} selected</Trans>
</span>
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
<div className="flex items-center gap-x-2 px-2">
<span className="sr-only" aria-live="polite">
<Trans>{selectedCount} selected</Trans>
</span>
<span
aria-hidden="true"
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
>
{selectedCount}
</span>
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
<Trans>selected</Trans>
</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="mx-1 h-5 w-px bg-border" />
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
<FolderInputIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onMoveClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<FolderInputIcon className="size-4 shrink-0" />
<Trans>Move</Trans>
</Button>
{onDownloadClick && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDownloadClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<DownloadIcon className="size-4 shrink-0" />
<Trans>Download</Trans>
</Button>
)}
{onCancelClick && (
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
<XCircleIcon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancelClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<XCircleIcon className="size-4 shrink-0" />
<Trans>Cancel</Trans>
</Button>
)}
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDeleteClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2Icon className="size-4 shrink-0" />
<Trans>Delete</Trans>
</Button>
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
<XIcon className="h-4 w-4" />
<div className="mx-1 h-5 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClearSelection}
aria-label={t`Clear selection`}
className="h-8 w-8 p-0"
>
<XIcon className="size-4 shrink-0" />
</Button>
</div>
);
+20 -15
View File
@@ -3,27 +3,32 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react';
import { StrictMode, startTransition, useEffect } from 'react';
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import './utils/polyfills/promise-with-resolvers';
function PosthogInit() {
/**
* Initialised imperatively (not as a component inside `hydrateRoot`) because
* rendering extra client-only siblings changes the React tree structure
* relative to the server render in `entry.server.tsx`. That shifts every
* `useId` value (used by Radix for `id`/`htmlFor`/`aria-*`), causing hydration
* mismatches which can abort hydration entirely when the user interacts with
* the page early, leaving dead event handlers (broken dropdowns, native form
* submits).
*/
function initPosthog() {
const postHogConfig = extractPostHogConfig();
useEffect(() => {
if (postHogConfig) {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
if (postHogConfig) {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
}
}, []);
return null;
});
}
}
async function main() {
@@ -38,11 +43,11 @@ async function main() {
<I18nProvider i18n={i18n}>
<HydratedRouter />
</I18nProvider>
<PosthogInit />
</StrictMode>,
);
});
void initPosthog();
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
+10 -2
View File
@@ -119,7 +119,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
return (
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
// `suppressHydrationWarning` because `remix-themes` intentionally mutates
// `data-theme`/`class` on <html> before hydration (PreventFlashOnWrongTheme),
// so the server-rendered attributes never match the client render when the
// theme is resolved from the system preference. Attribute-only, one level deep.
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@@ -173,7 +177,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<script
nonce={nonce(cspNonce)}
dangerouslySetInnerHTML={{
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
// `__webpack_nonce__` is read by `get-nonce` (used by
// react-remove-scroll / react-style-singleton inside Radix menus and
// dialogs) to stamp runtime-injected <style> elements. Without it the
// strict `style-src-elem` CSP blocks the scroll-lock styles.
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}; window.__webpack_nonce__ = ${JSON.stringify(cspNonce ?? '')}`,
}}
/>
@@ -88,7 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership: delegateDocumentOwnership,
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
@@ -1,59 +1,57 @@
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
import { XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { useNavigate, useParams } from 'react-router';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import {
type EnvelopeBulkDownloadItem,
EnvelopesBulkDownloadDialog,
} from '~/components/dialogs/envelopes-bulk-download-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { useCurrentTeam } from '~/providers/team';
import { documentsSearchParams } from '~/utils/documents-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Documents`);
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
// Stable initial values: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable, and the metadata cache
// effect below depends on that setter.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
export default function DocumentsPage() {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const documentsPath = formatDocumentsPath(team.url);
@@ -61,9 +59,18 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`documents-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
`documents-bulk-selection-meta-${team.id}`,
EMPTY_ENVELOPE_META_CACHE,
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
@@ -76,18 +83,23 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.EXPIRED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
history: 'push',
});
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
{
...findDocumentSearchParams,
status: findDocumentSearchParams.status ?? undefined,
period: findDocumentSearchParams.period ?? undefined,
senderIds: findDocumentSearchParams.senderIds ?? undefined,
page: findDocumentSearchParams.page ?? undefined,
perPage: findDocumentSearchParams.perPage ?? undefined,
query: findDocumentSearchParams.query ?? undefined,
folderId,
},
{
@@ -95,34 +107,66 @@ export default function DocumentsPage() {
},
);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
useEffect(() => {
setEnvelopeMetaCache((prev) => {
const next: EnvelopeMetaCache = {};
params.set('status', value);
for (const id of Object.keys(prev)) {
if (rowSelection[id]) {
next[id] = prev[id];
}
}
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
for (const document of data?.data ?? []) {
if (rowSelection[document.envelopeId]) {
next[document.envelopeId] = {
title: document.title,
status: document.status,
isLegacy: document.internalVersion === 1,
};
}
}
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
params.delete('status');
}
return next;
});
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
if (params.has('page')) {
params.delete('page');
}
const selectedEnvelopesForDownload = useMemo(() => {
return selectedEnvelopeIds
.map((id): EnvelopeBulkDownloadItem | null => {
const meta = envelopeMetaCache[id];
let path = formatDocumentsPath(team.url);
if (!meta) {
return null;
}
if (folderId) {
path += `/f/${folderId}`;
}
return {
id,
title: meta.title,
status: meta.status,
// Stale cache entries predating this field are treated as legacy so
// the Partial option is never offered without certainty.
isLegacy: meta.isLegacy ?? true,
};
})
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
}, [selectedEnvelopeIds, envelopeMetaCache]);
if (params.toString()) {
path += `?${params.toString()}`;
}
const hasActiveFilters = useMemo(() => {
return Boolean(
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
findDocumentSearchParams.senderIds?.length ||
findDocumentSearchParams.period,
);
}, [findDocumentSearchParams]);
return path;
const onResetFilters = () => {
void setFindDocumentSearchParams({
status: null,
senderIds: null,
period: null,
page: null,
});
};
useEffect(() => {
@@ -136,67 +180,40 @@ export default function DocumentsPage() {
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="mt-8 flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
</div>
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<DocumentSearch />
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
]
.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
<DocumentsTableStatusFilter stats={stats} />
return true;
})
.map((value) => (
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<DocumentsTablePeriodFilter />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
) : (
<DocumentsTable
data={data}
@@ -235,12 +252,28 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={(successfulEnvelopeIds) => {
setRowSelection((prev) => {
const next = { ...prev };
for (const id of successfulEnvelopeIds) {
delete next[id];
}
return next;
});
}}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -82,7 +82,7 @@ export default function TeamsSettingsPage() {
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}),
delegateDocumentOwnership: delegateDocumentOwnership,
delegateDocumentOwnership,
},
});
@@ -1,14 +1,18 @@
import { trpc } from '@documenso/trpc/react';
import type { TGetApiTokensResponse } from '@documenso/trpc/server/api-token-router/get-api-tokens.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { useMemo } from 'react';
import { TokenCreateDialog } from '~/components/dialogs/token-create-dialog';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -18,33 +22,88 @@ export function meta() {
}
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const { t, i18n } = useLingui();
const team = useOptionalCurrentTeam();
const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN;
const {
data: tokens,
isLoading,
isError,
} = trpc.apiToken.getMany.useQuery(undefined, {
enabled: !isUnauthorized,
});
const columns = useMemo(() => {
return [
{
header: t`Name`,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span>,
},
{
header: t`Created`,
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: t`Expires`,
cell: ({ row }) => {
if (!row.original.expires) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
if (row.original.expires < new Date()) {
return (
<Badge variant="destructive" size="small">
<Trans>Expired</Trans>
</Badge>
);
}
return i18n.date(row.original.expires);
},
},
{
header: t`Actions`,
cell: ({ row }) => (
<TokenDeleteDialog token={row.original}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
),
},
] satisfies DataTableColumnDef<TGetApiTokensResponse[number]>[];
}, []);
return (
<div>
<SettingsHeader
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
On this page, you can create and manage API tokens. See our{' '}
Create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
rel="noopener"
>
Documentation
documentation
</a>{' '}
for more information.
</Trans>
}
/>
>
{!isUnauthorized && <TokenCreateDialog />}
</SettingsHeader>
{team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? (
{isUnauthorized ? (
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
<div>
<AlertTitle>
@@ -56,58 +115,43 @@ export default function ApiTokensPage() {
</div>
</Alert>
) : (
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mt-8 mb-4" />
<h4 className="font-medium text-xl">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="mt-2 text-muted-foreground text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
<DataTable
columns={columns}
data={tokens ?? []}
perPage={0}
currentPage={0}
totalPages={0}
error={{
enable: isError,
}}
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
<Trans>You have no API tokens yet. Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="mt-2 text-muted-foreground text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
/>
)}
</div>
);
@@ -26,12 +26,14 @@ import { appMetaTags } from '~/utils/meta';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
export function meta() {
return appMetaTags(msg`Templates`);
}
// Stable initial value: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
export default function TemplatesPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -47,7 +49,11 @@ export default function TemplatesPage() {
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`templates-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
@@ -7,6 +7,7 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { Plural } from '@lingui/react/macro';
import { UsersIcon } from 'lucide-react';
@@ -14,6 +15,7 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@@ -70,8 +72,18 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
};
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
if (recipientsWithMissingFields.length > 0) {
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: true,
} as const;
}
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: false,
template: {
...template,
folder: null,
@@ -96,6 +108,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: false,
envelopeForSigning,
} as const;
})
@@ -108,6 +121,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: true,
} as const;
}
throw new Response('Not Found', { status: 404 });
});
};
@@ -181,6 +201,10 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DirectTemplateAuthPageView />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { template, directTemplateRecipient } = data;
return (
@@ -235,6 +259,10 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@@ -82,7 +82,7 @@ export default function WaitingForTurnToSignPage({ loaderData }: Route.Component
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md text-center">
<h2 className="font-bold text-3xl tracking-tigh">
<h2 className="font-bold text-3xl tracking-tight">
<Trans>Waiting for Your Turn</Trans>
</h2>
@@ -1,98 +1,15 @@
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { Link } from 'react-router';
import { redirect } from 'react-router';
import type { Route } from './+types/organisation.decline.$token';
export async function loader({ params }: Route.LoaderArgs) {
export function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
return {
state: 'InvalidLink',
} as const;
throw redirect('/');
}
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
where: {
token,
},
include: {
organisation: {
select: {
name: true,
},
},
},
});
if (!organisationMemberInvite) {
return {
state: 'InvalidLink',
} as const;
}
if (organisationMemberInvite.status !== OrganisationMemberInviteStatus.DECLINED) {
await prisma.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.DECLINED,
},
});
}
return {
state: 'Success',
organisationName: organisationMemberInvite.organisation.name,
} as const;
}
export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) {
const data = loaderData;
if (data.state === 'InvalidLink') {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invalid token</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>This token is invalid or has expired. No action is needed.</Trans>
</p>
<Button asChild>
<Link to="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<h1 className="font-semibold text-4xl">
<Trans>Invitation declined</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have declined the invitation from <strong>{data.organisationName}</strong> to join their organisation.
</Trans>
</p>
<Button asChild>
<Link to="/">
<Trans>Return to Home</Trans>
</Link>
</Button>
</div>
);
// Declining now happens on the invite page via tRPC. Redirect there with the
// `action=decline` flag so it renders the decline-only view (no accept).
throw redirect(`/organisation/invite/${token}?action=decline`);
}
@@ -1,9 +1,15 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import type { Route } from './+types/organisation.invite.$token';
@@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) {
} as const;
}
const organisationName = organisationMemberInvite.organisation.name;
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
return {
state: 'AlreadyAccepted',
organisationName,
} as const;
}
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) {
return {
state: 'AlreadyDeclined',
organisationName,
} as const;
}
const user = await prisma.user.findFirst({
where: {
email: {
@@ -49,26 +71,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
},
});
// Directly convert the team member invite to a team member if they already have an account.
if (user) {
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
}
if (!user) {
return {
state: 'LoginRequired',
email: organisationMemberInvite.email,
organisationName: organisationMemberInvite.organisation.name,
} as const;
}
const isSessionUserTheInvitedUser = user.id === session.user?.id;
return {
state: 'Success',
state: 'Pending',
token: organisationMemberInvite.token,
email: organisationMemberInvite.email,
organisationName: organisationMemberInvite.organisation.name,
isSessionUserTheInvitedUser,
organisationName,
userExists: user !== null,
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
} as const;
}
@@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
);
}
if (data.state === 'LoginRequired') {
if (data.state === 'AlreadyAccepted') {
return (
<div>
<h1 className="font-semibold text-4xl">
<Trans>Organisation invitation</Trans>
</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invitation already accepted</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
</Trans>
</p>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You are already a member of <strong>{data.organisationName}</strong>.
</Trans>
</p>
<p className="mt-1 mb-4 text-muted-foreground text-sm">
<Trans>To accept this invitation you must create an account.</Trans>
</p>
<Button asChild>
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (data.state === 'AlreadyDeclined') {
return <InvitationDeclined organisationName={data.organisationName} />;
}
return (
<div>
<h1 className="font-semibold text-4xl">
<Trans>Invitation accepted!</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have accepted an invitation from <strong>{data.organisationName}</strong> to join their organisation.
</Trans>
</p>
{data.isSessionUserTheInvitedUser ? (
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
<Trans>Continue to login</Trans>
</Link>
</Button>
)}
</div>
<PendingInvitation
token={data.token}
email={data.email}
organisationName={data.organisationName}
userExists={data.userExists}
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
/>
);
}
type PendingInvitationProps = {
token: string;
email: string;
organisationName: string;
userExists: boolean;
isSessionUserTheInvitedUser: boolean;
};
type InvitationResult = 'idle' | 'accepted' | 'declined';
type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown';
const PendingInvitation = ({
token,
email,
organisationName,
userExists,
isSessionUserTheInvitedUser,
}: PendingInvitationProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { refreshSession } = useOptionalSession();
const [searchParams] = useSearchParams();
const actionIsDecline = searchParams.get('action') === 'decline';
const [result, setResult] = useState<InvitationResult>('idle');
const [acceptFailureReason, setAcceptFailureReason] = useState<AcceptFailureReason | null>(null);
const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({
onSuccess: async () => {
await refreshSession();
setResult('accepted');
},
onError: (err) => {
const error = AppError.parseError(err);
const failureReason = match(error.code)
.with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const)
.with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const)
.otherwise(() => 'Unknown' as const);
setAcceptFailureReason(failureReason);
},
});
const declineInvitation = trpc.organisation.member.invite.decline.useMutation({
onSuccess: async () => {
await refreshSession();
setResult('declined');
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`Unable to decline this invitation at this time.`,
variant: 'destructive',
duration: 10000,
});
},
});
if (result === 'accepted') {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invitation accepted!</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have accepted an invitation from <strong>{organisationName}</strong> to join their organisation.
</Trans>
</p>
{isSessionUserTheInvitedUser ? (
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link to={`/signin#email=${encodeURIComponent(email)}`}>
<Trans>Continue to login</Trans>
</Link>
</Button>
)}
</div>
</div>
);
}
if (result === 'declined') {
return <InvitationDeclined organisationName={organisationName} />;
}
// Accepting requires an account (acceptance keys off the invited email).
// Declining does not, so we only gate account creation on the accept flow.
if (!actionIsDecline && !userExists) {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Organisation invitation</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
You have been invited by <strong>{organisationName}</strong> to join their organisation.
</Trans>
</p>
<p className="mt-1 mb-4 text-muted-foreground text-sm">
<Trans>To accept this invitation you must create an account.</Trans>
</p>
<Button asChild>
<Link to={`/signup#email=${encodeURIComponent(email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
</div>
</div>
);
}
const isPending = acceptInvitation.isPending || declineInvitation.isPending;
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Organisation invitation</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have been invited to join <strong>{organisationName}</strong> on Documenso.
</Trans>
</p>
{acceptFailureReason && (
<p className="mt-2 mb-4 text-destructive text-sm">
{match(acceptFailureReason)
.with('CapExceeded', () => (
<Trans>
<strong>{organisationName}</strong> has reached its member limit. Please contact the organisation
administrator to upgrade their plan before accepting this invitation.
</Trans>
))
.with('SubscriptionInactive', () => (
<Trans>
<strong>{organisationName}</strong> does not have an active subscription. Please contact the
organisation administrator to renew their plan before accepting this invitation.
</Trans>
))
.with('Unknown', () => (
<Trans>
We were unable to add you to <strong>{organisationName}</strong> at this time. Please try again later,
or contact the organisation administrator.
</Trans>
))
.exhaustive()}
</p>
)}
<div className="flex items-center gap-x-4">
<Button
variant="destructive"
onClick={async () => declineInvitation.mutateAsync({ token })}
loading={declineInvitation.isPending}
disabled={isPending}
>
<Trans>Decline</Trans>
</Button>
{!actionIsDecline && (
<Button
onClick={async () => acceptInvitation.mutateAsync({ token })}
loading={acceptInvitation.isPending}
disabled={isPending}
>
<Trans>Accept</Trans>
</Button>
)}
</div>
</div>
</div>
);
};
const InvitationDeclined = ({ organisationName }: { organisationName: string }) => {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invitation declined</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have declined the invitation from <strong>{organisationName}</strong> to join their organisation.
</Trans>
</p>
</div>
</div>
);
};
@@ -0,0 +1,19 @@
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
export const DOCUMENTS_PERIOD_VALUES = ['7d', '14d', '30d'] as const;
/**
* Shared nuqs parsers for the documents page URL state.
*
* Used by the documents page and its filter components so every consumer
* parses and serialises the params identically.
*/
export const documentsSearchParams = {
status: parseAsStringLiteral(Object.values(ExtendedDocumentStatus)),
period: parseAsStringLiteral(DOCUMENTS_PERIOD_VALUES),
senderIds: parseAsArrayOf(parseAsInteger),
page: parseAsInteger,
perPage: parseAsInteger,
query: parseAsString,
};
@@ -32,6 +32,10 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
@@ -77,6 +81,10 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
title: msg`Error`,
description: msg`The document was created but could not be sent to recipients.`,
}))
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
}))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
+6 -6
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
@@ -100,11 +100,11 @@
"esbuild": "^0.27.0",
"remix-flat-routes": "^0.8.5",
"rollup": "^4.53.3",
"tsx": "^4.20.6",
"tsx": "^4.23.1",
"typescript": "5.6.2",
"vite": "^7.2.4",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.14.0"
"version": "2.16.0"
}
+2 -2
View File
@@ -19,7 +19,7 @@ WORKDIR /app
COPY . .
RUN npm install -g "turbo@^1.9.3"
RUN npm install -g "turbo@^2.10.0"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@@ -79,7 +79,7 @@ COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
RUN npm install -g "turbo@^1.9.3"
RUN npm install -g "turbo@^2.10.0"
RUN turbo run build --filter=@documenso/remix...
+4848 -2042
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.14.0",
"version": "2.16.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -61,12 +61,13 @@
"@ts-rest/serverless": "^3.52.1",
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.27.0",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^8.0.5",
"nodemailer": "^9.0.0",
"pdfjs-dist": "5.4.296",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
@@ -78,7 +79,7 @@
"rimraf": "^6.1.2",
"superjson": "^2.2.5",
"syncpack": "^14.0.0-alpha.27",
"turbo": "^1.13.4",
"turbo": "^2.10.0",
"vite": "^7.2.4",
"vite-plugin-static-copy": "^3.1.4",
"zod-openapi": "^4.2.4",
@@ -93,6 +94,7 @@
"@prisma/extension-read-replicas": "^0.4.1",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
@@ -104,6 +106,7 @@
"overrides": {
"lodash": "4.18.1",
"pdfjs-dist": "5.4.296",
"postcss": "^8.5.19",
"typescript": "5.6.2",
"zod": "$zod",
"fumadocs-mdx": {
+1 -1
View File
@@ -11,7 +11,7 @@ export const OpenAPIV1 = Object.assign(
title: 'Documenso API',
version: '1.0.0',
description:
'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
'API V1 has been deprecated. For more details, see https://docs.documenso.com/docs/developers/api/migrate-to-envelopes. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
},
servers: [
{
@@ -0,0 +1,439 @@
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../fixtures/authentication';
import { openCommandMenu } from '../fixtures/command-menu';
test.describe.configure({ mode: 'parallel' });
const nanoid = customAlphabet('1234567890abcdef', 10);
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetUser.id));
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
// The category chips include the admin groups with their result counts.
await expect(page.getByRole('button', { name: /Global Users/ })).toBeVisible();
const userOption = page.getByRole('option').filter({ hasText: targetUser.email }).first();
// Admin results are real links so they support native link behaviour such
// as opening in a new tab.
await expect(userOption.getByRole('link')).toHaveAttribute('href', `/admin/users/${targetUser.id}`);
await userOption.click();
await page.waitForURL(`/admin/users/${targetUser.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified team result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { team: targetTeam } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetTeam.id));
await expect(page.getByText('Global Teams', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: targetTeam.url }).first().click();
await page.waitForURL(`/admin/teams/${targetTeam.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: text query shows document result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: document.secondaryId }).first().click();
await page.waitForURL(`/admin/documents/${document.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: envelope_ prefixed query resolves exact document', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.id);
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: document.title }).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: admin search requires more than 3 characters unless numeric', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
// A 3 character non-numeric query must not trigger the admin search. The
// personal document search fires for any non-empty query, so its response
// is the synchronization anchor proving the debounced queries have fired.
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
await input.fill('abc');
await documentSearchResponse;
await expect(page.getByText(/^Global /)).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
// A numeric query fires regardless of length.
const adminSearchRequest = page.waitForRequest((request) => request.url().includes('admin.search'));
await input.fill('7');
await adminSearchRequest;
});
test('[ADMIN][GLOBAL_SEARCH]: search bar position stays fixed while searching', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
const initialY = (await input.boundingBox())?.y;
expect(initialY).toBeGreaterThan(0);
// The height of the prompt may change as results come and go, but the
// search bar must never move.
await input.fill(String(targetUser.id));
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
const resultsY = (await input.boundingBox())?.y;
expect(resultsY).toBe(initialY);
// The search bar must not move when there are no results at all.
await input.fill('zzzz-no-such-thing-9x7q');
await expect(page.getByText('No results for')).toBeVisible();
const emptyY = (await input.boundingBox())?.y;
expect(emptyY).toBe(initialY);
});
test('[ADMIN][GLOBAL_SEARCH]: default view shows the document page links outside a team context', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
// Admin pages have no current team, the page links must still show.
await page.goto('/admin/stats');
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await expect(page.getByRole('option').filter({ hasText: 'All documents' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'All templates' })).toBeVisible();
// Chips only show for categories with actual results, not for the
// hardcoded page links.
await expect(page.getByRole('button', { name: /^Documents/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Templates/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Settings/ })).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: theme can be changed from the prompt', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
// The sub page has a contextual placeholder and a back option.
await expect(page.getByPlaceholder('Search themes…')).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Back' }).first()).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
await page.getByRole('option').filter({ hasText: 'Dark Mode' }).first().click();
await expect(page.locator('html')).toHaveClass(/dark/);
// The back option returns to the root view.
await page.getByRole('option').filter({ hasText: 'Back' }).first().click();
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: capped admin groups offer a view all link', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const namePrefix = `viewall-${nanoid()}`;
// Seed enough users sharing a name prefix to hit the 5 result cap.
for (let i = 0; i < 5; i++) {
await seedUser({ name: `${namePrefix}-${i}` });
}
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(namePrefix);
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
'href',
`/admin/users?search=${encodeURIComponent(namePrefix)}`,
);
});
test('[ADMIN][GLOBAL_SEARCH]: first result is highlighted after every search', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: firstUser } = await seedUser();
const { user: secondUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
// First search selects the first result.
await input.fill(String(firstUser.id));
await expect(page.getByRole('option').filter({ hasText: firstUser.email }).first()).toBeVisible();
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
// A subsequent search with entirely new results must select the first
// result again.
await input.fill(String(secondUser.id));
await expect(page.getByRole('option').filter({ hasText: secondUser.email }).first()).toBeVisible();
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
});
test('[ADMIN][GLOBAL_SEARCH]: static items match fuzzy queries', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
// "setg" is a non-contiguous abbreviation of "Settings".
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('setg');
// Wait for the debounced filter to apply first, "Draft documents" can
// never match "setg" under either matching strategy.
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toHaveCount(0);
await expect(page.getByRole('option').filter({ hasText: 'Settings' }).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: page scrollbar is hidden while the prompt is open', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await expect
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
.toBe('hidden');
await page.keyboard.press('Escape');
await expect
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
.toBe('visible');
});
test('[ADMIN][GLOBAL_SEARCH]: non-admin gets the prompt without the admin search', async ({ page }) => {
const { user, team } = await seedUser({ isAdmin: false });
const document = await seedPendingDocument(user, team.id, []);
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: user.email });
// Non-admins get the same prompt with a non-admin placeholder.
await openCommandMenu(page, 'Type a command or search...');
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER)).toHaveCount(0);
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
// Wait for the regular (non-admin) search to resolve so we know the
// debounced queries have fired.
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
await expect(page.getByText(/^Global /)).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: typing on a sub page fires no search requests', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const searchRequests: string[] = [];
page.on('request', (request) => {
if (/api\/trpc\/(document|template|admin)\.search/.test(request.url())) {
searchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
const input = page.getByPlaceholder('Search themes…');
await expect(input).toBeVisible();
// Long enough to pass the admin search threshold if it were enabled.
await input.fill('dark');
// The client-side filter applying proves the typing registered.
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Light Mode' })).toHaveCount(0);
// Wait out the 200ms search debounce with a wide margin before asserting
// that no requests fired: there is no response to anchor on when the
// desired behaviour is "no requests at all".
await page.waitForTimeout(750);
expect(searchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: failed searches show an error state instead of no results', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await page.route(/api\/trpc\/(document|template|admin)\.search/, async (route) => {
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('zzzz-no-such-thing-9x7q');
// A failed search must be honest about it, not claim there are no results.
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByText('No results for')).toHaveCount(0);
});
test('[ADMIN][GLOBAL_SEARCH]: partial search failure still shows results with a notice', async ({ page }) => {
const { user: adminUser, team } = await seedUser({ isAdmin: true });
const document = await seedPendingDocument(adminUser, team.id, [], {
createDocumentOptions: { title: `partial-fail-${nanoid()}` },
});
// Only the admin search fails: the personal searches succeed.
await page.route(/api\/trpc\/admin\.search/, async (route) => {
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
// The successful personal document search must still render its results.
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
// The failed admin search must be flagged rather than silently dropped.
await expect(page.getByText('Some searches failed')).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without erroring', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
// The admin search endpoint rejects queries longer than 100 characters, so
// the client must not send them. The personal searches accept up to 1024
// characters and still run, anchoring the debounced query flush.
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('a'.repeat(150));
await documentSearchResponse;
// The personal searches ran and found nothing: the honest empty state, with
// no error in sight.
await expect(page.getByText('No results for')).toBeVisible();
await expect(page.getByText('Something went wrong')).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
});
@@ -0,0 +1,249 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../../../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
type AdminSearchGroup = {
type: string;
results: Array<{ label: string; sublabel?: string; path: string; value: string }>;
};
const callAdminSearch = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.search?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
groups: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json.groups as AdminSearchGroup[])
: null,
};
};
const findGroup = (groups: AdminSearchGroup[] | null, type: string) =>
(groups ?? []).find((group) => group.type === type);
// ─── Access control ──────────────────────────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: unauthenticated request is rejected with 401', async ({ page }) => {
const { res } = await callAdminSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('[ADMIN][TRPC][SEARCH]: non-admin authenticated user is rejected with 401', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
await apiSignin({ page, email: nonAdminUser.email });
const { res } = await callAdminSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
// ─── Numeric queries: verified ID lookups ────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified user and team rows', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser, team: targetTeam } = await seedUser();
await apiSignin({ page, email: adminUser.email });
// Search by user ID.
const userSearch = await callAdminSearch(page, String(targetUser.id));
expect(userSearch.res.ok()).toBeTruthy();
const userGroup = findGroup(userSearch.groups, 'user');
expect(userGroup).toBeDefined();
expect(userGroup?.results).toHaveLength(1);
expect(userGroup?.results[0].path).toBe(`/admin/users/${targetUser.id}`);
expect(userGroup?.results[0].sublabel).toContain(targetUser.email);
// The cmdk `value` contract: value must contain the raw query.
expect(userGroup?.results[0].value).toContain(String(targetUser.id));
// Search by team ID.
const teamSearch = await callAdminSearch(page, String(targetTeam.id));
expect(teamSearch.res.ok()).toBeTruthy();
const teamGroup = findGroup(teamSearch.groups, 'team');
expect(teamGroup).toBeDefined();
expect(teamGroup?.results).toHaveLength(1);
expect(teamGroup?.results[0].path).toBe(`/admin/teams/${targetTeam.id}`);
expect(teamGroup?.results[0].label).toBe(targetTeam.name);
});
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified document and recipient rows', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const { user: recipientUser } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [recipientUser]);
const legacyDocumentId = document.secondaryId.replace('document_', '');
const recipient = document.recipients[0];
await apiSignin({ page, email: adminUser.email });
// Search by legacy document ID (bare number).
const documentSearch = await callAdminSearch(page, legacyDocumentId);
expect(documentSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(documentSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results).toHaveLength(1);
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
expect(documentGroup?.results[0].label).toBe(document.title);
// Search by recipient ID: links to the parent document.
const recipientSearch = await callAdminSearch(page, String(recipient.id));
expect(recipientSearch.res.ok()).toBeTruthy();
const recipientGroup = findGroup(recipientSearch.groups, 'recipient');
expect(recipientGroup).toBeDefined();
expect(recipientGroup?.results).toHaveLength(1);
expect(recipientGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
expect(recipientGroup?.results[0].label).toBe(recipient.email);
expect(recipientGroup?.results[0].sublabel).toBe(`#${recipient.id} · ${recipient.name} · ${document.title}`);
// Search by the full document_<id> secondary ID: exercises the prefix branch.
const secondaryIdSearch = await callAdminSearch(page, document.secondaryId);
expect(secondaryIdSearch.res.ok()).toBeTruthy();
const secondaryIdGroup = findGroup(secondaryIdSearch.groups, 'document');
expect(secondaryIdGroup).toBeDefined();
expect(secondaryIdGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
});
test('[ADMIN][TRPC][SEARCH]: numeric query with no matches returns no groups', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, '999999999');
expect(res.ok()).toBeTruthy();
expect(groups).toEqual([]);
});
test('[ADMIN][TRPC][SEARCH]: oversized number does not error and falls back to text search', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
// treated as text (and must not 500).
const oversizedNumber = '99999999999999';
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `${oversizedNumber}-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, oversizedNumber);
expect(res.ok()).toBeTruthy();
const documentGroup = findGroup(groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
});
// ─── Prefixed ID queries: exact lookups ──────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: envelope_ and org_ prefixes resolve exact matches', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, organisation, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, []);
await apiSignin({ page, email: adminUser.email });
// envelope_<id> resolves the document.
const envelopeSearch = await callAdminSearch(page, document.id);
expect(envelopeSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(envelopeSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
// Only the document group is returned for a recognized prefix.
expect(envelopeSearch.groups).toHaveLength(1);
// org_<id> resolves the organisation.
const orgSearch = await callAdminSearch(page, organisation.id);
expect(orgSearch.res.ok()).toBeTruthy();
const orgGroup = findGroup(orgSearch.groups, 'organisation');
expect(orgGroup).toBeDefined();
expect(orgGroup?.results[0].path).toBe(`/admin/organisations/${organisation.id}`);
expect(orgGroup?.results[0].label).toBe(organisation.name);
// Only the organisation group is returned for a recognized prefix.
expect(orgSearch.groups).toHaveLength(1);
});
// ─── Free text queries ───────────────────────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: text query matches documents by title and users by email', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
// A unique title: the default seeded title is shared across the whole suite,
// and global search only returns the newest few matches.
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
// Search by document title.
const titleSearch = await callAdminSearch(page, document.title);
expect(titleSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(titleSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
// Search by user email (emails are unique nanoid-based, so this is specific).
const emailSearch = await callAdminSearch(page, sender.email);
expect(emailSearch.res.ok()).toBeTruthy();
const userGroup = findGroup(emailSearch.groups, 'user');
expect(userGroup).toBeDefined();
expect(userGroup?.results[0].path).toBe(`/admin/users/${sender.id}`);
});
test('[ADMIN][TRPC][SEARCH]: gibberish query returns no groups', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, 'zzzz-no-such-thing-9x7q');
expect(res.ok()).toBeTruthy();
expect(groups).toEqual([]);
});

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