mirror of
https://github.com/documenso/documenso.git
synced 2026-08-16 03:21:55 +10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e31e174927 | ||
|
|
c11c6fc181 | ||
|
|
6552217391 |
File diff suppressed because it is too large
Load Diff
@@ -1,175 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,146 +0,0 @@
|
||||
---
|
||||
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
@@ -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,8 +6,6 @@ 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,8 +5,6 @@ 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,7 +8,6 @@
|
||||
"teams",
|
||||
"rate-limits",
|
||||
"versioning",
|
||||
"migrate-to-envelopes",
|
||||
"developer-mode",
|
||||
"common-errors"
|
||||
]
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
---
|
||||
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,14 +11,9 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
|
||||
## HTTP Rate Limits
|
||||
|
||||
**Limit:** 1000 requests per minute per IP address
|
||||
**Limit:** 100 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
|
||||
|
||||
@@ -6,8 +6,6 @@ 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,8 +5,6 @@ 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.
|
||||
@@ -21,16 +19,7 @@ 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,8 +8,6 @@ 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.
|
||||
@@ -474,7 +472,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. 1000 requests/minute)
|
||||
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -640,8 +638,8 @@ done
|
||||
</Tabs>
|
||||
|
||||
<Callout type="info">
|
||||
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.
|
||||
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
|
||||
between requests to avoid hitting limits.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
@@ -3,8 +3,6 @@ title: Examples
|
||||
description: Common integration patterns and end-to-end workflows.
|
||||
---
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title="Common Workflows"
|
||||
|
||||
@@ -7,8 +7,6 @@ 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,8 +7,6 @@ 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:
|
||||
@@ -485,7 +483,7 @@ The API returns standard HTTP status codes and JSON error responses:
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
|
||||
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
|
||||
|
||||
```javascript
|
||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||
|
||||
@@ -3,8 +3,6 @@ title: Getting Started
|
||||
description: Get your API key and make your first API call.
|
||||
---
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title="Authentication"
|
||||
|
||||
@@ -3,8 +3,6 @@ title: Developer Guide
|
||||
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
|
||||
---
|
||||
|
||||
<EnvelopeWarning />
|
||||
|
||||
## Getting Started
|
||||
|
||||
<Cards>
|
||||
|
||||
@@ -33,14 +33,13 @@ All webhook events share a common structure:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------- | ------------------------------------------------------ |
|
||||
| `id` | number | Legacy numeric v1 document or template ID |
|
||||
| `envelopeId` | string | Canonical v2 identifier (`envelope_` + 16 characters) |
|
||||
| `id` | number | Document or template ID |
|
||||
| `externalId` | string? | External identifier for integration |
|
||||
| `userId` | number | Owner's user ID |
|
||||
| `authOptions` | object? | Document-level authentication options |
|
||||
| `formValues` | object? | PDF form values associated with the document |
|
||||
| `title` | string | Document or template title |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
|
||||
| `visibility` | string | Document visibility setting |
|
||||
| `createdAt` | datetime | Document creation timestamp |
|
||||
| `updatedAt` | datetime | Last modification timestamp |
|
||||
@@ -48,8 +47,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`, `TEMPLATE`, or `TEMPLATE_DIRECT_LINK` |
|
||||
| `documentMeta` | object? | Nullable document metadata (subject, message, signing options) |
|
||||
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
|
||||
| `documentMeta` | object | Document metadata (subject, message, signing options) |
|
||||
| `recipients` | array | List of recipient objects |
|
||||
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
|
||||
|
||||
@@ -61,6 +60,7 @@ 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,9 +77,8 @@ All webhook events share a common structure:
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | --------- | ------------------------------------------ |
|
||||
| `id` | number | Recipient ID |
|
||||
| `envelopeId` | string | Canonical parent envelope ID |
|
||||
| `documentId` | number? | Legacy parent document ID; null for templates |
|
||||
| `templateId` | number? | Legacy parent template ID; null for documents |
|
||||
| `documentId` | number? | Parent document ID |
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `email` | string | Recipient email address |
|
||||
| `name` | string | Recipient name |
|
||||
| `token` | string | Unique signing token |
|
||||
@@ -95,8 +94,6 @@ 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
|
||||
@@ -114,7 +111,6 @@ Triggered when a new document is created.
|
||||
"event": "DOCUMENT_CREATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
@@ -133,8 +129,9 @@ 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": "Etc/UTC",
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
@@ -148,7 +145,6 @@ Triggered when a new document is created.
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -170,7 +166,6 @@ Triggered when a new document is created.
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -208,7 +203,6 @@ 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,
|
||||
@@ -227,8 +221,9 @@ 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": "Etc/UTC",
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
@@ -242,7 +237,6 @@ 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",
|
||||
@@ -264,7 +258,6 @@ 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",
|
||||
@@ -302,14 +295,12 @@ 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",
|
||||
@@ -337,7 +328,6 @@ 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",
|
||||
@@ -345,7 +335,6 @@ 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",
|
||||
@@ -372,14 +361,12 @@ 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",
|
||||
@@ -408,7 +395,6 @@ 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,
|
||||
@@ -427,8 +413,9 @@ 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": "Etc/UTC",
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
@@ -442,7 +429,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"recipients": [
|
||||
{
|
||||
"id": 50,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "reviewer@example.com",
|
||||
@@ -465,7 +451,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -490,7 +475,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 50,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "reviewer@example.com",
|
||||
@@ -513,7 +497,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -554,14 +537,12 @@ 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",
|
||||
@@ -580,7 +561,7 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
||||
|
||||
### `document.cancelled`
|
||||
|
||||
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.
|
||||
Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted.
|
||||
|
||||
This event is **not** triggered when a recipient hides a document from their inbox.
|
||||
|
||||
@@ -591,7 +572,6 @@ 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,
|
||||
@@ -611,6 +591,7 @@ 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",
|
||||
@@ -625,7 +606,6 @@ 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",
|
||||
@@ -647,7 +627,6 @@ 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",
|
||||
@@ -672,45 +651,6 @@ 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.
|
||||
@@ -722,14 +662,12 @@ 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",
|
||||
@@ -748,7 +686,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. 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 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.created`
|
||||
|
||||
@@ -761,10 +699,9 @@ Triggered when a new template is created.
|
||||
"event": "TEMPLATE_CREATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"title": "My Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": null,
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
@@ -784,10 +721,9 @@ 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": null,
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
@@ -807,10 +743,9 @@ Triggered when a template is deleted.
|
||||
"event": "TEMPLATE_DELETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"title": "Deleted Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": null,
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
@@ -830,7 +765,6 @@ 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,
|
||||
@@ -857,8 +791,7 @@ 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` | 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_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted |
|
||||
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
|
||||
|
||||
### Template Events
|
||||
@@ -888,7 +821,7 @@ When processing webhook events:
|
||||
**Process idempotently** — Webhooks may be retried, so handle duplicate events
|
||||
</Step>
|
||||
<Step>
|
||||
**Respond quickly** — Return a `2xx` status code within 10 seconds
|
||||
**Respond quickly** — Return a 200 status code within 30 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), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used).
|
||||
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
|
||||
|
||||
---
|
||||
|
||||
@@ -42,14 +42,12 @@ 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"
|
||||
}
|
||||
@@ -60,8 +58,6 @@ 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 `2xx` status within 10 seconds. Documenso will retry failed deliveries according to the configured background-job provider.
|
||||
Always respond with a `200 OK` status within 30 seconds. Documenso will retry failed deliveries.
|
||||
</Callout>
|
||||
|
||||
## Configuring Webhooks in Documenso via the Dashboard
|
||||
@@ -184,7 +184,7 @@ Fill in the following fields:
|
||||
|
||||
| Field | Description |
|
||||
| ----- | ----------- |
|
||||
| **Webhook URL** | The HTTP or HTTPS endpoint that will receive webhook events |
|
||||
| **Webhook URL** | The 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,21 +202,12 @@ Your webhook endpoint must meet these requirements:
|
||||
|
||||
| Requirement | Details |
|
||||
| ----------- | ------- |
|
||||
| **Protocol** | HTTP and HTTPS are accepted; use HTTPS in production |
|
||||
| **Response** | Must return a `2xx` status code within 10 seconds |
|
||||
| **Protocol** | HTTPS required (HTTP not allowed in production) |
|
||||
| **Response** | Must return `2xx` status code within 30 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>
|
||||
@@ -234,8 +225,7 @@ 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` | 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_CANCELLED` | The document owner deletes the document |
|
||||
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
|
||||
| `TEMPLATE_CREATED` | A new template is created |
|
||||
| `TEMPLATE_UPDATED` | A template is modified |
|
||||
@@ -304,7 +294,6 @@ Each webhook call shows the following details:
|
||||
- Timestamp
|
||||
- Response code
|
||||
- Request and response bodies
|
||||
- Response headers
|
||||
|
||||
Click any call to see full details including headers and response data.
|
||||
</Step>
|
||||
@@ -329,17 +318,17 @@ Documenso will attempt to deliver the same payload again
|
||||
|
||||
## Retry Policy
|
||||
|
||||
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`.
|
||||
When a webhook delivery fails (non-2xx response or timeout), Documenso automatically retries with exponential backoff:
|
||||
|
||||
For self-hosted deployments, retries are handled by the background-job provider selected with `NEXT_PRIVATE_JOBS_PROVIDER`:
|
||||
| Attempt | Delay |
|
||||
| ------- | ----- |
|
||||
| 1 | Immediate |
|
||||
| 2 | 1 minute |
|
||||
| 3 | 5 minutes |
|
||||
| 4 | 30 minutes |
|
||||
| 5 | 2 hours |
|
||||
|
||||
| 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.
|
||||
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.
|
||||
|
||||
<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,7 +255,6 @@ const validEvents = [
|
||||
'DOCUMENT_REJECTED',
|
||||
'DOCUMENT_CANCELLED',
|
||||
'DOCUMENT_REMINDER_SENT',
|
||||
'RECIPIENT_EXPIRED',
|
||||
'TEMPLATE_CREATED',
|
||||
'TEMPLATE_UPDATED',
|
||||
'TEMPLATE_DELETED',
|
||||
|
||||
@@ -41,17 +41,12 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
|
||||
|
||||
| Action | Limit | Window |
|
||||
| --- | --- | --- |
|
||||
| API requests (v1 and v2) | 1000 requests | 1 minute |
|
||||
| API requests (v1 and v2) | 100 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.
|
||||
|
||||
@@ -13,7 +13,7 @@ There are three distinct kinds of limit:
|
||||
| ---------------------- | ------------------------------------------------- | ----------------------- |
|
||||
| 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) |
|
||||
| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -91,7 +91,7 @@ Monthly quota usage is keyed to the **UTC calendar month**. There is no schedule
|
||||
|
||||
## 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).
|
||||
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build",
|
||||
"dev": "next dev",
|
||||
"start": "next start",
|
||||
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes';
|
||||
|
||||
/**
|
||||
* Deprecation banner steering API consumers away from the legacy document and
|
||||
* template create endpoints and towards the unified Envelope API.
|
||||
*
|
||||
* Registered globally in `mdx-components.tsx`, so it can be used in any MDX page
|
||||
* as `<EnvelopeWarning />` without an explicit import.
|
||||
*/
|
||||
export function EnvelopeWarning() {
|
||||
return (
|
||||
<Callout type="error">
|
||||
<strong>Documents and templates are being deprecated and replaced by envelopes.</strong>{' '}
|
||||
<a href={MIGRATION_GUIDE_HREF}>Read the migration guide here.</a>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -10,7 +9,6 @@ export function getMDXComponents(components?: MDXComponents): any {
|
||||
...defaultMdxComponents,
|
||||
...TabsComponents,
|
||||
Mermaid,
|
||||
EnvelopeWarning,
|
||||
...components,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,377 +0,0 @@
|
||||
import {
|
||||
createZipWriter,
|
||||
sanitizeZipPathSegment,
|
||||
type ZipFileEntry,
|
||||
} from '@documenso/lib/client-only/create-zip-writer';
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { fetchPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* The maximum number of documents that can be downloaded in a single bulk
|
||||
* download. Each document requires fetching its full PDFs into the browser,
|
||||
* so this bounds both request volume and blob storage usage. Matches the
|
||||
* spirit of the server-side 100 cap on bulk move/delete/cancel.
|
||||
*/
|
||||
export const MAX_BULK_DOWNLOAD_ENVELOPES = 50;
|
||||
|
||||
type BulkDownloadVersion = 'signed' | 'original' | 'pending';
|
||||
|
||||
export type EnvelopeBulkDownloadItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: DocumentStatus;
|
||||
|
||||
/**
|
||||
* Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a
|
||||
* different field-rendering pipeline that the partial PDF helper does not
|
||||
* implement, so the Partial option is hidden for them.
|
||||
*/
|
||||
isLegacy: boolean;
|
||||
};
|
||||
|
||||
const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
|
||||
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original';
|
||||
|
||||
export type EnvelopesBulkDownloadDialogProps = {
|
||||
envelopes: EnvelopeBulkDownloadItem[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (successfulEnvelopeIds: string[]) => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopesBulkDownloadDialog = ({
|
||||
envelopes,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkDownloadDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [versionMap, setVersionMap] = useState<Record<string, BulkDownloadVersion>>({});
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const abortRef = useRef(false);
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)])));
|
||||
setProgress(0);
|
||||
}, [open]);
|
||||
|
||||
const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
|
||||
versionMap[envelope.id] ?? getDefaultVersion(envelope);
|
||||
|
||||
/**
|
||||
* The version options selectable for an envelope, mirroring the gating used
|
||||
* by the single envelope download dialog:
|
||||
* - COMPLETED: signed or original.
|
||||
* - PENDING (non-legacy): partial or original. Legacy envelopes use a
|
||||
* field-rendering pipeline the partial PDF helper does not implement.
|
||||
* - Anything else: original only, so no choice is shown.
|
||||
*/
|
||||
const getVersionOptions = (
|
||||
envelope: EnvelopeBulkDownloadItem,
|
||||
): { value: BulkDownloadVersion; label: string }[] | null => {
|
||||
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||
return [
|
||||
{ value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) },
|
||||
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
|
||||
];
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) {
|
||||
return [
|
||||
{ value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) },
|
||||
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: DocumentStatus) =>
|
||||
match(status)
|
||||
.with(DocumentStatus.COMPLETED, () => t`Completed`)
|
||||
.with(DocumentStatus.PENDING, () => t`Pending`)
|
||||
.with(DocumentStatus.DRAFT, () => t`Draft`)
|
||||
.with(DocumentStatus.REJECTED, () => t`Rejected`)
|
||||
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
|
||||
.exhaustive();
|
||||
|
||||
const onDownload = async () => {
|
||||
if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortRef.current = false;
|
||||
setIsDownloading(true);
|
||||
setProgress(0);
|
||||
|
||||
const zipWriter = createZipWriter();
|
||||
|
||||
const successfulEnvelopeIds: string[] = [];
|
||||
let failedDownloads = 0;
|
||||
|
||||
try {
|
||||
for (const envelope of envelopes) {
|
||||
if (abortRef.current) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadVersion = getDownloadVersion(envelope);
|
||||
|
||||
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
|
||||
envelopeId: envelope.id,
|
||||
access: {
|
||||
type: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
// Each envelope's items are grouped in their own folder. The id
|
||||
// prefix guarantees uniqueness, the truncated title keeps it
|
||||
// readable without risking overly long extraction paths.
|
||||
const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96));
|
||||
|
||||
// Buffer this envelope's files before writing so a failed envelope
|
||||
// is either fully in the zip or not at all. Files from previous
|
||||
// envelopes have already been written to the zip stream and freed.
|
||||
const envelopeFiles: ZipFileEntry[] = [];
|
||||
|
||||
for (const envelopeItem of envelopeItems) {
|
||||
const { filename, blob } = await fetchPDF({
|
||||
envelopeItem,
|
||||
token: undefined,
|
||||
fileName: envelopeItem.title,
|
||||
version: downloadVersion,
|
||||
});
|
||||
|
||||
envelopeFiles.push({
|
||||
filename: `${folderName}/${sanitizeZipPathSegment(filename)}`,
|
||||
data: blob,
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of envelopeFiles) {
|
||||
await zipWriter.addFile(file);
|
||||
}
|
||||
|
||||
successfulEnvelopeIds.push(envelope.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
failedDownloads++;
|
||||
}
|
||||
|
||||
setProgress((p) => p + 1);
|
||||
}
|
||||
|
||||
// The user intentionally stopped the download, discard anything fetched
|
||||
// so far without toasting an error.
|
||||
if (abortRef.current) {
|
||||
zipWriter.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
if (successfulEnvelopeIds.length === 0) {
|
||||
zipWriter.abort();
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while downloading the documents.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
downloadFile({
|
||||
filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`,
|
||||
data: zipWriter.finalize(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
zipWriter.abort();
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while downloading the documents.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedDownloads > 0) {
|
||||
toast({
|
||||
title: t`Documents partially downloaded`,
|
||||
description: t`${plural(successfulEnvelopeIds.length, {
|
||||
one: '# document downloaded.',
|
||||
other: '# documents downloaded.',
|
||||
})} ${plural(failedDownloads, {
|
||||
one: '# document could not be downloaded.',
|
||||
other: '# documents could not be downloaded.',
|
||||
})}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
onSuccess?.(successfulEnvelopeIds);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Documents downloaded`,
|
||||
description: plural(successfulEnvelopeIds.length, {
|
||||
one: '# document has been downloaded.',
|
||||
other: '# documents have been downloaded.',
|
||||
}),
|
||||
});
|
||||
|
||||
onSuccess?.(successfulEnvelopeIds);
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (!isDownloading) {
|
||||
onOpenChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Download Documents</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Plural
|
||||
value={envelopes.length}
|
||||
one="Select the version to download for the selected document."
|
||||
other="Select the version to download for each of the # selected documents."
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isOverDownloadLimit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
|
||||
continue.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset disabled={isDownloading} className="space-y-4">
|
||||
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
|
||||
<div className="divide-y divide-border rounded-lg border border-border">
|
||||
{envelopes.map((envelope) => {
|
||||
const versionOptions = getVersionOptions(envelope);
|
||||
|
||||
return (
|
||||
<div key={envelope.id} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground text-sm" title={envelope.title}>
|
||||
{envelope.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{getStatusLabel(envelope.status)}</p>
|
||||
</div>
|
||||
|
||||
{versionOptions && (
|
||||
<RadioGroupSegmented
|
||||
className="shrink-0"
|
||||
value={getDownloadVersion(envelope)}
|
||||
onValueChange={(value) =>
|
||||
setVersionMap((prev) => ({
|
||||
...prev,
|
||||
[envelope.id]: value as BulkDownloadVersion,
|
||||
}))
|
||||
}
|
||||
aria-label={t`Download version for ${envelope.title}`}
|
||||
>
|
||||
{versionOptions.map((option) => (
|
||||
<RadioGroupSegmentedItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</RadioGroupSegmentedItem>
|
||||
))}
|
||||
</RadioGroupSegmented>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Downloading {progress} / {envelopes.length}...
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (isDownloading) {
|
||||
abortRef.current = true;
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onDownload()}
|
||||
loading={isDownloading}
|
||||
disabled={envelopes.length === 0 || isOverDownloadLimit}
|
||||
>
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -24,7 +23,6 @@ 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';
|
||||
|
||||
@@ -76,7 +74,6 @@ 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 : {};
|
||||
@@ -99,42 +96,6 @@ 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 '';
|
||||
@@ -436,7 +397,6 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`background-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.background}
|
||||
@@ -460,7 +420,6 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`foreground-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.foreground}
|
||||
@@ -484,7 +443,6 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`primary-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.primary}
|
||||
@@ -508,7 +466,6 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`primary-foreground-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
|
||||
@@ -532,7 +489,6 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`border-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.border}
|
||||
@@ -556,7 +512,6 @@ export function BrandingPreferencesForm({
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
key={`ring-${colorPickerKey}`}
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.ring}
|
||||
@@ -638,15 +593,6 @@ 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, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
|
||||
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
|
||||
import { extractTeamSignatureSettings } 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 { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType, type RecipientRole } 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,26 +93,6 @@ 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,
|
||||
@@ -133,7 +113,7 @@ export const DocumentPreferencesForm = ({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||
documentTimezone: z.string().nullable(),
|
||||
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
|
||||
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
|
||||
includeSenderDetails: z.boolean().nullable(),
|
||||
includeSigningCertificate: z.boolean().nullable(),
|
||||
includeAuditLog: z.boolean().nullable(),
|
||||
@@ -147,33 +127,26 @@ 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,
|
||||
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,
|
||||
},
|
||||
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);
|
||||
@@ -799,17 +772,6 @@ 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,17 +3,12 @@ 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 { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { 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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -29,7 +24,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, resetToDefaults }: FormStickySaveBarProps) => {
|
||||
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -105,8 +100,6 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefau
|
||||
</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,4 +1,3 @@
|
||||
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';
|
||||
@@ -10,7 +9,6 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyRoundIcon,
|
||||
@@ -31,8 +29,6 @@ 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 || {};
|
||||
@@ -151,24 +147,6 @@ 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
@@ -1,36 +0,0 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type PromptItem = {
|
||||
id: string;
|
||||
label: string | MessageDescriptor;
|
||||
sublabel?: string;
|
||||
path?: string;
|
||||
onAction?: () => void;
|
||||
icon?: LucideIcon;
|
||||
initials?: string;
|
||||
shortcut?: string;
|
||||
isChecked?: boolean;
|
||||
};
|
||||
|
||||
export type PromptCategory = {
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
items: PromptItem[];
|
||||
/**
|
||||
* The number of actual results, excluding utility rows such as the
|
||||
* "View all results" link.
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* The count shown on the category chip, or null to not show a chip at all.
|
||||
* Categories which only contain hardcoded page links have no chip.
|
||||
*/
|
||||
chipCount: number | null;
|
||||
isCapped: boolean;
|
||||
/**
|
||||
* Global admin categories are marked with a globe icon to distinguish them
|
||||
* from the equally named personal categories.
|
||||
*/
|
||||
isGlobal: boolean;
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
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,7 +11,6 @@ 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,
|
||||
@@ -224,12 +223,27 @@ export const DirectTemplateSigningForm = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
getDictatableNextRecipient({
|
||||
recipients: template.recipients,
|
||||
currentRecipientId: directRecipient.id,
|
||||
}) ?? 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;
|
||||
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
|
||||
|
||||
return (
|
||||
|
||||
+22
-3
@@ -15,7 +15,6 @@ 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';
|
||||
@@ -144,11 +143,31 @@ export const DocumentSigningPageViewV1 = ({
|
||||
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (documentMeta?.signingOrder !== 'SEQUENTIAL') {
|
||||
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getDictatableNextRecipient({ recipients: allRecipients, currentRecipientId: recipient.id }) ?? 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;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -291,14 +290,32 @@ export const EnvelopeSigningProvider = ({
|
||||
.filter((field) => field.inserted);
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getDictatableNextRecipient({
|
||||
recipients: envelope.recipients,
|
||||
currentRecipientId: recipient.id,
|
||||
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;
|
||||
});
|
||||
|
||||
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 (
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Share,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
@@ -53,6 +53,21 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkIfMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkIfMobile();
|
||||
|
||||
window.addEventListener('resize', checkIfMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkIfMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
@@ -74,7 +89,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="end" forceMount>
|
||||
<DropdownMenuContent className="w-52" align={isMobile ? 'end' : 'start'} forceMount>
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Action</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
@@ -2,24 +2,38 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
export const DocumentSearch = () => {
|
||||
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
} else {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setQuery(debouncedSearchTerm || null);
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, query, setQuery]);
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
@@ -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, TimerOff, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
@@ -46,12 +46,6 @@ 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`,
|
||||
|
||||
@@ -54,7 +54,6 @@ 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';
|
||||
|
||||
@@ -239,8 +238,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
{envelope.recipients.length === 0 && (
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
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,7 +22,6 @@ 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 = () => {
|
||||
@@ -229,8 +228,6 @@ 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>
|
||||
|
||||
+521
-56
@@ -1,30 +1,37 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import {
|
||||
updateEditorSigners,
|
||||
ZEditorRecipientsFormSchema,
|
||||
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { 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 { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
|
||||
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
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 { 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 } from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } 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 } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
|
||||
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useWatch } from 'react-hook-form';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
@@ -34,8 +41,6 @@ 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();
|
||||
@@ -43,6 +48,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { sessionData } = useOptionalSession();
|
||||
@@ -50,6 +56,7 @@ 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
|
||||
@@ -95,8 +102,23 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
const { recipients } = envelope;
|
||||
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 { form } = editorRecipients;
|
||||
|
||||
@@ -134,20 +156,21 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}, [watchedSigners]);
|
||||
|
||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
||||
return signers
|
||||
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
|
||||
.map((signer, index) => ({ ...signer, signingOrder: index + 1 }));
|
||||
};
|
||||
|
||||
// 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({
|
||||
const {
|
||||
append: appendSigner,
|
||||
fields: signers,
|
||||
remove: removeSigner,
|
||||
} = 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,
|
||||
@@ -159,40 +182,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) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
|
||||
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
|
||||
);
|
||||
|
||||
const hasDocumentBeenSent = recipients.some(
|
||||
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
|
||||
);
|
||||
|
||||
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`));
|
||||
}
|
||||
const canRecipientBeModified = (recipientId?: number) => {
|
||||
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (recipientId === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const recipient = recipients.find((recipient) => recipient.id === recipientId);
|
||||
|
||||
if (!recipient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return utilCanRecipientBeModified(recipient, fields);
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendNormalizedSigner({
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
name: '',
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: stepCount + 1,
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -204,8 +227,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) {
|
||||
updateEditorSigners(
|
||||
form,
|
||||
form.setValue(
|
||||
'signers',
|
||||
detectedRecipients.map((recipient, index) => ({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
@@ -214,6 +237,10 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
actionAuth: [],
|
||||
signingOrder: index + 1,
|
||||
})),
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -240,7 +267,10 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
nextSigningOrder += 1;
|
||||
}
|
||||
|
||||
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
|
||||
form.setValue('signers', normalizeSigningOrders(currentSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: plural(detectedRecipients.length, {
|
||||
@@ -254,6 +284,32 @@ 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 ?? '', {
|
||||
@@ -267,35 +323,168 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
form.setFocus(`signers.${emptySignerIndex}.email`);
|
||||
} else {
|
||||
appendNormalizedSigner(
|
||||
appendSigner(
|
||||
{
|
||||
formId: nanoid(12),
|
||||
name: currentEditorName ?? '',
|
||||
email: currentEditorEmail ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: stepCount + 1,
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
},
|
||||
{
|
||||
shouldFocus: true,
|
||||
},
|
||||
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 = normalizeSigningOrders(
|
||||
currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
})),
|
||||
);
|
||||
|
||||
updateEditorSigners(form, updatedSigners);
|
||||
const updatedSigners = currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
@@ -376,7 +565,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}, [formValues]);
|
||||
|
||||
const recipientCountLimit = organisation.organisationClaim.recipientCount;
|
||||
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
|
||||
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
|
||||
|
||||
return (
|
||||
<Card backdropBlur={false} className="border">
|
||||
@@ -432,7 +621,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
type="button"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
|
||||
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||
onClick={() => onAddSigner()}
|
||||
>
|
||||
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
|
||||
@@ -582,7 +771,283 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
|
||||
<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>
|
||||
|
||||
<FormErrorMessage
|
||||
className="mt-2"
|
||||
|
||||
@@ -26,7 +26,6 @@ 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';
|
||||
|
||||
@@ -450,9 +449,6 @@ 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>
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
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);
|
||||
@@ -1,247 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,359 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,186 +0,0 @@
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type FilterPillOption = {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
trailing?: string;
|
||||
};
|
||||
|
||||
type FilterPillCommonProps = {
|
||||
icon: LucideIcon;
|
||||
label: ReactNode;
|
||||
options: FilterPillOption[];
|
||||
enableSearch?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export type FilterPillSingleProps = FilterPillCommonProps & {
|
||||
multiple?: false;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
selectedLabel?: ReactNode;
|
||||
};
|
||||
|
||||
export type FilterPillMultipleProps = FilterPillCommonProps & {
|
||||
multiple: true;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
|
||||
|
||||
/**
|
||||
* A faceted filter pill.
|
||||
*
|
||||
* Renders as a dashed "add a filter" pill at rest, and shows the current
|
||||
* selection inline once a value is picked. Selecting the active option
|
||||
* again (or the Clear row) removes it.
|
||||
*
|
||||
* Single select by default, closing on pick. When `multiple` is set the
|
||||
* popover stays open for toggling, and the trigger shows the first two
|
||||
* selections followed by a "+N more" chip.
|
||||
*/
|
||||
export const FilterPill = (props: FilterPillProps) => {
|
||||
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value];
|
||||
|
||||
const selectedOptions = selectedValues
|
||||
.map((value) => options.find((option) => option.value === value))
|
||||
.filter((option): option is FilterPillOption => option !== undefined);
|
||||
|
||||
const hasSelection = selectedOptions.length > 0;
|
||||
const extraCount = selectedOptions.length - 2;
|
||||
|
||||
const onSelect = (nextValue: string) => {
|
||||
if (props.multiple) {
|
||||
const newValues = selectedValues.includes(nextValue)
|
||||
? selectedValues.filter((value) => value !== nextValue)
|
||||
: [...selectedValues, nextValue];
|
||||
|
||||
props.onChange(newValues);
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange(nextValue === props.value ? null : nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
if (props.multiple) {
|
||||
props.onChange([]);
|
||||
} else {
|
||||
props.onChange(null);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
className={cn('border-dashed text-muted-foreground', {
|
||||
'border-solid text-foreground': hasSelection,
|
||||
})}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
|
||||
{props.multiple ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
{selectedOptions.slice(0, 2).map((option) => (
|
||||
<Badge key={option.value} variant="neutral" size="small">
|
||||
{option.label}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{extraCount > 0 && (
|
||||
<Badge variant="neutral" size="small">
|
||||
<Trans>+{extraCount} more</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{props.selectedLabel ?? selectedOptions[0].label}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-52 p-0" align="start">
|
||||
<Command>
|
||||
{enableSearch && <CommandInput placeholder={searchPlaceholder} />}
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4 shrink-0',
|
||||
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
{option.label}
|
||||
|
||||
{option.trailing !== undefined && (
|
||||
<span className="ml-auto pl-4 text-muted-foreground text-xs">{option.trailing}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem className="justify-center text-center text-muted-foreground" onSelect={onClear}>
|
||||
<Trans>Clear</Trans>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
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, TimerOff, XCircle } from 'lucide-react';
|
||||
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
|
||||
@@ -29,16 +29,6 @@ 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.`,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: '7d', label: <Trans>Last 7 days</Trans> },
|
||||
{ value: '14d', label: <Trans>Last 14 days</Trans> },
|
||||
{ value: '30d', label: <Trans>Last 30 days</Trans> },
|
||||
];
|
||||
|
||||
export const DocumentsTablePeriodFilter = () => {
|
||||
const [{ period }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
period: documentsSearchParams.period,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const onChange = (newPeriod: string | null) => {
|
||||
void setSearchParams({
|
||||
period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
icon={CalendarIcon}
|
||||
label={<Trans>Period</Trans>}
|
||||
value={period}
|
||||
onChange={onChange}
|
||||
options={PERIOD_OPTIONS}
|
||||
testId="documents-table-period-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +1,63 @@
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
type DocumentsTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [{ senderIds }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
senderIds: documentsSearchParams.senderIds,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString());
|
||||
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const options = (data ?? []).map((member) => ({
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: string[]) => {
|
||||
void setSearchParams({
|
||||
senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null,
|
||||
page: null,
|
||||
});
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
multiple
|
||||
icon={UserIcon}
|
||||
label={<Trans>Sender</Trans>}
|
||||
value={selectedSenderIds}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
enableSearch
|
||||
searchPlaceholder={_(msg`Search members...`)}
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="font-normal text-muted-foreground">
|
||||
<Trans>
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder={msg`Search`}
|
||||
loading={!isMounted || isLoading}
|
||||
testId="documents-table-sender-filter"
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
import { ListFilterIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
type DocumentsTableStatusFilterProps = {
|
||||
stats: TFindDocumentsInternalResponse['stats'];
|
||||
};
|
||||
|
||||
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [{ status }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
status: documentsSearchParams.status,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectableStatuses = useMemo(
|
||||
() =>
|
||||
SELECTABLE_STATUSES.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[organisation.type],
|
||||
);
|
||||
|
||||
const selectedStatus = useMemo(
|
||||
() => selectableStatuses.find((value) => value === status) ?? null,
|
||||
[selectableStatuses, status],
|
||||
);
|
||||
|
||||
const onChange = (newStatus: string | null) => {
|
||||
void setSearchParams({
|
||||
status: selectableStatuses.find((value) => value === newStatus) ?? null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterPill
|
||||
icon={ListFilterIcon}
|
||||
label={<Trans>Status</Trans>}
|
||||
value={selectedStatus}
|
||||
onChange={onChange}
|
||||
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
|
||||
options={selectableStatuses.map((value) => ({
|
||||
value,
|
||||
label: <DocumentStatus status={value} />,
|
||||
trailing: formatStatsCount(stats[value]),
|
||||
}))}
|
||||
testId="documents-table-status-filter"
|
||||
/>
|
||||
|
||||
{/* Visually hidden document counts, for screen readers and tests. */}
|
||||
<span className="sr-only" data-testid="documents-status-counts">
|
||||
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
|
||||
<span key={value}>
|
||||
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
|
||||
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
ExtendedDocumentStatus.EXPIRED,
|
||||
];
|
||||
|
||||
const formatStatsCount = (count: number) => {
|
||||
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||
|
||||
export type EnvelopesTableBulkActionBarProps = {
|
||||
selectedCount: number;
|
||||
onDownloadClick?: () => void;
|
||||
onMoveClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onCancelClick?: () => void;
|
||||
@@ -14,7 +12,6 @@ export type EnvelopesTableBulkActionBarProps = {
|
||||
|
||||
export const EnvelopesTableBulkActionBar = ({
|
||||
selectedCount,
|
||||
onDownloadClick,
|
||||
onMoveClick,
|
||||
onDeleteClick,
|
||||
onCancelClick,
|
||||
@@ -22,106 +19,37 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
}: EnvelopesTableBulkActionBarProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
// Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault
|
||||
// when handling Escape, so this only clears the selection when nothing
|
||||
// else consumed the key press.
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) {
|
||||
onClearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [selectedCount, onClearSelection]);
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
|
||||
<div className="flex items-center gap-x-2 px-2">
|
||||
<span className="sr-only" aria-live="polite">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
|
||||
>
|
||||
{selectedCount}
|
||||
</span>
|
||||
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
|
||||
<Trans>selected</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
|
||||
<span className="font-medium text-sm">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</span>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<FolderInputIcon className="size-4 shrink-0" />
|
||||
<Trans>Move</Trans>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
|
||||
<FolderInputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</Button>
|
||||
|
||||
{onDownloadClick && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDownloadClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<DownloadIcon className="size-4 shrink-0" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCancelClick && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancelClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<XCircleIcon className="size-4 shrink-0" />
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
|
||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDeleteClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0" />
|
||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearSelection}
|
||||
aria-label={t`Clear selection`}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XIcon className="size-4 shrink-0" />
|
||||
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,57 +1,59 @@
|
||||
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, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
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 = {};
|
||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
status: true,
|
||||
period: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
}).extend({
|
||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
@@ -59,18 +61,9 @@ export default function DocumentsPage() {
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
|
||||
|
||||
// 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 [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
|
||||
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
||||
|
||||
const selectedEnvelopeIds = useMemo(() => {
|
||||
@@ -83,23 +76,18 @@ export default function DocumentsPage() {
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.CANCELLED]: 0,
|
||||
[ExtendedDocumentStatus.EXPIRED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
});
|
||||
|
||||
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
const findDocumentSearchParams = useMemo(
|
||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
{
|
||||
status: findDocumentSearchParams.status ?? undefined,
|
||||
period: findDocumentSearchParams.period ?? undefined,
|
||||
senderIds: findDocumentSearchParams.senderIds ?? undefined,
|
||||
page: findDocumentSearchParams.page ?? undefined,
|
||||
perPage: findDocumentSearchParams.perPage ?? undefined,
|
||||
query: findDocumentSearchParams.query ?? undefined,
|
||||
...findDocumentSearchParams,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
@@ -107,66 +95,34 @@ export default function DocumentsPage() {
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEnvelopeMetaCache((prev) => {
|
||||
const next: EnvelopeMetaCache = {};
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
for (const id of Object.keys(prev)) {
|
||||
if (rowSelection[id]) {
|
||||
next[id] = prev[id];
|
||||
}
|
||||
}
|
||||
params.set('status', value);
|
||||
|
||||
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.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
const selectedEnvelopesForDownload = useMemo(() => {
|
||||
return selectedEnvelopeIds
|
||||
.map((id): EnvelopeBulkDownloadItem | null => {
|
||||
const meta = envelopeMetaCache[id];
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
let path = formatDocumentsPath(team.url);
|
||||
|
||||
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 (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return Boolean(
|
||||
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
|
||||
findDocumentSearchParams.senderIds?.length ||
|
||||
findDocumentSearchParams.period,
|
||||
);
|
||||
}, [findDocumentSearchParams]);
|
||||
if (params.toString()) {
|
||||
path += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const onResetFilters = () => {
|
||||
void setFindDocumentSearchParams({
|
||||
status: null,
|
||||
senderIds: null,
|
||||
period: null,
|
||||
page: null,
|
||||
});
|
||||
return path;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,40 +136,67 @@ 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-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-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>
|
||||
|
||||
<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 />
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DocumentsTableStatusFilter stats={stats} />
|
||||
<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;
|
||||
}
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
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} />
|
||||
|
||||
<DocumentsTablePeriodFilter />
|
||||
{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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
</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}
|
||||
@@ -252,28 +235,12 @@ 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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -26,14 +26,12 @@ 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();
|
||||
@@ -49,11 +47,7 @@ export default function TemplatesPage() {
|
||||
const isOrgView = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
// 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 [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -15,7 +14,6 @@ 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';
|
||||
@@ -72,18 +70,8 @@ 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,
|
||||
@@ -108,7 +96,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
.then((envelopeForSigning) => {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
isTemplateMissingSignatures: false,
|
||||
envelopeForSigning,
|
||||
} as const;
|
||||
})
|
||||
@@ -121,13 +108,6 @@ 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 });
|
||||
});
|
||||
};
|
||||
@@ -201,10 +181,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
return <DirectTemplateAuthPageView />;
|
||||
}
|
||||
|
||||
if (data.isTemplateMissingSignatures) {
|
||||
return <DirectTemplateInvalidPageView />;
|
||||
}
|
||||
|
||||
const { template, directTemplateRecipient } = data;
|
||||
|
||||
return (
|
||||
@@ -259,10 +235,6 @@ 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({
|
||||
|
||||
@@ -1,15 +1,98 @@
|
||||
import { redirect } from 'react-router';
|
||||
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 type { Route } from './+types/organisation.decline.$token';
|
||||
|
||||
export function loader({ params }: Route.LoaderArgs) {
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw redirect('/');
|
||||
return {
|
||||
state: 'InvalidLink',
|
||||
} as const;
|
||||
}
|
||||
|
||||
// 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`);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/organisation.invite.$token';
|
||||
|
||||
@@ -43,22 +37,6 @@ 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: {
|
||||
@@ -71,13 +49,26 @@ 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: 'Pending',
|
||||
token: organisationMemberInvite.token,
|
||||
state: 'Success',
|
||||
email: organisationMemberInvite.email,
|
||||
organisationName,
|
||||
userExists: user !== null,
|
||||
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
isSessionUserTheInvitedUser,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -106,253 +97,57 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
);
|
||||
}
|
||||
|
||||
if (data.state === 'AlreadyAccepted') {
|
||||
if (data.state === 'LoginRequired') {
|
||||
return (
|
||||
<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 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You are already a member of <strong>{data.organisationName}</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.state === 'AlreadyDeclined') {
|
||||
return <InvitationDeclined organisationName={data.organisationName} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have been invited to join <strong>{organisationName}</strong> on Documenso.
|
||||
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
|
||||
</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>
|
||||
)}
|
||||
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>To accept this invitation you must create an account.</Trans>
|
||||
</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>
|
||||
<Button asChild>
|
||||
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
|
||||
<Trans>Create account</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</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>
|
||||
<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 declined the invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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,10 +32,6 @@ 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.`,
|
||||
@@ -81,10 +77,6 @@ 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.`,
|
||||
|
||||
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.16.0"
|
||||
"version": "2.15.0"
|
||||
}
|
||||
|
||||
Generated
+30
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.16.0",
|
||||
"version": "2.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.16.0",
|
||||
"version": "2.15.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -22,7 +22,6 @@
|
||||
"@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",
|
||||
@@ -367,7 +366,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.16.0",
|
||||
"version": "2.15.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -2953,6 +2952,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2970,6 +2972,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2987,6 +2992,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3004,6 +3012,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -20081,9 +20092,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
@@ -25015,6 +25026,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -25031,6 +25045,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -25047,6 +25064,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -25063,6 +25083,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -26686,12 +26709,6 @@
|
||||
"web-vitals": "^4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js/node_modules/fflate": {
|
||||
"version": "0.4.9",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
|
||||
"integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-node": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz",
|
||||
|
||||
+1
-2
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.16.0",
|
||||
"version": "2.15.0",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
@@ -94,7 +94,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -11,7 +11,7 @@ export const OpenAPIV1 = Object.assign(
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'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.',
|
||||
'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.',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
@@ -2,20 +2,10 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { signSignaturePad } from '../../fixtures/signature';
|
||||
|
||||
test.describe('Document API', () => {
|
||||
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
|
||||
@@ -442,194 +432,4 @@ test.describe('Document API', () => {
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('sendDocument: should complete document immediately when all recipients are CC', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a blank document and get it with envelope items
|
||||
const blankDocument = await seedBlankDocument(user, team.id);
|
||||
const document = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: blankDocument.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
// Add two CC recipients without any fields, mirroring the production
|
||||
// state where CC recipients are created pre-signed.
|
||||
for (const email of ['cc1@example.com', 'cc2@example.com']) {
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email,
|
||||
name: 'Test CC',
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The document seals asynchronously and completes without anyone signing.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return updatedDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('sendDocument: should not block initial sequential send when CC recipient is first in signing order', async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a blank document and get it with envelope items
|
||||
const blankDocument = await seedBlankDocument(user, team.id);
|
||||
const document = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: blankDocument.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: document.documentMetaId },
|
||||
data: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
});
|
||||
|
||||
// CC recipient first in the signing order, mirroring the production
|
||||
// state where CC recipients are created pre-signed.
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: 'cc@example.com',
|
||||
name: 'Test CC',
|
||||
role: RecipientRole.CC,
|
||||
signingOrder: 1,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [signerA, signerB] = await Promise.all(
|
||||
[
|
||||
{ email: 'signer-a@example.com', name: 'Signer A', signingOrder: 2 },
|
||||
{ email: 'signer-b@example.com', name: 'Signer B', signingOrder: 3 },
|
||||
].map(async ({ email, name, signingOrder }) =>
|
||||
prisma.recipient.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
fields: {
|
||||
create: {
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: signingOrder * 10,
|
||||
positionY: 10,
|
||||
width: 5,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
envelopeId: document.id,
|
||||
envelopeItemId: document.envelopeItems[0].id,
|
||||
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The CC recipient at order 1 must not block signer A at order 2.
|
||||
await page.goto(`/sign/${signerA.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${signerA.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Signer B at order 3 must still wait for signer A.
|
||||
await page.goto(`/sign/${signerB.token}`);
|
||||
await expect(page).toHaveURL(`/sign/${signerB.token}/waiting`);
|
||||
|
||||
// Sign as signer A then signer B.
|
||||
for (const signer of [signerA, signerB]) {
|
||||
await page.goto(`/sign/${signer.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const signerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: signer.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${signerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${signer.token}/complete`);
|
||||
}
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return updatedDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||
* apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
@@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
@@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
||||
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
TeamMemberRole,
|
||||
} from '@documenso/prisma/client';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedCompletedDocument,
|
||||
@@ -1566,307 +1560,3 @@ test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => {
|
||||
expect(ownTemplate!.data[0].title).toBe('TeamA Doc from Template');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Find Documents API - Expired Recipient Filter', () => {
|
||||
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const FUTURE = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
test('hasExpiredRecipients=true returns only docs with an expired, unsigned, non-CC recipient', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const expiredDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Recipient Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: expiredDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const activeDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Active Recipient Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: activeDoc.id },
|
||||
data: { expiresAt: FUTURE },
|
||||
});
|
||||
|
||||
await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'No Expiry Doc' },
|
||||
});
|
||||
|
||||
const { json } = await findDocuments(request, token, { hasExpiredRecipients: 'true' });
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('Expired Recipient Doc');
|
||||
expect(titles).not.toContain('Active Recipient Doc');
|
||||
expect(titles).not.toContain('No Expiry Doc');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
|
||||
test('hasExpiredRecipients=false (and omitted) does not filter by expiry', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'expired-false-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const expiredDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: expiredDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Active Doc' },
|
||||
});
|
||||
|
||||
// "false" must NOT be coerced to true — both docs should be returned.
|
||||
const { json: falseJson } = await findDocuments(request, token, { hasExpiredRecipients: 'false' });
|
||||
expect(falseJson!.count).toBe(2);
|
||||
|
||||
const { json: omittedJson } = await findDocuments(request, token);
|
||||
expect(omittedJson!.count).toBe(2);
|
||||
});
|
||||
|
||||
test('excludes signed and CC recipients from the expired filter', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'expired-exclude-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const signedDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired but Signed' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: signedDoc.id },
|
||||
data: { expiresAt: PAST, signingStatus: SigningStatus.SIGNED },
|
||||
});
|
||||
|
||||
const ccDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired but CC' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: ccDoc.id },
|
||||
data: { expiresAt: PAST, role: RecipientRole.CC },
|
||||
});
|
||||
|
||||
const validDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Unsigned Signer' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: validDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findDocuments(request, token, { hasExpiredRecipients: 'true' });
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('Expired Unsigned Signer');
|
||||
expect(titles).not.toContain('Expired but Signed');
|
||||
expect(titles).not.toContain('Expired but CC');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adversarial: Expired Recipient Filter cross-tenant isolation ────────────
|
||||
// The expired filter adds an EXISTS subquery over Recipient. These tests ensure
|
||||
// that predicate never widens visibility past the caller's team/access scope.
|
||||
|
||||
test.describe('Find Documents API - Adversarial: Cross-Team Expired Recipient Filter', () => {
|
||||
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
test('token scoped to team A must NOT see team B docs with expired recipients', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'teamA-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// Team A: one expired doc the caller is legitimately allowed to see.
|
||||
const teamADoc = await seedPendingDocument(userA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
// Team B: an expired doc that must remain invisible to team A's token.
|
||||
const teamBDoc = await seedPendingDocument(userB, teamB.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamB Expired Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findDocuments(request, tokenA, { hasExpiredRecipients: 'true' });
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('TeamA Expired Doc');
|
||||
expect(titles).not.toContain('TeamB Expired Doc');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
|
||||
test('shared recipient email across teams does not leak the other team expired docs', async ({ request }) => {
|
||||
// A recipient with the SAME email is on expired docs in both teams. The
|
||||
// filter must still scope strictly to the token's team.
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
const { user: sharedRecipient } = await seedUser();
|
||||
|
||||
const { token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'teamB-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const teamADoc = await seedPendingDocument(userA, teamA.id, [sharedRecipient], {
|
||||
createDocumentOptions: { title: 'TeamA Shared-Recipient Expired' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const teamBDoc = await seedPendingDocument(userB, teamB.id, [sharedRecipient], {
|
||||
createDocumentOptions: { title: 'TeamB Shared-Recipient Expired' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findDocuments(request, tokenB, { hasExpiredRecipients: 'true' });
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('TeamB Shared-Recipient Expired');
|
||||
expect(titles).not.toContain('TeamA Shared-Recipient Expired');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
|
||||
test('x-team-id spoofing with status=EXPIRED is rejected for a non-member', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired Secret' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
// ownerB is NOT a member of teamA.
|
||||
await apiSignin({ page, email: ownerB.email });
|
||||
|
||||
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||
status: 'EXPIRED',
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('EXPIRED pseudo-status via session only returns the caller team expired docs (positive control)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired Visible' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const teamBDoc = await seedPendingDocument(ownerB, teamB.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamB Expired Hidden' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||
status: 'EXPIRED',
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const data = await res.json();
|
||||
const docs = data.result.data.json.data;
|
||||
const titles = docs.map((d: { title: string }) => d.title);
|
||||
expect(titles).toContain('TeamA Expired Visible');
|
||||
expect(titles).not.toContain('TeamB Expired Hidden');
|
||||
});
|
||||
|
||||
test('EXPIRED stats count is scoped to the caller team and excludes other-team expired docs', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// One expired doc in team A.
|
||||
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired For Stats' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
// Two expired docs in team B — must NOT bleed into team A's EXPIRED count.
|
||||
for (const title of ['TeamB Expired For Stats 1', 'TeamB Expired For Stats 2']) {
|
||||
const doc = await seedPendingDocument(ownerB, teamB.id, [recipient], {
|
||||
createDocumentOptions: { title },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: doc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
}
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const data = await res.json();
|
||||
expect(data.result.data.json.stats.EXPIRED).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1055,120 +1055,3 @@ test.describe('Find Envelopes API - Cross-User Isolation', () => {
|
||||
expect(titles).not.toContain('Member Org Team Env');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Find Envelopes API - Expired Recipient Filter', () => {
|
||||
test('hasExpiredRecipients=true returns only envelopes with an expired, unsigned recipient', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'env-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const expiredEnvelope = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: expiredEnvelope.id },
|
||||
data: { expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Active Envelope' },
|
||||
});
|
||||
|
||||
const { json } = await findEnvelopes(request, token, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
hasExpiredRecipients: 'true',
|
||||
});
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('Expired Envelope');
|
||||
expect(titles).not.toContain('Active Envelope');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adversarial: Expired Recipient Filter cross-tenant isolation ────────────
|
||||
|
||||
test.describe('Find Envelopes API - Adversarial: Cross-Team Expired Recipient Filter', () => {
|
||||
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
test('token scoped to team A must NOT see team B envelopes with expired recipients', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'env-teamA-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const teamAEnvelope = await seedPendingDocument(userA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamAEnvelope.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const teamBEnvelope = await seedPendingDocument(userB, teamB.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamB Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBEnvelope.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findEnvelopes(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
hasExpiredRecipients: 'true',
|
||||
});
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('TeamA Expired Envelope');
|
||||
expect(titles).not.toContain('TeamB Expired Envelope');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
|
||||
test('shared recipient email across teams does not leak the other team expired envelopes', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
const { user: sharedRecipient } = await seedUser();
|
||||
|
||||
const { token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'env-teamB-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const teamAEnvelope = await seedPendingDocument(userA, teamA.id, [sharedRecipient], {
|
||||
createDocumentOptions: { title: 'TeamA Shared Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamAEnvelope.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const teamBEnvelope = await seedPendingDocument(userB, teamB.id, [sharedRecipient], {
|
||||
createDocumentOptions: { title: 'TeamB Shared Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBEnvelope.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findEnvelopes(request, tokenB, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
hasExpiredRecipients: 'true',
|
||||
});
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('TeamB Shared Expired Envelope');
|
||||
expect(titles).not.toContain('TeamA Shared Expired Envelope');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||
* apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts).
|
||||
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
@@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
|
||||
`${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
|
||||
|
||||
const seedOwnerWithDraft = async () => {
|
||||
const owner = await seedUser();
|
||||
|
||||
const draft = await seedDraftDocument(owner.user, owner.team.id, [], {
|
||||
createDocumentOptions: { title: 'File Download Auth Test' },
|
||||
});
|
||||
|
||||
return { owner, draft, draftItem: draft.envelopeItems[0] };
|
||||
};
|
||||
|
||||
test.describe('Envelope item file download endpoint authorization', () => {
|
||||
test('rejects an unauthenticated download request', async ({ request }) => {
|
||||
const { draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a download request from a user outside the organisation', async ({ page }) => {
|
||||
const { draft, draftItem } = await seedOwnerWithDraft();
|
||||
const { user: outsider } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('returns 404 for a nonexistent envelope', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const res = await page.request.get(
|
||||
downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'),
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('rejects a pending version download for a draft envelope', async ({ page }) => {
|
||||
const { owner, draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects a pending version download for a legacy envelope', async ({ page }) => {
|
||||
const owner = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// Default internalVersion is 1 (legacy).
|
||||
const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Legacy Pending Download Test' },
|
||||
});
|
||||
|
||||
const envelopeItem = pendingDocument.envelopeItems[0];
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('allows the owner to download their own document', async ({ page }) => {
|
||||
const { owner, draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.headers()['content-type']).toContain('application/pdf');
|
||||
|
||||
const body = await res.body();
|
||||
|
||||
// %PDF magic bytes.
|
||||
expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
|
||||
});
|
||||
|
||||
test('rejects a recipient-token download with an invalid token', async ({ request }) => {
|
||||
const { draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openCommandMenu } from '../fixtures/command-menu';
|
||||
|
||||
const COMMAND_MENU_PLACEHOLDER = 'Type a command or search...';
|
||||
|
||||
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -17,9 +14,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -33,9 +30,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -49,8 +46,8 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(recipient.email);
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -2,14 +2,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
@@ -377,221 +370,3 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
|
||||
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should skip CC recipient when dictating next signer', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: ccUser } = await seedUser();
|
||||
const { user: secondSigner } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstSigner, ccUser, secondSigner],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
update: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstRecipient = recipients.find((r) => r.email === firstSigner.email);
|
||||
const ccRecipient = recipients.find((r) => r.email === ccUser.email);
|
||||
|
||||
if (!firstRecipient || !ccRecipient) {
|
||||
throw new Error('Recipients not found');
|
||||
}
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { token, fields } = firstRecipient;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Fill in all fields
|
||||
for (const field of fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
// Complete signing and verify the offered next recipient
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Next Recipient Name')).toBeVisible();
|
||||
|
||||
// The dictation dialog must offer the second signer, not the CC recipient.
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByLabel('Name')).toHaveValue(secondSigner.name ?? '');
|
||||
await expect(dialog.getByLabel('Email')).toHaveValue(secondSigner.email);
|
||||
|
||||
// Submit and verify completion
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
// Verify document and recipient states
|
||||
const updatedDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: {
|
||||
recipients: {
|
||||
orderBy: { signingOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Document should still be pending as the second signer has not signed
|
||||
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
// The CC recipient must remain untouched
|
||||
const updatedCcRecipient = updatedDocument.recipients[1];
|
||||
expect(updatedCcRecipient.email).toBe(ccUser.email);
|
||||
expect(updatedCcRecipient.role).toBe(RecipientRole.CC);
|
||||
expect(updatedCcRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||
|
||||
// The second signer must remain the next pending recipient
|
||||
const updatedSecondRecipient = updatedDocument.recipients[2];
|
||||
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
|
||||
expect(updatedSecondRecipient.signingOrder).toBe(3);
|
||||
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should not offer dictation when CC recipient is last', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: secondSigner } = await seedUser();
|
||||
const { user: ccUser } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstSigner, secondSigner, ccUser],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{ signingOrder: 2 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 3,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
update: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstRecipient = recipients.find((r) => r.email === firstSigner.email);
|
||||
const secondRecipient = recipients.find((r) => r.email === secondSigner.email);
|
||||
const ccRecipient = recipients.find((r) => r.email === ccUser.email);
|
||||
|
||||
if (!firstRecipient || !secondRecipient || !ccRecipient) {
|
||||
throw new Error('Recipients not found');
|
||||
}
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sign as both signers in order.
|
||||
for (const recipient of [firstRecipient, secondRecipient]) {
|
||||
const { token, fields } = recipient;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Fill in all fields
|
||||
for (const field of fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
// Complete signing
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
if (recipient.id === secondRecipient.id) {
|
||||
// The last actionable signer must not be offered the CC recipient.
|
||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
}
|
||||
|
||||
// Submit and verify completion
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
}
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
@@ -4,14 +4,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
@@ -232,20 +225,15 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// CC recipients are kept last, so new rows are inserted above the CC row.
|
||||
await expect(page.getByLabel('Email')).toHaveCount(3);
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('user3@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('User 3');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Email').nth(2).fill('user3@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('User 3');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await expect(page.getByLabel('Email')).toHaveCount(4);
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('user4@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('User 4');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Email').nth(3).fill('user4@example.com');
|
||||
await page.getByLabel('Name').nth(3).fill('User 4');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -673,182 +661,3 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
|
||||
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should skip CC recipients in sequential signing order', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
teamId: team.id,
|
||||
owner: user,
|
||||
recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
const firstSigner = recipients.find((r) => r.email === 'signer1@example.com');
|
||||
const ccRecipient = recipients.find((r) => r.email === 'cc@example.com');
|
||||
const lastSigner = recipients.find((r) => r.email === 'signer2@example.com');
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sequential order is enforced: the last signer must wait while the first signer is pending.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
|
||||
// Sign as the first signer.
|
||||
await page.goto(`/sign/${firstSigner?.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const firstSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: firstSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${firstSigner?.token}/complete`);
|
||||
|
||||
// The CC recipient at order 2 must not block the last signer at order 3.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const lastSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: lastSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${lastSigner?.token}/complete`);
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should skip unsigned CC recipients in sequential signing order', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
teamId: team.id,
|
||||
owner: user,
|
||||
recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// Legacy/inconsistent data: a CC recipient that was never marked as signed.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
const firstSigner = recipients.find((r) => r.email === 'signer1@example.com');
|
||||
const ccRecipient = recipients.find((r) => r.email === 'cc@example.com');
|
||||
const lastSigner = recipients.find((r) => r.email === 'signer2@example.com');
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sign as the first signer.
|
||||
await page.goto(`/sign/${firstSigner?.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const firstSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: firstSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${firstSigner?.token}/complete`);
|
||||
|
||||
// The unsigned CC recipient at order 2 must not block the last signer at order 3.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const lastSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: lastSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${lastSigner?.token}/complete`);
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from 'node:fs';
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
@@ -7,7 +5,6 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
@@ -53,10 +50,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
||||
@@ -70,7 +67,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
|
||||
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
@@ -83,11 +80,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
||||
@@ -101,13 +98,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
@@ -116,122 +113,6 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const teamBUrl = `team-b-${Date.now()}`;
|
||||
|
||||
await createTeam({
|
||||
userId: sender.user.id,
|
||||
teamName: 'Team B',
|
||||
teamUrl: teamBUrl,
|
||||
organisationId: sender.organisation.id,
|
||||
inheritMembers: true,
|
||||
});
|
||||
|
||||
const teamB = await prisma.team.findFirstOrThrow({
|
||||
where: { url: teamBUrl },
|
||||
});
|
||||
|
||||
await seedDraftDocument(sender.user, teamB.id, [], {
|
||||
createDocumentOptions: { title: 'Team B Doc' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// The selection made in team A must not appear in team B.
|
||||
await page.goto(`/t/${teamBUrl}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
|
||||
// Returning to team A restores its selection.
|
||||
await page.goto(`/t/${sender.team.url}/documents`);
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// Escape while a dialog is open should close the dialog but keep the selection.
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// Escape with no dialog open should clear the selection.
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByText(/1\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => {
|
||||
const { sender, documents } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const [doc1, doc2] = documents;
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Download', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('Download Documents')).toBeVisible();
|
||||
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
|
||||
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
|
||||
await expect(dialog.getByText('Draft').first()).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
|
||||
|
||||
await dialog.getByRole('button', { name: 'Download' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/);
|
||||
|
||||
const downloadPath = await download.path();
|
||||
const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath)));
|
||||
|
||||
// Each envelope's files are nested inside an `envelopeId_title` folder.
|
||||
expect(Object.keys(zipContents).sort()).toEqual(
|
||||
[`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(),
|
||||
);
|
||||
|
||||
// Each entry should be a valid non-empty PDF (%PDF magic bytes).
|
||||
for (const entry of Object.values(zipContents)) {
|
||||
expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
|
||||
}
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents downloaded');
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
@@ -271,14 +152,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
@@ -291,13 +172,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
@@ -318,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
@@ -355,14 +236,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentCounts, selectDocumentStatusFilter } from '../fixtures/documents';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -61,10 +61,13 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// The document must remain in the dashboard, unlike deleting a pending document.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Cancelled', 1);
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
@@ -128,7 +131,7 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
|
||||
@@ -3,7 +3,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentCounts } from '../fixtures/documents';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -174,7 +174,11 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 0, all: 2 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
|
||||
@@ -203,7 +207,11 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 1, draft: 1, all: 2 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => {
|
||||
@@ -231,7 +239,11 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 0, draft: 1, all: 2 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
|
||||
// Sign into the recipient account.
|
||||
await apiSignout({ page });
|
||||
@@ -243,7 +255,11 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
|
||||
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => {
|
||||
@@ -284,7 +300,11 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 0, draft: 0, all: 0 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 0);
|
||||
|
||||
// Sign into the sender account.
|
||||
await apiSignout({ page });
|
||||
@@ -295,7 +315,11 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Check document counts for sender.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 1, all: 3 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
// Sign into the other recipient account.
|
||||
await apiSignout({ page });
|
||||
@@ -306,5 +330,9 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Check document counts for other recipient.
|
||||
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
@@ -10,17 +10,10 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
OrganisationMemberRole,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
import { DocumentStatus, DocumentVisibility, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
@@ -61,7 +54,10 @@ test.describe('Find Documents UI - Personal Context', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentCounts(page, { draft: 1, pending: 1, completed: 1, all: 3 });
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
});
|
||||
|
||||
test('received documents from other teams should NOT appear in personal context', async ({ page }) => {
|
||||
@@ -137,9 +133,10 @@ test.describe('Find Documents UI - Personal Context', () => {
|
||||
redirectPath: `/t/${ownerTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Inbox should be 0 since there's no team email and received docs are on sender's team.
|
||||
// Owner's own doc should still show in All.
|
||||
await checkDocumentCounts(page, { inbox: 0, all: 1 });
|
||||
// Inbox should be 0 since there's no team email and received docs are on sender's team
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
// Owner's own doc should still show in All
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -703,8 +700,9 @@ test.describe('Find Documents UI - Team with Team Email', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Inbox should be 0, but pending should still show.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
// But pending should still show
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
});
|
||||
|
||||
test('documents sent BY team email user should appear in team context', async ({ page }) => {
|
||||
@@ -805,9 +803,12 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
|
||||
});
|
||||
|
||||
// UserA should see only their own docs
|
||||
await checkDocumentCounts(page, { draft: 1, completed: 1, all: 3 });
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
|
||||
// Verify no B docs leaked
|
||||
await page.getByRole('tab', { name: 'All' }).click();
|
||||
await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible();
|
||||
@@ -958,9 +959,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
|
||||
redirectPath: `/t/${outsideTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Only the outside user's own draft should appear (cross-team docs are not visible).
|
||||
// Inbox is 0 since there is no team email.
|
||||
await checkDocumentCounts(page, { inbox: 0, all: 1 });
|
||||
// Only the outside user's own draft should appear (cross-team docs are not visible)
|
||||
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0
|
||||
await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links
|
||||
await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible();
|
||||
@@ -1005,10 +1006,12 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${ownerTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Only owner's own docs appear (received docs are on sender's team).
|
||||
// Inbox is 0 since there is no team email, and only the owned completed
|
||||
// doc counts (received is on sender's team). All = 2 drafts + 1 pending + 1 completed.
|
||||
await checkDocumentCounts(page, { inbox: 0, draft: 2, pending: 1, completed: 1, all: 4 });
|
||||
// Only owner's own docs appear (received docs are on sender's team)
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0
|
||||
await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team)
|
||||
await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed
|
||||
});
|
||||
|
||||
test('team context tab counts should be accurate with mixed documents', async ({ page }) => {
|
||||
@@ -1060,7 +1063,10 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentCounts(page, { draft: 2, pending: 1, completed: 1, all: 4 });
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
});
|
||||
|
||||
test('team with team email tab counts should include received documents', async ({ page }) => {
|
||||
@@ -1094,9 +1100,11 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Inbox = one pending doc received by team email (NOT_SIGNED), pending = own
|
||||
// pending, completed = received completed via email, all = all of the above.
|
||||
await checkDocumentCounts(page, { inbox: 1, draft: 1, pending: 1, completed: 1, all: 4 });
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED)
|
||||
await checkDocumentTabCount(page, 'Pending', 1); // Own pending
|
||||
await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email
|
||||
await checkDocumentTabCount(page, 'All', 4); // All of the above
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1148,139 +1156,12 @@ test.describe('Find Documents UI - Sender Filter', () => {
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
// Filter by member1
|
||||
await toggleDocumentSenderFilter(page, member1.name ?? '');
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: member1.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Should only show member1's doc
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
await expect(page.getByRole('link', { name: 'Member1 Sent Doc' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Find Documents UI - Rejected and Expired Tabs', () => {
|
||||
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
test('rejected tab lists rejected documents and counts them independently', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// A rejected document: envelope status REJECTED + a recipient who rejected.
|
||||
const rejectedDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Rejected Doc' },
|
||||
});
|
||||
await prisma.envelope.update({
|
||||
where: { id: rejectedDoc.id },
|
||||
data: { status: DocumentStatus.REJECTED },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: rejectedDoc.id },
|
||||
data: { signingStatus: SigningStatus.REJECTED },
|
||||
});
|
||||
|
||||
// A plain pending document (noise — must not appear under Rejected).
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Plain Pending Doc' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Rejected', 1);
|
||||
await expect(page.getByRole('link', { name: 'Rejected Doc' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Plain Pending Doc' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('expired tab lists documents with an expired recipient and shows empty state otherwise', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const expiredDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: expiredDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
// Active pending doc — recipient link not expired.
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Active Doc' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Expired doc is still PENDING, so it appears under both Pending and Expired.
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Expired', 1);
|
||||
await expect(page.getByRole('link', { name: 'Expired Doc' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Active Doc' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('expired tab excludes signed and CC recipients', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// Expired but already signed — must NOT count as expired.
|
||||
const signedDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Signed Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: signedDoc.id },
|
||||
data: { expiresAt: PAST, signingStatus: SigningStatus.SIGNED },
|
||||
});
|
||||
|
||||
// Expired but CC — must NOT count as expired.
|
||||
const ccDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired CC Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: ccDoc.id },
|
||||
data: { expiresAt: PAST, role: RecipientRole.CC },
|
||||
});
|
||||
|
||||
// Expired, unsigned, non-CC — the only one that should appear.
|
||||
const validDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Valid Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: validDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Expired', 1);
|
||||
await expect(page.getByRole('link', { name: 'Expired Valid Doc' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Expired Signed Doc' })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Expired CC Doc' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('rejected and expired tabs show tailored empty states when nothing matches', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Just Pending' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// count === 0 asserts the empty-document-state is visible.
|
||||
await checkDocumentTabCount(page, 'Rejected', 0);
|
||||
await checkDocumentTabCount(page, 'Expired', 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
|
||||
|
||||
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
|
||||
|
||||
/**
|
||||
* Place a field on the PDF canvas in the envelope editor.
|
||||
*/
|
||||
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
|
||||
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await canvas.click({ position });
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a V2 direct template and open it in the native template editor.
|
||||
*
|
||||
* Only the native template editor is covered here: direct links only exist
|
||||
* for templates and are not part of the embedded editor surfaces.
|
||||
*/
|
||||
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: `E2E Direct Template Validation ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||
await expect(page.getByText('are missing a signature field')).toBeVisible();
|
||||
});
|
||||
|
||||
test('does not show the warning when all signers have signature fields', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
|
||||
|
||||
// Wait for the editor to render before asserting the banner is absent.
|
||||
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('warning disappears after placing a signature field', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||
|
||||
// Place a signature field for the direct recipient (auto-selected single recipient).
|
||||
await clickEnvelopeEditorStep(page, 'addFields');
|
||||
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
|
||||
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
|
||||
|
||||
// The banner clears once the field is autosaved and the envelope state updates.
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } 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 { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import {
|
||||
assertRecipientRole,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientRows,
|
||||
getRecipientStepCards,
|
||||
openDocumentEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
const SIGNER_A = { email: 'cc-order-signer-a@example.com', name: 'Signer A' };
|
||||
const SIGNER_B = { email: 'cc-order-signer-b@example.com', name: 'Signer B' };
|
||||
const CC_RECIPIENT = { email: 'cc-order-cc@example.com', name: 'CC Recipient' };
|
||||
|
||||
const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => {
|
||||
// CC recipient is displayed last despite being added/stored mid-list.
|
||||
await expect(getRecipientEmailInputs(root)).toHaveCount(3);
|
||||
await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue(SIGNER_A.email);
|
||||
await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue(SIGNER_B.email);
|
||||
await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email);
|
||||
|
||||
await assertRecipientRole(root, 0, 'Needs to sign');
|
||||
await assertRecipientRole(root, 1, 'Needs to sign');
|
||||
await assertRecipientRole(root, 2, 'Receives copy');
|
||||
|
||||
// Only the two signers render as ordered group cards, showing groups 1 and 2.
|
||||
await expect(getRecipientStepCards(root)).toHaveCount(2);
|
||||
await expect(root.getByText('Group 1', { exact: true })).toBeVisible();
|
||||
await expect(root.getByText('Group 2', { exact: true })).toBeVisible();
|
||||
|
||||
// The CC row itself renders outside the group cards with no drag handle.
|
||||
const ccRow = getRecipientRows(root).nth(2);
|
||||
await expect(ccRow.locator('[data-testid="recipient-row-drag-handle"]')).toHaveCount(0);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('CC recipient added mid-list is displayed last with no signing order input', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { root } = surface;
|
||||
|
||||
await toggleSigningOrder(root, true);
|
||||
|
||||
// Add signer A into the initial empty row.
|
||||
await setRecipientEmail(root, 0, SIGNER_A.email);
|
||||
await setRecipientName(root, 0, SIGNER_A.name);
|
||||
|
||||
// Add the CC recipient second.
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await setRecipientEmail(root, 1, CC_RECIPIENT.email);
|
||||
await setRecipientName(root, 1, CC_RECIPIENT.name);
|
||||
await setRecipientRole(root, 1, 'Receives copy');
|
||||
|
||||
// Once the row becomes CC, it drops out of the ordered group cards.
|
||||
await expect(getRecipientStepCards(root)).toHaveCount(1);
|
||||
|
||||
// Add signer B third. The new row is inserted before the CC recipient,
|
||||
// which is kept last by the client-side sorting.
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email);
|
||||
|
||||
await setRecipientEmail(root, 1, SIGNER_B.email);
|
||||
await setRecipientName(root, 1, SIGNER_B.name);
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(root);
|
||||
|
||||
// The editor autosaves with a debounce, poll the DB until all three
|
||||
// recipients have been persisted before reloading the page.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId: surface.envelopeId },
|
||||
});
|
||||
|
||||
return recipients.length;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(3);
|
||||
|
||||
// Reload the editor and assert the CC recipient is still displayed last.
|
||||
await root.reload();
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(root);
|
||||
});
|
||||
|
||||
test('CC recipient seeded with mid-list signing order is displayed last', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
// Seed a CC recipient directly in the DB with a mid-list signing order
|
||||
// (2 of 3) BEFORE opening the editor, so the editor's autosave cannot
|
||||
// race with the seeded recipients, and assert the editor renders it last.
|
||||
await prisma.envelope.update({
|
||||
where: { id: document.id },
|
||||
data: {
|
||||
documentMeta: {
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
recipients: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
email: SIGNER_A.email,
|
||||
name: SIGNER_A.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: CC_RECIPIENT.email,
|
||||
name: CC_RECIPIENT.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.CC,
|
||||
signingOrder: 2,
|
||||
},
|
||||
{
|
||||
email: SIGNER_B.email,
|
||||
name: SIGNER_B.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(page);
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickAddSignerButton,
|
||||
dragGroupCardOntoCard,
|
||||
dragRecipientRowToGap,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientStepCards,
|
||||
moveGroupCardUp,
|
||||
openDocumentEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
type TEnvelopeEditorSurface,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => {
|
||||
const { envelopeId } = surface;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected surface to have an envelope ID');
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0])));
|
||||
};
|
||||
|
||||
const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root } = surface;
|
||||
|
||||
await setRecipientEmail(root, 0, 'alice@example.com');
|
||||
await setRecipientName(root, 0, 'Alice');
|
||||
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, 'bob@example.com');
|
||||
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 2, 'carol@example.com');
|
||||
|
||||
await toggleSigningOrder(root, true);
|
||||
|
||||
// Three standalone groups.
|
||||
await expect(root.getByText('Group 1', { exact: true })).toBeVisible();
|
||||
await expect(root.getByText('Group 3', { exact: true })).toBeVisible();
|
||||
|
||||
// Drag carol's card onto bob's card to merge them into one group.
|
||||
await dragGroupCardOntoCard(root, 2, 1);
|
||||
|
||||
await expect(root.getByText('2 recipients · any order')).toBeVisible();
|
||||
await expect(root.getByTestId('ungroup-step-button')).toBeVisible();
|
||||
await expect(root.getByText('Group 3', { exact: true })).not.toBeVisible();
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 2],
|
||||
['carol@example.com', 2],
|
||||
]);
|
||||
|
||||
// Groups survive a reload (grouped normalization on load).
|
||||
await root.reload();
|
||||
await expect(root.getByText('2 recipients · any order')).toBeVisible();
|
||||
|
||||
// Ungroup dissolves back into sequential groups.
|
||||
await root.getByTestId('ungroup-step-button').click();
|
||||
|
||||
await expect(root.getByText('2 recipients · any order')).not.toBeVisible();
|
||||
await expect(root.getByText('Group 3', { exact: true })).toBeVisible();
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 2],
|
||||
['carol@example.com', 3],
|
||||
]);
|
||||
|
||||
// Drag bob's row into the gap after the last group, moving him to the end.
|
||||
await dragRecipientRowToGap(root, 1, 3);
|
||||
|
||||
await expectRecipientOrders(surface, [
|
||||
['alice@example.com', 1],
|
||||
['bob@example.com', 3],
|
||||
['carol@example.com', 2],
|
||||
]);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('documents: group recipients via drag and drop and ungroup', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await runGroupingFlow(surface);
|
||||
});
|
||||
|
||||
test('documents: reordered group cards can still be dragged', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { root } = surface;
|
||||
|
||||
await setRecipientEmail(root, 0, 'alice@example.com');
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, 'bob@example.com');
|
||||
|
||||
await toggleSigningOrder(root, true);
|
||||
await expect(getRecipientStepCards(root)).toHaveCount(2);
|
||||
|
||||
// Move bob's card into position 1.
|
||||
await moveGroupCardUp(root, 1);
|
||||
|
||||
await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue('bob@example.com');
|
||||
await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue('alice@example.com');
|
||||
|
||||
// Regression: after a reorder, the card moved into position 2 must still
|
||||
// be draggable — positional drag-and-drop ids used to go stale on mounted
|
||||
// cards, silently killing their drag handles. Prove it by completing a
|
||||
// merge with the repositioned card.
|
||||
await dragGroupCardOntoCard(root, 1, 0);
|
||||
|
||||
await expect(root.getByText('2 recipients · any order')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('templates: group recipients via drag and drop and ungroup', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
await runGroupingFlow(surface);
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
clickAddMyselfButton,
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
dragRecipientRowToGap,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientNameInputs,
|
||||
getRecipientRemoveButtons,
|
||||
getRecipientStepCards,
|
||||
getSigningOrderInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
setSigningOrderValue,
|
||||
type TEnvelopeEditorSurface,
|
||||
toggleAllowDictateSigners,
|
||||
toggleSigningOrder,
|
||||
@@ -112,71 +112,46 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<Recipi
|
||||
await setRecipientRole(surface.root, 1, 'Needs to approve');
|
||||
await setRecipientRole(surface.root, 2, 'Receives copy');
|
||||
|
||||
// The role selects must reflect the change immediately, without requiring a
|
||||
// navigation or reload (regression: leaf controllers going stale after a
|
||||
// root-level signers array update).
|
||||
await assertRecipientRole(surface.root, 1, 'Needs to approve');
|
||||
await assertRecipientRole(surface.root, 2, 'Receives copy');
|
||||
|
||||
await getRecipientRemoveButtons(surface.root).nth(2).click();
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
|
||||
await toggleSigningOrder(surface.root, true);
|
||||
await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
|
||||
|
||||
// Reordering is drag-only. Pointer-emulated drags are unreliable inside the
|
||||
// embedded authoring surface (its inner scroll container auto-scrolls and
|
||||
// cancels the emulated drag), so the drag-swap is exercised on the native
|
||||
// surfaces only — the same component drives all surfaces.
|
||||
const shouldSwapViaDrag = !surface.isEmbedded;
|
||||
|
||||
if (shouldSwapViaDrag) {
|
||||
// Let the debounced autosave from the edits above land before dragging —
|
||||
// the editor re-rendering mid-drag would cancel the drag.
|
||||
await surface.root.waitForTimeout(1500);
|
||||
|
||||
// Drag the first recipient's row into the gap after the last group,
|
||||
// swapping the two.
|
||||
await dragRecipientRowToGap(surface.root, 0, 2);
|
||||
}
|
||||
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
|
||||
await setSigningOrderValue(surface.root, 0, 2);
|
||||
|
||||
await toggleAllowDictateSigners(surface.root, true);
|
||||
|
||||
await navigateToAddFieldsAndBack(surface.root);
|
||||
|
||||
const [firstRecipient, secondRecipient] = shouldSwapViaDrag
|
||||
? [TEST_RECIPIENT_VALUES.secondRecipient, primaryRecipient]
|
||||
: [primaryRecipient, TEST_RECIPIENT_VALUES.secondRecipient];
|
||||
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(firstRecipient.email);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(secondRecipient.email);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.email);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
|
||||
|
||||
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(firstRecipient.name);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(secondRecipient.name);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.name);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
|
||||
|
||||
await assertRecipientRole(surface.root, 0, shouldSwapViaDrag ? 'Needs to approve' : 'Needs to sign');
|
||||
await assertRecipientRole(surface.root, 1, shouldSwapViaDrag ? 'Needs to sign' : 'Needs to approve');
|
||||
await assertRecipientRole(surface.root, 0, 'Needs to approve');
|
||||
await assertRecipientRole(surface.root, 1, 'Needs to sign');
|
||||
|
||||
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(surface.root.getByText('Group 1', { exact: true })).toBeVisible();
|
||||
await expect(surface.root.getByText('Group 2', { exact: true })).toBeVisible();
|
||||
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
|
||||
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
|
||||
|
||||
return {
|
||||
externalId,
|
||||
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
|
||||
expectedRecipientsBySigningOrder: [
|
||||
{
|
||||
email: firstRecipient.email,
|
||||
name: firstRecipient.name,
|
||||
role: shouldSwapViaDrag ? RecipientRole.APPROVER : RecipientRole.SIGNER,
|
||||
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||
role: RecipientRole.APPROVER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: secondRecipient.email,
|
||||
name: secondRecipient.name,
|
||||
role: shouldSwapViaDrag ? RecipientRole.SIGNER : RecipientRole.APPROVER,
|
||||
email: primaryRecipient.email,
|
||||
name: primaryRecipient.name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Opens the app command menu via the keyboard shortcut.
|
||||
*
|
||||
* Retries the shortcut until the menu appears since the keypress is a no-op
|
||||
* when it happens before the page has hydrated.
|
||||
*
|
||||
* @param placeholder The search input placeholder to wait for, which differs
|
||||
* between admin and non-admin users.
|
||||
*/
|
||||
export const openCommandMenu = async (page: Page, placeholder: string) => {
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('Meta+K');
|
||||
await expect(page.getByPlaceholder(placeholder).first()).toBeVisible({ timeout: 1_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
};
|
||||
@@ -1,116 +1,11 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
type DocumentStatusCounts = {
|
||||
inbox?: number;
|
||||
pending?: number;
|
||||
completed?: number;
|
||||
draft?: number;
|
||||
cancelled?: number;
|
||||
rejected?: number;
|
||||
expired?: number;
|
||||
all?: number;
|
||||
};
|
||||
|
||||
const STATUS_KEYS = {
|
||||
inbox: 'INBOX',
|
||||
pending: 'PENDING',
|
||||
completed: 'COMPLETED',
|
||||
draft: 'DRAFT',
|
||||
cancelled: 'CANCELLED',
|
||||
rejected: 'REJECTED',
|
||||
expired: 'EXPIRED',
|
||||
all: 'ALL',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check the counts for multiple document statuses in one go via the
|
||||
* visually hidden stats rendered alongside the status filter.
|
||||
*
|
||||
* When `all` is provided the status filter is also cleared and the
|
||||
* unfiltered table count (or empty state) is verified.
|
||||
*/
|
||||
export const checkDocumentCounts = async (page: Page, counts: DocumentStatusCounts) => {
|
||||
for (const [key, status] of Object.entries(STATUS_KEYS)) {
|
||||
const count = counts[key as keyof typeof STATUS_KEYS];
|
||||
|
||||
if (count === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await expect(page.getByTestId(`documents-status-count-${status}`)).toHaveText(count.toString());
|
||||
}
|
||||
|
||||
if (counts.all !== undefined) {
|
||||
await clearDocumentStatusFilter(page);
|
||||
|
||||
if (counts.all === 0) {
|
||||
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${counts.all}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Select a status in the documents status filter pill.
|
||||
*
|
||||
* No-op if the status is already selected, since selecting the active
|
||||
* option again would clear the filter.
|
||||
*/
|
||||
export const selectDocumentStatusFilter = async (page: Page, statusName: string) => {
|
||||
const currentStatus = new URL(page.url()).searchParams.get('status');
|
||||
|
||||
if (currentStatus === statusName.toUpperCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId('documents-table-status-filter').click();
|
||||
await page.getByRole('option', { name: statusName }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a sender in the documents sender filter pill.
|
||||
*
|
||||
* The sender filter is a multi select, so the popover stays open after
|
||||
* picking and is closed with Escape.
|
||||
*/
|
||||
export const toggleDocumentSenderFilter = async (page: Page, senderName: string) => {
|
||||
await page.getByTestId('documents-table-sender-filter').click();
|
||||
await page.getByRole('option', { name: senderName }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await page.keyboard.press('Escape');
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the documents status filter pill, returning to the "All" view.
|
||||
*/
|
||||
export const clearDocumentStatusFilter = async (page: Page) => {
|
||||
const currentStatus = new URL(page.url()).searchParams.get('status');
|
||||
|
||||
if (!currentStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId('documents-table-status-filter').click();
|
||||
await page.getByRole('option', { name: 'Clear' }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a status filter (or 'All' to clear it) and verify both the hidden
|
||||
* stats count and the resulting table.
|
||||
*
|
||||
* The count is not asserted against the stats for 'All', since tests use it
|
||||
* with search queries applied which only the table respects.
|
||||
*/
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
if (tabName === 'All') {
|
||||
await clearDocumentStatusFilter(page);
|
||||
} else {
|
||||
await expect(page.getByTestId(`documents-status-count-${tabName.toUpperCase()}`)).toHaveText(count.toString());
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
|
||||
await selectDocumentStatusFilter(page, tabName);
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-ed
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from './authentication';
|
||||
@@ -264,6 +264,8 @@ export const getRecipientRows = (root: Page) =>
|
||||
|
||||
export const getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]');
|
||||
|
||||
export const getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]');
|
||||
|
||||
export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => {
|
||||
await root.waitForTimeout(200);
|
||||
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
|
||||
@@ -333,208 +335,10 @@ export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) =>
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs a mouse-based drag from a drag handle onto a target element.
|
||||
*
|
||||
* `@hello-pangea/dnd` only starts a drag once the pointer travels a small
|
||||
* distance while pressed, and it hit-tests drop targets using the CENTRE of
|
||||
* the dragged element — not the cursor. Since drag handles sit at the edge of
|
||||
* wide rows/cards, the cursor destination is compensated so the dragged
|
||||
* element's centre lands on the target's centre.
|
||||
*/
|
||||
export const dragHandleToTarget = async (
|
||||
root: Page,
|
||||
handle: Locator,
|
||||
target: Locator,
|
||||
options: { activeClass: string },
|
||||
) => {
|
||||
const { activeClass } = options;
|
||||
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
|
||||
const handleBox = await handle.boundingBox();
|
||||
|
||||
if (!handleBox) {
|
||||
throw new Error('Unable to resolve drag handle position');
|
||||
}
|
||||
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
|
||||
await root.mouse.move(startX, startY);
|
||||
await root.mouse.down();
|
||||
|
||||
// Exceed the drag activation threshold, then wait for drag-dependent layout
|
||||
// (e.g. expanding gap drop-zones) to settle before resolving positions.
|
||||
const cursorX = startX + 8;
|
||||
const cursorY = startY;
|
||||
|
||||
await root.mouse.move(cursorX, cursorY, { steps: 2 });
|
||||
await root.waitForTimeout(300);
|
||||
|
||||
// The dragged element is the handle's draggable ancestor; while dragging it
|
||||
// is fixed-positioned and follows the cursor at a constant offset. Drop
|
||||
// targeting uses the dragged element's CENTRE, not the cursor, so the
|
||||
// cursor destination is compensated by that offset.
|
||||
const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]');
|
||||
const draggedBox = await draggedElement.boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
|
||||
if (!draggedBox || !targetBox) {
|
||||
await root.mouse.up();
|
||||
|
||||
throw new Error('Unable to resolve drag positions');
|
||||
}
|
||||
|
||||
const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX;
|
||||
const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY;
|
||||
|
||||
const hasBecomeActive = async () => {
|
||||
const className = await target.getAttribute('class');
|
||||
|
||||
return Boolean(className?.includes(activeClass));
|
||||
};
|
||||
|
||||
// The highlight class is rendered from the library's own drag state, so it
|
||||
// cannot disagree with where a drop will land — both phases below only drop
|
||||
// once the target reports the drag as over it AND that state survives a
|
||||
// short confirmation dwell (it can flicker while crossing a card's
|
||||
// reorder/combine boundary).
|
||||
//
|
||||
// The cursor is always clamped inside the viewport: moving outside the
|
||||
// window cancels the drag (pointercancel), and holding near the bottom edge
|
||||
// lets the library auto-scroll the target up to the cursor instead.
|
||||
const viewportHeight = root.viewportSize()?.height ?? 720;
|
||||
const maxCursorY = viewportHeight - 40;
|
||||
|
||||
const confirmAndDrop = async () => {
|
||||
if (!(await hasBecomeActive())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await root.waitForTimeout(150);
|
||||
|
||||
if (!(await hasBecomeActive())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await root.mouse.up();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
let hasDropped = false;
|
||||
|
||||
// Crawl-and-drop: approach from above and inch downward through the
|
||||
// corridor. Captured drop-target geometry can drift a few pixels from the
|
||||
// live layout for small targets, so a slow traversal is the reliable way to
|
||||
// hit them.
|
||||
const crawlX = targetBox.x + targetBox.width / 2 - itemOffsetX;
|
||||
const crawlStartY = Math.min(targetBox.y + targetBox.height / 2 - itemOffsetY - 140, maxCursorY);
|
||||
|
||||
await root.mouse.move(crawlX, crawlStartY, { steps: 15 });
|
||||
await root.waitForTimeout(150);
|
||||
|
||||
for (let step = 1; step <= 80; step += 1) {
|
||||
if (await confirmAndDrop()) {
|
||||
hasDropped = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await root.mouse.move(crawlX, Math.min(crawlStartY + step * 6, maxCursorY), { steps: 2 });
|
||||
await root.waitForTimeout(70);
|
||||
}
|
||||
|
||||
if (!hasDropped) {
|
||||
await root.mouse.up();
|
||||
}
|
||||
|
||||
await root.waitForTimeout(400);
|
||||
};
|
||||
|
||||
export const getRecipientStepCards = (root: Page) => root.locator('[data-testid="recipient-step-card"]');
|
||||
|
||||
export const getRecipientStepGaps = (root: Page) => root.locator('[data-testid="recipient-step-gap"]');
|
||||
|
||||
export const getStepDragHandles = (root: Page) => root.locator('[data-testid="step-drag-handle"]');
|
||||
|
||||
export const getRecipientRowDragHandles = (root: Page) => root.locator('[data-testid="recipient-row-drag-handle"]');
|
||||
|
||||
/**
|
||||
* Drags a whole group card onto another card, merging the two groups.
|
||||
*
|
||||
* Uses @hello-pangea/dnd's keyboard drag mode: mouse-emulated combines are
|
||||
* unreliable because approaching a card traverses its reorder edge, which
|
||||
* displaces the target away from the cursor. Keyboard drags step through
|
||||
* positions (including combine states) deterministically.
|
||||
*/
|
||||
export const dragGroupCardOntoCard = async (root: Page, sourceCardIndex: number, targetCardIndex: number) => {
|
||||
const handle = getStepDragHandles(root).nth(sourceCardIndex);
|
||||
const target = getRecipientStepCards(root).nth(targetCardIndex);
|
||||
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
await handle.focus();
|
||||
|
||||
// Lift.
|
||||
await root.keyboard.press('Space');
|
||||
await root.waitForTimeout(250);
|
||||
|
||||
const direction = targetCardIndex < sourceCardIndex ? 'ArrowUp' : 'ArrowDown';
|
||||
|
||||
for (let press = 0; press < 4; press += 1) {
|
||||
await root.keyboard.press(direction);
|
||||
await root.waitForTimeout(250);
|
||||
|
||||
const targetClassName = await target.getAttribute('class');
|
||||
|
||||
if (targetClassName?.includes('ring-primary')) {
|
||||
// Drop while the target reports the combine state.
|
||||
await root.keyboard.press('Space');
|
||||
await root.waitForTimeout(400);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await root.keyboard.press('Escape');
|
||||
|
||||
throw new Error('Combine drag did not reach the target card');
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves a group card one position up via keyboard drag. With combining
|
||||
* enabled, the first ArrowUp enters the combine state with the card above and
|
||||
* the second moves above it.
|
||||
*/
|
||||
export const moveGroupCardUp = async (root: Page, cardIndex: number) => {
|
||||
const handle = getStepDragHandles(root).nth(cardIndex);
|
||||
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
await handle.focus();
|
||||
|
||||
await root.keyboard.press('Space');
|
||||
await root.waitForTimeout(250);
|
||||
await root.keyboard.press('ArrowUp');
|
||||
await root.waitForTimeout(250);
|
||||
await root.keyboard.press('ArrowUp');
|
||||
await root.waitForTimeout(250);
|
||||
await root.keyboard.press('Space');
|
||||
await root.waitForTimeout(400);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drags a recipient row into a gap between group cards, extracting it into
|
||||
* its own standalone group at that position.
|
||||
*/
|
||||
export const dragRecipientRowToGap = async (root: Page, rowIndex: number, gapIndex: number) => {
|
||||
await dragHandleToTarget(
|
||||
root,
|
||||
getRecipientRowDragHandles(root).nth(rowIndex),
|
||||
getRecipientStepGaps(root).nth(gapIndex),
|
||||
// The marker class applied to a gap drop-zone while dragged over.
|
||||
{ activeClass: 'gap-active' },
|
||||
);
|
||||
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
|
||||
const input = getSigningOrderInputs(root).nth(index);
|
||||
await input.fill(value.toString());
|
||||
await input.blur();
|
||||
};
|
||||
|
||||
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -74,19 +73,8 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
await expect(page.locator('body')).toContainText('public-direct-template-title');
|
||||
await expect(page.locator('body')).toContainText('public-direct-template-description');
|
||||
|
||||
const directSignatureField = directTemplate.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await page.getByRole('link', { name: 'Sign' }).click();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } 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 { DocumentSigningOrder, DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
type SeededRecipient = Awaited<ReturnType<typeof seedPendingDocumentWithFullFields>>['recipients'][number];
|
||||
|
||||
const completeSigning = async (page: Page, recipient: SeededRecipient) => {
|
||||
const signUrl = `/sign/${recipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
for (const field of recipient.fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
};
|
||||
|
||||
const expectWaiting = async (page: Page, token: string) => {
|
||||
await page.goto(`/sign/${token}`);
|
||||
await page.waitForURL(`/sign/${token}/waiting`);
|
||||
};
|
||||
|
||||
test('[SIGNING_GROUPS]: group members sign in any order and gate the next step', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: signer1 } = await seedUser();
|
||||
const { user: signer2a } = await seedUser();
|
||||
const { user: signer2b } = await seedUser();
|
||||
const { user: signer3 } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [signer1, signer2a, signer2b, signer3],
|
||||
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 2 }, { signingOrder: 3 }],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [recipient1, recipient2a, recipient2b, recipient3] = recipients;
|
||||
|
||||
// While step 1 is pending, both group members and step 3 are blocked.
|
||||
await expectWaiting(page, recipient2a.token);
|
||||
await expectWaiting(page, recipient2b.token);
|
||||
await expectWaiting(page, recipient3.token);
|
||||
|
||||
await completeSigning(page, recipient1);
|
||||
|
||||
// The group is now active; step 3 is still blocked.
|
||||
await expectWaiting(page, recipient3.token);
|
||||
|
||||
// Sign with the SECOND group member first to prove any-order signing.
|
||||
await completeSigning(page, recipient2b);
|
||||
|
||||
// One group member remains — step 3 stays blocked.
|
||||
await expectWaiting(page, recipient3.token);
|
||||
|
||||
await completeSigning(page, recipient2a);
|
||||
|
||||
// The whole group is done — step 3 unlocks and completes the document.
|
||||
await completeSigning(page, recipient3);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return envelope.status;
|
||||
})
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
@@ -110,7 +110,7 @@ test.describe('Default Recipients', () => {
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a regular signer using the v2 editor
|
||||
await page.getByTestId('signer-email-input').first().fill('regular-signer@documenso.com');
|
||||
await page.getByTestId('signer-email-input').last().fill('regular-signer@documenso.com');
|
||||
await page
|
||||
.getByPlaceholder(/Recipient/)
|
||||
.first()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
@@ -20,13 +20,23 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 2, all: 5 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 5);
|
||||
|
||||
// Apply filter.
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -105,13 +115,23 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentCounts(page, { inbox: 2, pending: 3, completed: 3, draft: 3, all: 11 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 3);
|
||||
await checkDocumentTabCount(page, 'Completed', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 3);
|
||||
await checkDocumentTabCount(page, 'All', 11);
|
||||
|
||||
// Apply filter.
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -182,13 +202,23 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentCounts(page, { inbox: 3, pending: 2, completed: 2, draft: 2, all: 9 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 3);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 2);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 9);
|
||||
|
||||
// Apply filter.
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
});
|
||||
|
||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
@@ -243,7 +273,11 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 1, all: 4 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -282,7 +316,11 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 2, all: 4 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -321,7 +359,11 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 2, all: 4 });
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
|
||||
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
||||
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
|
||||
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
||||
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
|
||||
@@ -197,18 +197,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeDisabled();
|
||||
|
||||
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
@@ -246,37 +235,6 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
|
||||
|
||||
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
// Wait for the PDF and the Konva canvas overlay to be ready.
|
||||
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
|
||||
const canvas = page.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
|
||||
await signSignaturePad(page);
|
||||
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas bounding box not found');
|
||||
}
|
||||
|
||||
const x =
|
||||
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
|
||||
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
|
||||
const y =
|
||||
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
|
||||
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
|
||||
|
||||
await canvas.click({ position: { x, y } });
|
||||
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByLabel('Your Email')).not.toBeVisible();
|
||||
|
||||
@@ -308,16 +266,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
|
||||
|
||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
|
||||
const directSignatureField = template.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/sign/);
|
||||
@@ -351,13 +299,19 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
// The seeded direct template already includes a signature field for the direct recipient.
|
||||
const directSignatureField = template.fields[0];
|
||||
const directTemplateRecipient = template.recipients[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
if (!directTemplateRecipient) {
|
||||
throw new Error('Expected direct template recipient to exist');
|
||||
}
|
||||
|
||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: directTemplateRecipient.id,
|
||||
positionY: 10,
|
||||
});
|
||||
|
||||
const originalName = 'Signer 2';
|
||||
const originalSecondSignerEmail = seedTestEmail();
|
||||
|
||||
@@ -459,13 +413,19 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
// The seeded direct template already includes a signature field for the direct recipient.
|
||||
const directSignatureField = template.fields[0];
|
||||
const directTemplateRecipient = template.recipients[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
if (!directTemplateRecipient) {
|
||||
throw new Error('Expected direct template recipient to exist');
|
||||
}
|
||||
|
||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: directTemplateRecipient.id,
|
||||
positionY: 10,
|
||||
});
|
||||
|
||||
const originalName = 'Signer 2';
|
||||
const originalSecondSignerEmail = seedTestEmail();
|
||||
|
||||
@@ -561,48 +521,3 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
||||
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
|
||||
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: 'V1 invalid direct template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
createDirectRecipientSignatureField: false,
|
||||
});
|
||||
|
||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||
|
||||
// The signing flow must not render.
|
||||
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: 'V2 invalid direct template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
createDirectRecipientSignatureField: false,
|
||||
});
|
||||
|
||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||
|
||||
// The signing flow (PDF canvas) must not render.
|
||||
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: options.envelopeId },
|
||||
});
|
||||
|
||||
return await prisma.field.create({
|
||||
data: {
|
||||
envelopeId: options.envelopeId,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
recipientId: options.recipientId,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// seedTemplate creates one SIGNER recipient and no fields.
|
||||
await seedTemplate({
|
||||
title: 'Template missing signature fields',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||
|
||||
// Enable distribution so the document is sent on creation.
|
||||
await page.locator('#distributeDocument').click();
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Missing signature fields');
|
||||
await expectToastTextToBeVisible(
|
||||
page,
|
||||
'The document could not be sent because some signers do not have a signature field',
|
||||
);
|
||||
});
|
||||
|
||||
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedTemplate({
|
||||
title: 'Template with signature fields',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: template.recipients[0].id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||
|
||||
await page.locator('#distributeDocument').click();
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const envelopeId = page.url().split('/').pop()?.split('?')[0];
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: envelopeId },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
@@ -18,9 +18,9 @@
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/node": "^20",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"tsx": "^4.23.1",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.23.1"
|
||||
"pngjs": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.1.3"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user