mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 14:52:23 +10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
720333dc6b | ||
|
|
66f8dc7e8a | ||
|
|
d42254ff52 | ||
|
|
914e325486 | ||
|
|
05f646b326 | ||
|
|
0099dd672a | ||
|
|
871c2a6f0e | ||
|
|
9bab1cddb3 | ||
|
|
3e0c1c444a | ||
|
|
6a8fe6f1ad | ||
|
|
779de01fe8 | ||
|
|
283c6d274b | ||
|
|
688ef2fdf3 | ||
|
|
a5e37af3e8 | ||
|
|
617f8cc204 | ||
|
|
1bd09480e6 | ||
|
|
962cffc9f5 | ||
|
|
820b319474 | ||
|
|
797f5c0e79 | ||
|
|
fc95ee9ead | ||
|
|
d6cf3fec4b | ||
|
|
f0ab7c112e | ||
|
|
8bfcec8ee6 |
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,56 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-justification
|
|
||||||
description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title
|
|
||||||
license: MIT
|
|
||||||
compatibility: opencode
|
|
||||||
metadata:
|
|
||||||
audience: agents
|
|
||||||
workflow: decision-making
|
|
||||||
---
|
|
||||||
|
|
||||||
## What I do
|
|
||||||
|
|
||||||
I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets:
|
|
||||||
|
|
||||||
- A unique three-word identifier (e.g., `swift-emerald-river`)
|
|
||||||
- Frontmatter with the current date and formatted title
|
|
||||||
- Content you provide
|
|
||||||
|
|
||||||
## How to use
|
|
||||||
|
|
||||||
Run the script with a slug and content:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx scripts/create-justification.ts "decision-name" "Justification content here"
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use heredoc for multi-line content:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx scripts/create-justification.ts "decision-name" << HEREDOC
|
|
||||||
Multi-line
|
|
||||||
justification content
|
|
||||||
goes here
|
|
||||||
HEREDOC
|
|
||||||
```
|
|
||||||
|
|
||||||
## File format
|
|
||||||
|
|
||||||
Files are created as: `{three-word-id}-{slug}.md`
|
|
||||||
|
|
||||||
Example: `swift-emerald-river-decision-name.md`
|
|
||||||
|
|
||||||
The file includes frontmatter:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
date: 2026-01-13
|
|
||||||
title: Decision Name
|
|
||||||
---
|
|
||||||
|
|
||||||
Your content here
|
|
||||||
```
|
|
||||||
|
|
||||||
## When to use me
|
|
||||||
|
|
||||||
Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-plan
|
|
||||||
description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title
|
|
||||||
license: MIT
|
|
||||||
compatibility: opencode
|
|
||||||
metadata:
|
|
||||||
audience: agents
|
|
||||||
workflow: planning
|
|
||||||
---
|
|
||||||
|
|
||||||
## What I do
|
|
||||||
|
|
||||||
I help you create new plan files in the `.agents/plans/` directory. Each plan file gets:
|
|
||||||
|
|
||||||
- A unique three-word identifier (e.g., `happy-blue-moon`)
|
|
||||||
- Frontmatter with the current date and formatted title
|
|
||||||
- Content you provide
|
|
||||||
|
|
||||||
## How to use
|
|
||||||
|
|
||||||
Run the script with a slug and content:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx scripts/create-plan.ts "feature-name" "Plan content here"
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use heredoc for multi-line content:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx scripts/create-plan.ts "feature-name" << HEREDOC
|
|
||||||
Multi-line
|
|
||||||
plan content
|
|
||||||
goes here
|
|
||||||
HEREDOC
|
|
||||||
```
|
|
||||||
|
|
||||||
## File format
|
|
||||||
|
|
||||||
Files are created as: `{three-word-id}-{slug}.md`
|
|
||||||
|
|
||||||
Example: `happy-blue-moon-feature-name.md`
|
|
||||||
|
|
||||||
The file includes frontmatter:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
date: 2026-01-13
|
|
||||||
title: Feature Name
|
|
||||||
---
|
|
||||||
|
|
||||||
Your content here
|
|
||||||
```
|
|
||||||
|
|
||||||
## When to use me
|
|
||||||
|
|
||||||
Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-scratch
|
|
||||||
description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title
|
|
||||||
license: MIT
|
|
||||||
compatibility: opencode
|
|
||||||
metadata:
|
|
||||||
audience: agents
|
|
||||||
workflow: exploration
|
|
||||||
---
|
|
||||||
|
|
||||||
## What I do
|
|
||||||
|
|
||||||
I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets:
|
|
||||||
|
|
||||||
- A unique three-word identifier (e.g., `calm-teal-cloud`)
|
|
||||||
- Frontmatter with the current date and formatted title
|
|
||||||
- Content you provide
|
|
||||||
|
|
||||||
## How to use
|
|
||||||
|
|
||||||
Run the script with a slug and content:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx scripts/create-scratch.ts "note-name" "Scratch content here"
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use heredoc for multi-line content:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx scripts/create-scratch.ts "note-name" << HEREDOC
|
|
||||||
Multi-line
|
|
||||||
scratch content
|
|
||||||
goes here
|
|
||||||
HEREDOC
|
|
||||||
```
|
|
||||||
|
|
||||||
## File format
|
|
||||||
|
|
||||||
Files are created as: `{three-word-id}-{slug}.md`
|
|
||||||
|
|
||||||
Example: `calm-teal-cloud-note-name.md`
|
|
||||||
|
|
||||||
The file includes frontmatter:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
date: 2026-01-13
|
|
||||||
title: Note Name
|
|
||||||
---
|
|
||||||
|
|
||||||
Your content here
|
|
||||||
```
|
|
||||||
|
|
||||||
## When to use me
|
|
||||||
|
|
||||||
Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
|
||||||
@@ -15,6 +15,7 @@ jobs:
|
|||||||
build_app:
|
build_app:
|
||||||
name: Build App
|
name: Build App
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -32,6 +33,7 @@ jobs:
|
|||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ jobs:
|
|||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
label-when-assigned:
|
label-when-assigned:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Label issue
|
- name: Label issue
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
label_issues:
|
label_issues:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v4
|
- uses: actions/labeler@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ jobs:
|
|||||||
build_and_publish_platform_containers:
|
build_and_publish_platform_containers:
|
||||||
name: Build and publish platform containers
|
name: Build and publish platform containers
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -78,6 +79,7 @@ jobs:
|
|||||||
create_and_publish_manifest:
|
create_and_publish_manifest:
|
||||||
name: Create and publish manifest
|
name: Create and publish manifest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
needs: build_and_publish_platform_containers
|
needs: build_and_publish_platform_containers
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ jobs:
|
|||||||
validate-pr:
|
validate-pr:
|
||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
id: lint_pr_title
|
id: lint_pr_title
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ jobs:
|
|||||||
pull_translations:
|
pull_translations:
|
||||||
name: Force pull translations
|
name: Force pull translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ jobs:
|
|||||||
pull_translations:
|
pull_translations:
|
||||||
name: Pull translations
|
name: Pull translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ jobs:
|
|||||||
extract_translations:
|
extract_translations:
|
||||||
name: Extract and upload translations
|
name: Extract and upload translations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
legacy-peer-deps = true
|
legacy-peer-deps = true
|
||||||
prefer-dedupe = true
|
prefer-dedupe = true
|
||||||
# min-release-age = 7
|
min-release-age = 7
|
||||||
|
|||||||
@@ -28,35 +28,62 @@ Each document contains one or more PDF files, a list of recipients, and the fiel
|
|||||||
|
|
||||||
A document object contains the following properties:
|
A document object contains the following properties:
|
||||||
|
|
||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
| --------------- | -------------- | -------------------------------------------------------------- |
|
| ------------------- | -------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
||||||
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) |
|
||||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` |
|
| `internalVersion` | number | Internal envelope schema version |
|
||||||
| `title` | string | Document title |
|
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
||||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
|
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
|
||||||
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
| `title` | string | Document title |
|
||||||
| `externalId` | string \| null | Your custom identifier for the document |
|
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
|
||||||
| `createdAt` | string | ISO 8601 timestamp |
|
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
||||||
| `updatedAt` | string | ISO 8601 timestamp |
|
| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) |
|
||||||
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
|
| `externalId` | string \| null | Your custom identifier for the document |
|
||||||
| `deletedAt` | string \| null | Timestamp if soft-deleted |
|
| `userId` | number | ID of the user who owns the document |
|
||||||
| `recipients` | array | List of recipients and their signing status |
|
| `teamId` | number | ID of the team the document belongs to |
|
||||||
| `fields` | array | Signature and form fields on the document |
|
| `folderId` | string \| null | ID of the folder containing the document |
|
||||||
| `envelopeItems` | array | PDF files attached to the document |
|
| `templateId` | number \| null | Legacy ID of the template this document was created from |
|
||||||
| `documentMeta` | object | Email settings, redirect URL, signing options |
|
| `authOptions` | object \| null | Access and action authentication requirements |
|
||||||
|
| `formValues` | object \| null | Pre-filled form values |
|
||||||
|
| `publicTitle` | string | Public title shown on profile and direct-link pages |
|
||||||
|
| `publicDescription` | string | Public description shown on profile and direct-link pages |
|
||||||
|
| `createdAt` | string | ISO 8601 timestamp |
|
||||||
|
| `updatedAt` | string | ISO 8601 timestamp |
|
||||||
|
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
|
||||||
|
| `deletedAt` | string \| null | Timestamp if soft-deleted |
|
||||||
|
| `recipients` | array | List of recipients and their signing status |
|
||||||
|
| `fields` | array | Signature and form fields on the document |
|
||||||
|
| `envelopeItems` | array | PDF files attached to the document |
|
||||||
|
| `directLink` | object \| null | Direct-link signing configuration (`id`, `token`, `enabled`, `directTemplateRecipientId`) |
|
||||||
|
| `team` | object | Owning team (`id`, `url`) |
|
||||||
|
| `user` | object | Document owner (`id`, `name`, `email`) |
|
||||||
|
| `documentMeta` | object | Email settings, redirect URL, signing options |
|
||||||
|
|
||||||
|
Documents created through the API have `source: "DOCUMENT"` — there is no separate `API` source value. To tag documents created by your integration, set `externalId` when creating them.
|
||||||
|
|
||||||
### Example Document Object
|
### Example Document Object
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "envelope_abc123xyz",
|
"id": "envelope_abc123xyz",
|
||||||
|
"secondaryId": "document_123",
|
||||||
|
"internalVersion": 2,
|
||||||
"type": "DOCUMENT",
|
"type": "DOCUMENT",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"source": "API",
|
"source": "DOCUMENT",
|
||||||
"visibility": "EVERYONE",
|
"visibility": "EVERYONE",
|
||||||
|
"templateType": "PRIVATE",
|
||||||
"title": "Service Agreement",
|
"title": "Service Agreement",
|
||||||
"externalId": "contract-2025-001",
|
"externalId": "contract-2025-001",
|
||||||
|
"userId": 1,
|
||||||
|
"teamId": 1,
|
||||||
|
"folderId": null,
|
||||||
|
"templateId": null,
|
||||||
|
"authOptions": null,
|
||||||
|
"formValues": null,
|
||||||
|
"publicTitle": "",
|
||||||
|
"publicDescription": "",
|
||||||
"createdAt": "2025-01-15T10:30:00.000Z",
|
"createdAt": "2025-01-15T10:30:00.000Z",
|
||||||
"updatedAt": "2025-01-15T10:35:00.000Z",
|
"updatedAt": "2025-01-15T10:35:00.000Z",
|
||||||
"completedAt": null,
|
"completedAt": null,
|
||||||
@@ -73,23 +100,41 @@ A document object contains the following properties:
|
|||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"id": "field_123",
|
"id": 123,
|
||||||
|
"secondaryId": "field_abc123",
|
||||||
"type": "SIGNATURE",
|
"type": "SIGNATURE",
|
||||||
|
"recipientId": 1,
|
||||||
|
"envelopeId": "envelope_abc123xyz",
|
||||||
|
"envelopeItemId": "envelope_item_xyz",
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"positionX": 10,
|
"positionX": "10",
|
||||||
"positionY": 80,
|
"positionY": "80",
|
||||||
"width": 30,
|
"width": "30",
|
||||||
"height": 5,
|
"height": "5",
|
||||||
"recipientId": 1
|
"customText": "",
|
||||||
|
"inserted": false,
|
||||||
|
"fieldMeta": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"envelopeItems": [
|
"envelopeItems": [
|
||||||
{
|
{
|
||||||
"id": "envelope_item_xyz",
|
"id": "envelope_item_xyz",
|
||||||
|
"envelopeId": "envelope_abc123xyz",
|
||||||
|
"documentDataId": "doc_data_abc123",
|
||||||
"title": "contract.pdf",
|
"title": "contract.pdf",
|
||||||
"order": 1
|
"order": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"directLink": null,
|
||||||
|
"team": {
|
||||||
|
"id": 1,
|
||||||
|
"url": "your-team"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Jane Smith",
|
||||||
|
"email": "jane@example.com"
|
||||||
|
},
|
||||||
"documentMeta": {
|
"documentMeta": {
|
||||||
"subject": "Please sign this document",
|
"subject": "Please sign this document",
|
||||||
"message": "Hi, please review and sign this agreement.",
|
"message": "Hi, please review and sign this agreement.",
|
||||||
@@ -99,6 +144,8 @@ A document object contains the following properties:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Field position and size values are stored as decimals and serialized as strings in API responses.
|
||||||
|
|
||||||
## List Documents
|
## List Documents
|
||||||
|
|
||||||
Retrieve a paginated list of documents.
|
Retrieve a paginated list of documents.
|
||||||
@@ -114,7 +161,7 @@ GET /envelope
|
|||||||
| `page` | integer | Page number (default: 1) |
|
| `page` | integer | Page number (default: 1) |
|
||||||
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
||||||
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
|
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
|
||||||
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` |
|
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
|
||||||
| `source` | string | Filter by creation source |
|
| `source` | string | Filter by creation source |
|
||||||
| `folderId` | string | Filter by folder ID |
|
| `folderId` | string | Filter by folder ID |
|
||||||
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
||||||
@@ -154,8 +201,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, pagination } = await response.json();
|
const { data, count } = await response.json();
|
||||||
console.log(`Found ${pagination.totalItems} documents`);
|
console.log(`Found ${count} documents`);
|
||||||
|
|
||||||
// Filter by status
|
// Filter by status
|
||||||
const pendingResponse = await fetch(
|
const pendingResponse = await fetch(
|
||||||
@@ -197,12 +244,10 @@ const pendingDocs = await pendingResponse.json();
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pagination": {
|
"count": 42,
|
||||||
"page": 1,
|
"currentPage": 1,
|
||||||
"perPage": 10,
|
"perPage": 10,
|
||||||
"totalPages": 5,
|
"totalPages": 5
|
||||||
"totalItems": 42
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -628,6 +673,72 @@ The response includes signing URLs for each recipient:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Cancel Document
|
||||||
|
|
||||||
|
Cancel a pending document. This changes its status from `PENDING` to `CANCELLED`.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /envelope/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ------------ | ------ | -------- | ----------------------------------- |
|
||||||
|
| `envelopeId` | string | Yes | Document ID |
|
||||||
|
| `reason` | string | No | Reason for cancelling the document |
|
||||||
|
|
||||||
|
### Code Examples
|
||||||
|
|
||||||
|
<Tabs items={['curl', 'TypeScript']}>
|
||||||
|
<Tab value="curl">
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \
|
||||||
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"envelopeId": "envelope_abc123",
|
||||||
|
"reason": "The agreement is no longer needed."
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab value="TypeScript">
|
||||||
|
```typescript
|
||||||
|
const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
envelopeId: 'envelope_abc123',
|
||||||
|
reason: 'The agreement is no longer needed.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { success } = await response.json();
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- Only documents in `PENDING` status can be cancelled. Other statuses return `400`.
|
||||||
|
- Cancellation is not idempotent. Cancelling the same document again returns `400`.
|
||||||
|
- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`.
|
||||||
|
- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook.
|
||||||
|
- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Delete Document
|
## Delete Document
|
||||||
|
|
||||||
Delete a document. Completed documents cannot be deleted.
|
Delete a document. Completed documents cannot be deleted.
|
||||||
@@ -670,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
|
|||||||
|
|
||||||
const { success } = await response.json();
|
const { success } = await response.json();
|
||||||
|
|
||||||
````
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -680,7 +791,7 @@ const { success } = await response.json();
|
|||||||
{
|
{
|
||||||
"success": true
|
"success": true
|
||||||
}
|
}
|
||||||
````
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -694,9 +805,11 @@ POST /envelope/get-many
|
|||||||
|
|
||||||
### Request Body
|
### Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ------------- | ----- | -------- | --------------------- |
|
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
|
||||||
| `envelopeIds` | array | Yes | Array of document IDs |
|
| `ids` | object | Yes | ID selector containing `type` and `ids` |
|
||||||
|
| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` |
|
||||||
|
| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` |
|
||||||
|
|
||||||
### Code Examples
|
### Code Examples
|
||||||
|
|
||||||
@@ -707,12 +820,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
|
|||||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
"ids": {
|
||||||
|
"type": "envelopeId",
|
||||||
|
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||||
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value="TypeScript">
|
<Tab value="TypeScript">
|
||||||
```typescript
|
```typescript
|
||||||
|
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
|
||||||
|
|
||||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -720,16 +838,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
|
ids: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
ids: requestedIds,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const documents = await response.json();
|
const { data } = await response.json();
|
||||||
|
|
||||||
````
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "envelope_abc123",
|
||||||
|
"type": "DOCUMENT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"title": "Service Agreement"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Document Statuses
|
## Document Statuses
|
||||||
@@ -740,6 +878,7 @@ const documents = await response.json();
|
|||||||
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
|
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
|
||||||
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
||||||
| `REJECTED` | A recipient rejected the document. |
|
| `REJECTED` | A recipient rejected the document. |
|
||||||
|
| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. |
|
||||||
|
|
||||||
### Status Transitions
|
### Status Transitions
|
||||||
|
|
||||||
@@ -747,11 +886,13 @@ const documents = await response.json();
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
DRAFT --> PENDING --> COMPLETED
|
DRAFT --> PENDING --> COMPLETED
|
||||||
PENDING --> REJECTED
|
PENDING --> REJECTED
|
||||||
|
PENDING --> CANCELLED
|
||||||
```
|
```
|
||||||
|
|
||||||
- **DRAFT to PENDING**: Call the distribute endpoint
|
- **DRAFT to PENDING**: Call the distribute endpoint
|
||||||
- **PENDING to COMPLETED**: All recipients complete their signing
|
- **PENDING to COMPLETED**: All recipients complete their signing
|
||||||
- **PENDING to REJECTED**: A recipient rejects the document
|
- **PENDING to REJECTED**: A recipient rejects the document
|
||||||
|
- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
||||||
@@ -773,8 +914,8 @@ flowchart LR
|
|||||||
| Parameter | Values | Description |
|
| Parameter | Values | Description |
|
||||||
| ---------- | ------------------------------------------- | ------------------------- |
|
| ---------- | ------------------------------------------- | ------------------------- |
|
||||||
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
||||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
|
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
|
||||||
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
|
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
|
||||||
| `folderId` | string | Filter by folder |
|
| `folderId` | string | Filter by folder |
|
||||||
|
|
||||||
### Sorting
|
### Sorting
|
||||||
@@ -800,10 +941,10 @@ async function getAllPendingDocuments() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, pagination } = await response.json();
|
const { data, currentPage, totalPages } = await response.json();
|
||||||
documents.push(...data);
|
documents.push(...data);
|
||||||
|
|
||||||
hasMore = page < pagination.totalPages;
|
hasMore = currentPage < totalPages;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
|||||||
| ------------------------------------------------- | ----------------------------------------------------- |
|
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||||
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
||||||
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
| `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/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) |
|
||||||
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
|
| `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/create/beta` | `POST /api/v2/envelope/create` |
|
||||||
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
||||||
@@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
|||||||
| ------------------------------------- | ------------------------------------------------ |
|
| ------------------------------------- | ------------------------------------------------ |
|
||||||
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||||
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
| `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/get-many` | `POST /api/v2/envelope/get-many` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) |
|
||||||
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
| `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/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
|||||||
|
|
||||||
## HTTP Rate Limits
|
## HTTP Rate Limits
|
||||||
|
|
||||||
|
The rate limit applies to:
|
||||||
|
|
||||||
|
- `/api/v1/*`
|
||||||
|
- `/api/v2/*`
|
||||||
|
- `/api/v2-beta/*`
|
||||||
|
|
||||||
**Limit:** 1000 requests per minute per IP address
|
**Limit:** 1000 requests per minute per IP address
|
||||||
**Response:** 429 Too Many Requests
|
**Response:** 429 Too Many Requests
|
||||||
|
|
||||||
@@ -19,7 +25,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
|||||||
this value, in which case you can be rate-limited before reaching the global limit.
|
this value, in which case you can be rate-limited before reaching the global limit.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
### Rate Limit Response
|
### Global per-IP 429 Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="warn">
|
### Rate Limit Headers
|
||||||
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
|
|
||||||
seconds before retrying.
|
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
|
||||||
</Callout>
|
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
|
||||||
|
and carry no rate limit headers:
|
||||||
|
|
||||||
|
| Header | Description |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
|
||||||
|
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
|
||||||
|
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
|
||||||
|
|
||||||
|
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
|
||||||
|
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
|
||||||
|
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
|
||||||
|
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
|
||||||
|
|
||||||
## Resource Limits
|
## Resource Limits
|
||||||
|
|
||||||
@@ -44,24 +62,55 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
|
|||||||
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
||||||
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
|
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
|
||||||
|
|
||||||
### Error Response
|
### Organisation Limit 429 Responses
|
||||||
|
|
||||||
When you exceed a resource limit:
|
Organisation windowed limits and organisation monthly quotas produce 429 responses whose body
|
||||||
|
shape depends on the API version, and neither matches the global per-IP limiter's
|
||||||
|
`{ "error": "..." }` body.
|
||||||
|
|
||||||
|
On `/api/v1/*`, the body contains only a message:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": "You have reached your document limit for this month. Please upgrade your plan.",
|
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||||
"code": "LIMIT_EXCEEDED",
|
|
||||||
"statusCode": 400
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
|
||||||
|
"code": "TOO_MANY_REQUESTS",
|
||||||
|
"data": {
|
||||||
|
"code": "TOO_MANY_REQUESTS",
|
||||||
|
"httpStatus": 429,
|
||||||
|
"appError": {
|
||||||
|
"code": "TOO_MANY_REQUESTS",
|
||||||
|
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
|
||||||
|
their own window. Monthly quota responses carry no quota-specific rate limit headers or
|
||||||
|
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
|
||||||
|
|
||||||
## Error Codes
|
## Error Codes
|
||||||
|
|
||||||
| Code | Status | Description |
|
| Code | Status | Description |
|
||||||
| ------------------- | ------ | ----------------------------- |
|
| ------------------- | ------ | ------------------------------------------------------------------ |
|
||||||
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
|
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
|
||||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
||||||
|
|
||||||
|
There are three sources of `TOO_MANY_REQUESTS` responses:
|
||||||
|
|
||||||
|
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
|
||||||
|
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
|
||||||
|
3. Organisation monthly quotas for the same three counters. Every authenticated API request
|
||||||
|
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
|
||||||
|
exhausted — not just envelope-related ones.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1000,9 +1000,12 @@ async function fetchWithRetry(
|
|||||||
// Retry on rate limit
|
// Retry on rate limit
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const retryAfter = response.headers.get('Retry-After');
|
const retryAfter = response.headers.get('Retry-After');
|
||||||
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
|
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
|
||||||
|
const delay = retryAfter
|
||||||
|
? parseInt(retryAfter) * 1000
|
||||||
|
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
||||||
console.log(`Rate limited, waiting ${delay}ms...`);
|
console.log(`Rate limited, waiting ${delay}ms...`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -10,13 +10,12 @@
|
|||||||
"postinstall": "fumadocs-mdx"
|
"postinstall": "fumadocs-mdx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"fumadocs-core": "16.14.3",
|
||||||
"fumadocs-core": "16.5.0",
|
"fumadocs-mdx": "15.2.3",
|
||||||
"fumadocs-mdx": "14.2.6",
|
"fumadocs-ui": "16.14.3",
|
||||||
"fumadocs-ui": "16.5.0",
|
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"next": "16.2.6",
|
"next": "16.3.0",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next": "16.2.6"
|
"next": "16.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "18.3.27",
|
"@types/react": "^19.2.17",
|
||||||
"typescript": "5.6.2"
|
"typescript": "5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-9
@@ -3,7 +3,7 @@
|
|||||||
/* Inter Variable Fonts */
|
/* Inter Variable Fonts */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Inter";
|
font-family: "Inter";
|
||||||
src: url("/fonts/inter-variablefont_opsz,wght.ttf") format("truetype-variations");
|
src: url("/fonts/inter-variablefont_opsz,wght.woff2") format("woff2");
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
/* Inter Italic Variable Fonts */
|
/* Inter Italic Variable Fonts */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Inter";
|
font-family: "Inter";
|
||||||
src: url("/fonts/inter-italic-variablefont_opsz,wght.ttf") format("truetype-variations");
|
src: url("/fonts/inter-italic-variablefont_opsz,wght.woff2") format("woff2");
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
/* Caveat Variable Font */
|
/* Caveat Variable Font */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Caveat";
|
font-family: "Caveat";
|
||||||
src: url("/fonts/caveat-variablefont_wght.ttf") format("truetype-variations");
|
src: url("/fonts/caveat-variablefont_wght.woff2") format("woff2");
|
||||||
font-weight: 400 600;
|
font-weight: 400 600;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans";
|
font-family: "Noto Sans";
|
||||||
src: url("/fonts/noto-sans.ttf") format("truetype-variations");
|
src: url("/fonts/noto-sans.woff2") format("woff2");
|
||||||
font-weight: 100 900;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
/* Korean noto sans */
|
/* Korean noto sans */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Korean";
|
font-family: "Noto Sans Korean";
|
||||||
src: url("/fonts/noto-sans-korean.ttf") format("truetype-variations");
|
src: url("/fonts/noto-sans-korean.woff2") format("woff2");
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
/* Japanese noto sans */
|
/* Japanese noto sans */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Japanese";
|
font-family: "Noto Sans Japanese";
|
||||||
src: url("/fonts/noto-sans-japanese.ttf") format("truetype-variations");
|
src: url("/fonts/noto-sans-japanese.woff2") format("woff2");
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -56,13 +56,19 @@
|
|||||||
/* Chinese noto sans */
|
/* Chinese noto sans */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Chinese";
|
font-family: "Noto Sans Chinese";
|
||||||
src: url("/fonts/noto-sans-chinese.ttf") format("truetype-variations");
|
src: url("/fonts/noto-sans-chinese.woff2") format("woff2");
|
||||||
font-weight: 100 900;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html {
|
||||||
|
@apply antialiased;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-synthesis: style;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: "Inter";
|
--font-sans: "Inter";
|
||||||
--font-signature: "Caveat";
|
--font-signature: "Caveat";
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export type DocumentPreferencesResetDialogProps = {
|
|||||||
onReset: () => Promise<void>;
|
onReset: () => Promise<void>;
|
||||||
showAiFeatures?: boolean;
|
showAiFeatures?: boolean;
|
||||||
showDocumentVisibility?: boolean;
|
showDocumentVisibility?: boolean;
|
||||||
showIncludeSenderDetails?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPreferencesResetDialog = ({
|
export const DocumentPreferencesResetDialog = ({
|
||||||
@@ -26,7 +25,6 @@ export const DocumentPreferencesResetDialog = ({
|
|||||||
onReset,
|
onReset,
|
||||||
showAiFeatures = false,
|
showAiFeatures = false,
|
||||||
showDocumentVisibility = false,
|
showDocumentVisibility = false,
|
||||||
showIncludeSenderDetails = false,
|
|
||||||
}: DocumentPreferencesResetDialogProps) => {
|
}: DocumentPreferencesResetDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isResetting, setIsResetting] = useState(false);
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
@@ -92,29 +90,12 @@ export const DocumentPreferencesResetDialog = ({
|
|||||||
<li>
|
<li>
|
||||||
<Trans>Default signature settings</Trans>
|
<Trans>Default signature settings</Trans>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<Trans>Default recipients</Trans>
|
<Trans>Default recipients</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Trans>Delegate document ownership</Trans>
|
<Trans>Delegate document ownership</Trans>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Trans>Default envelope expiration</Trans>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Trans>Default signing reminders</Trans>
|
|
||||||
</li>
|
|
||||||
{showAiFeatures && (
|
{showAiFeatures && (
|
||||||
<li>
|
<li>
|
||||||
<Trans>AI features</Trans>
|
<Trans>AI features</Trans>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
||||||
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="https://documen.so/enterprise-cta"
|
to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
|
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,28 +17,25 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
export type TeamEmailDeleteDialogProps = {
|
export type TeamEmailDeleteDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
team: Prisma.TeamGetPayload<{
|
team: Pick<Team, 'id' | 'avatarImageId' | 'name'>;
|
||||||
include: {
|
teamEmail: Pick<TeamEmail, 'email' | 'name'> | null;
|
||||||
teamEmail: true;
|
emailVerification: Pick<TeamEmailVerification, 'email' | 'name' | 'expiresAt'> | null;
|
||||||
emailVerification: {
|
|
||||||
select: {
|
|
||||||
expiresAt: true;
|
|
||||||
name: true;
|
|
||||||
email: true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => {
|
export const TeamEmailDeleteDialog = ({
|
||||||
|
trigger,
|
||||||
|
teamName,
|
||||||
|
team,
|
||||||
|
teamEmail,
|
||||||
|
emailVerification,
|
||||||
|
}: TeamEmailDeleteDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@@ -83,11 +80,11 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onRemove = async () => {
|
const onRemove = async () => {
|
||||||
if (team.teamEmail) {
|
if (teamEmail) {
|
||||||
await deleteTeamEmail({ teamId: team.id });
|
await deleteTeamEmail({ teamId: team.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (team.emailVerification) {
|
if (emailVerification) {
|
||||||
await deleteTeamEmailVerification({ teamId: team.id });
|
await deleteTeamEmailVerification({ teamId: team.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +118,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
|
|||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarClass="h-12 w-12"
|
avatarClass="h-12 w-12"
|
||||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||||
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
|
avatarFallback={extractInitials((teamEmail?.name || emailVerification?.name) ?? '')}
|
||||||
primaryText={
|
primaryText={
|
||||||
<span className="font-semibold text-foreground/80 text-sm">
|
<span className="font-semibold text-foreground/80 text-sm">
|
||||||
{team.teamEmail?.name || team.emailVerification?.name}
|
{teamEmail?.name || emailVerification?.name}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
secondaryText={<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>}
|
secondaryText={<span className="text-sm">{teamEmail?.email || emailVerification?.email}</span>}
|
||||||
/>
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { useRevalidator } from 'react-router';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
export type TeamEmailUpdateDialogProps = {
|
export type TeamEmailUpdateDialogProps = {
|
||||||
teamEmail: TeamEmail;
|
teamId: number;
|
||||||
|
teamEmail: Pick<TeamEmail, 'email' | 'name'>;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
|
|||||||
|
|
||||||
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||||
|
|
||||||
export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
|
export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -53,7 +54,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
|
|||||||
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await updateTeamEmail({
|
await updateTeamEmail({
|
||||||
teamId: teamEmail.teamId,
|
teamId,
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||||
@@ -32,6 +33,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
|||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
@@ -162,9 +164,12 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(toastMessage.title),
|
||||||
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
|
description: _(toastMessage.description),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useState } from 'react';
|
|||||||
import { match, P } from 'ts-pattern';
|
import { match, P } from 'ts-pattern';
|
||||||
|
|
||||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||||
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
||||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
||||||
@@ -141,9 +142,12 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
onDocumentError?.();
|
onDocumentError?.();
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(toastMessage.title),
|
||||||
description: _(msg`Failed to complete the document. Please try again.`),
|
description: _(toastMessage.description),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
|||||||
import { useCspNonce } from '~/utils/nonce';
|
import { useCspNonce } from '~/utils/nonce';
|
||||||
|
|
||||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
import { InheritableField } from './inheritable-field';
|
||||||
|
|
||||||
const ZBrandingPreferencesFormSchema = z.object({
|
const ZBrandingPreferencesFormSchema = z.object({
|
||||||
brandingEnabled: z.boolean().nullable(),
|
brandingEnabled: z.boolean().nullable(),
|
||||||
@@ -210,11 +211,13 @@ export function BrandingPreferencesForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="brandingEnabled"
|
name="brandingEnabled"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Enable Custom Branding</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Enable Custom Branding</Trans>}
|
||||||
|
testId="branding-enabled"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
@@ -252,7 +255,7 @@ export function BrandingPreferencesForm({
|
|||||||
<Trans>Enable custom branding for all documents in this organisation</Trans>
|
<Trans>Enable custom branding for all documents in this organisation</Trans>
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -263,11 +266,13 @@ export function BrandingPreferencesForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="brandingLogo"
|
name="brandingLogo"
|
||||||
render={({ field: { value: _value, onChange, ...field } }) => (
|
render={({ field: { value: _value, onChange, ...field } }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Branding Logo</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={!previewUrl}
|
||||||
|
label={<Trans>Branding Logo</Trans>}
|
||||||
|
testId="branding-logo"
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
|
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
@@ -345,7 +350,7 @@ export function BrandingPreferencesForm({
|
|||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -353,11 +358,13 @@ export function BrandingPreferencesForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="brandingUrl"
|
name="brandingUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Brand Website</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={!field.value}
|
||||||
|
label={<Trans>Brand Website</Trans>}
|
||||||
|
testId="branding-url"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="url" placeholder="https://example.com" disabled={!isBrandingEnabled} {...field} />
|
<Input type="url" placeholder="https://example.com" disabled={!isBrandingEnabled} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -372,7 +379,7 @@ export function BrandingPreferencesForm({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -380,11 +387,13 @@ export function BrandingPreferencesForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="brandingCompanyDetails"
|
name="brandingCompanyDetails"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Brand Details</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={!field.value}
|
||||||
|
label={<Trans>Brand Details</Trans>}
|
||||||
|
testId="branding-company-details"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={t`Enter your brand details`}
|
placeholder={t`Enter your brand details`}
|
||||||
@@ -404,7 +413,7 @@ export function BrandingPreferencesForm({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Form, FormControl, FormDescription, FormField } from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { TeamGlobalSettings } from '@prisma/client';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
import { InheritableField } from './inheritable-field';
|
||||||
|
|
||||||
|
const ZCertificatePreferencesFormSchema = z.object({
|
||||||
|
includeSigningCertificate: z.boolean().nullable(),
|
||||||
|
includeAuditLog: z.boolean().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCertificatePreferencesFormSchema = z.infer<typeof ZCertificatePreferencesFormSchema>;
|
||||||
|
|
||||||
|
type SettingsSubset = Pick<TeamGlobalSettings, 'includeSigningCertificate' | 'includeAuditLog'>;
|
||||||
|
|
||||||
|
export type CertificatePreferencesFormProps = {
|
||||||
|
settings: SettingsSubset;
|
||||||
|
canInherit: boolean;
|
||||||
|
onFormSubmit: (data: TCertificatePreferencesFormSchema) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CertificatePreferencesForm = ({ settings, canInherit, onFormSubmit }: CertificatePreferencesFormProps) => {
|
||||||
|
const form = useForm<TCertificatePreferencesFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
includeSigningCertificate: settings.includeSigningCertificate,
|
||||||
|
includeAuditLog: settings.includeAuditLog,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZCertificatePreferencesFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await onFormSubmit(data);
|
||||||
|
} catch {
|
||||||
|
// The page handler surfaces its own error toast. Keep the form dirty so
|
||||||
|
// the save bar stays visible and the user can retry.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleFormSubmit}>
|
||||||
|
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="includeSigningCertificate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<InheritableField
|
||||||
|
className="flex-1"
|
||||||
|
canInherit={canInherit}
|
||||||
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Include the Signing Certificate in the Document</Trans>}
|
||||||
|
testId="include-signing-certificate"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value === null ? '-1' : field.value.toString()}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="bg-background text-muted-foreground"
|
||||||
|
data-testid="include-signing-certificate-trigger"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">
|
||||||
|
<Trans>Yes</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value="false">
|
||||||
|
<Trans>No</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{canInherit && (
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls whether the signing certificate will be included in the document when it is downloaded. The
|
||||||
|
signing certificate can still be downloaded from the logs page separately.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</InheritableField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="includeAuditLog"
|
||||||
|
render={({ field }) => (
|
||||||
|
<InheritableField
|
||||||
|
className="flex-1"
|
||||||
|
canInherit={canInherit}
|
||||||
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Include the Audit Logs in the Document</Trans>}
|
||||||
|
testId="include-audit-log"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value === null ? '-1' : field.value.toString()}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="bg-background text-muted-foreground"
|
||||||
|
data-testid="include-audit-log-trigger"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">
|
||||||
|
<Trans>Yes</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value="false">
|
||||||
|
<Trans>No</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{canInherit && (
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls whether the audit logs will be included in the document when it is downloaded. The audit
|
||||||
|
logs can still be downloaded from the logs page separately.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</InheritableField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormStickySaveBar
|
||||||
|
isDirty={form.formState.isDirty}
|
||||||
|
isSubmitting={form.formState.isSubmitting}
|
||||||
|
onReset={() => form.reset()}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import {
|
|
||||||
type TEnvelopeExpirationPeriod,
|
|
||||||
ZEnvelopeExpirationPeriod,
|
|
||||||
} from '@documenso/lib/constants/envelope-expiration';
|
|
||||||
import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
|
||||||
import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
||||||
@@ -16,36 +12,24 @@ import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documens
|
|||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
|
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
|
||||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
|
||||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
import {
|
import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg, t } from '@lingui/core/macro';
|
import { msg, t } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
|
import { DocumentVisibility, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
|
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
||||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
import { InheritableField } from './inheritable-field';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
||||||
@@ -56,15 +40,10 @@ export type TDocumentPreferencesFormSchema = {
|
|||||||
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
|
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
|
||||||
documentTimezone: string | null;
|
documentTimezone: string | null;
|
||||||
documentDateFormat: TDocumentMetaDateFormat | null;
|
documentDateFormat: TDocumentMetaDateFormat | null;
|
||||||
includeSenderDetails: boolean | null;
|
|
||||||
includeSigningCertificate: boolean | null;
|
|
||||||
includeAuditLog: boolean | null;
|
|
||||||
signatureTypes: DocumentSignatureType[];
|
signatureTypes: DocumentSignatureType[];
|
||||||
defaultRecipients: TDefaultRecipients | null;
|
defaultRecipients: TDefaultRecipients | null;
|
||||||
delegateDocumentOwnership: boolean | null;
|
delegateDocumentOwnership: boolean | null;
|
||||||
aiFeaturesEnabled: boolean | null;
|
aiFeaturesEnabled: boolean | null;
|
||||||
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
|
||||||
reminderSettings: TEnvelopeReminderSettings | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsSubset = Pick<
|
type SettingsSubset = Pick<
|
||||||
@@ -73,23 +52,17 @@ type SettingsSubset = Pick<
|
|||||||
| 'documentLanguage'
|
| 'documentLanguage'
|
||||||
| 'documentTimezone'
|
| 'documentTimezone'
|
||||||
| 'documentDateFormat'
|
| 'documentDateFormat'
|
||||||
| 'includeSenderDetails'
|
|
||||||
| 'includeSigningCertificate'
|
|
||||||
| 'includeAuditLog'
|
|
||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
| 'uploadSignatureEnabled'
|
| 'uploadSignatureEnabled'
|
||||||
| 'drawSignatureEnabled'
|
| 'drawSignatureEnabled'
|
||||||
| 'defaultRecipients'
|
| 'defaultRecipients'
|
||||||
| 'delegateDocumentOwnership'
|
| 'delegateDocumentOwnership'
|
||||||
| 'aiFeaturesEnabled'
|
| 'aiFeaturesEnabled'
|
||||||
| 'envelopeExpirationPeriod'
|
|
||||||
| 'reminderSettings'
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type DocumentPreferencesFormProps = {
|
export type DocumentPreferencesFormProps = {
|
||||||
settings: SettingsSubset;
|
settings: SettingsSubset;
|
||||||
canInherit: boolean;
|
canInherit: boolean;
|
||||||
isAiFeaturesConfigured?: boolean;
|
|
||||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,50 +74,34 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
|
|||||||
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
||||||
documentTimezone: settings.documentTimezone,
|
documentTimezone: settings.documentTimezone,
|
||||||
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
|
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
|
||||||
includeSenderDetails: settings.includeSenderDetails,
|
|
||||||
includeSigningCertificate: settings.includeSigningCertificate,
|
|
||||||
includeAuditLog: settings.includeAuditLog,
|
|
||||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||||
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
||||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
|
||||||
reminderSettings: settings.reminderSettings ?? null,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPreferencesForm = ({
|
export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }: DocumentPreferencesFormProps) => {
|
||||||
settings,
|
|
||||||
onFormSubmit,
|
|
||||||
canInherit,
|
|
||||||
isAiFeaturesConfigured = false,
|
|
||||||
}: DocumentPreferencesFormProps) => {
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { user, organisations } = useSession();
|
const { organisations } = useSession();
|
||||||
const currentOrganisation = useCurrentOrganisation();
|
const currentOrganisation = useCurrentOrganisation();
|
||||||
const optionalTeam = useOptionalCurrentTeam();
|
const optionalTeam = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
const isAiFeaturesConfigured = IS_AI_FEATURES_CONFIGURED();
|
||||||
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
|
||||||
|
|
||||||
const placeholderEmail = user.email ?? 'user@example.com';
|
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||||
|
|
||||||
const ZDocumentPreferencesFormSchema = z.object({
|
const ZDocumentPreferencesFormSchema = z.object({
|
||||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||||
documentTimezone: z.string().nullable(),
|
documentTimezone: z.string().nullable(),
|
||||||
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
|
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
|
||||||
includeSenderDetails: z.boolean().nullable(),
|
|
||||||
includeSigningCertificate: z.boolean().nullable(),
|
|
||||||
includeAuditLog: z.boolean().nullable(),
|
|
||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
||||||
delegateDocumentOwnership: z.boolean().nullable(),
|
delegateDocumentOwnership: z.boolean().nullable(),
|
||||||
aiFeaturesEnabled: z.boolean().nullable(),
|
aiFeaturesEnabled: z.boolean().nullable(),
|
||||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
|
||||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultValues = getDocumentPreferencesFormValues(settings);
|
const defaultValues = getDocumentPreferencesFormValues(settings);
|
||||||
@@ -189,17 +146,19 @@ export const DocumentPreferencesForm = ({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={handleFormSubmit}>
|
<form onSubmit={handleFormSubmit}>
|
||||||
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||||
{!isPersonalLayoutMode && (
|
{!isPersonalLayoutMode && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="documentVisibility"
|
name="documentVisibility"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Default Document Visibility</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Default Document Visibility</Trans>}
|
||||||
|
testId="document-visibility"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
@@ -236,7 +195,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Controls the default visibility of an uploaded document.</Trans>
|
<Trans>Controls the default visibility of an uploaded document.</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -245,11 +204,13 @@ export const DocumentPreferencesForm = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="documentLanguage"
|
name="documentLanguage"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Default Document Language</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Default Document Language</Trans>}
|
||||||
|
testId="document-language"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
@@ -283,7 +244,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
communications with the recipients.
|
communications with the recipients.
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -291,11 +252,12 @@ export const DocumentPreferencesForm = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="documentDateFormat"
|
name="documentDateFormat"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<InheritableField
|
||||||
<FormLabel>
|
canInherit={canInherit}
|
||||||
<Trans>Default Date Format</Trans>
|
isInherited={field.value === null}
|
||||||
</FormLabel>
|
label={<Trans>Default Date Format</Trans>}
|
||||||
|
testId="document-date-format"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
value={field.value === null ? '-1' : field.value}
|
value={field.value === null ? '-1' : field.value}
|
||||||
@@ -322,7 +284,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -330,11 +292,12 @@ export const DocumentPreferencesForm = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="documentTimezone"
|
name="documentTimezone"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<InheritableField
|
||||||
<FormLabel>
|
canInherit={canInherit}
|
||||||
<Trans>Default Time Zone</Trans>
|
isInherited={field.value === null}
|
||||||
</FormLabel>
|
label={<Trans>Default Time Zone</Trans>}
|
||||||
|
testId="document-timezone"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Combobox
|
<Combobox
|
||||||
triggerPlaceholder={canInherit ? t`Inherit from organisation` : t`Local timezone`}
|
triggerPlaceholder={canInherit ? t`Inherit from organisation` : t`Local timezone`}
|
||||||
@@ -347,7 +310,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -355,12 +318,18 @@ export const DocumentPreferencesForm = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="signatureTypes"
|
name="signatureTypes"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel className="flex flex-row items-center">
|
className="flex-1"
|
||||||
<Trans>Default Signature Settings</Trans>
|
canInherit={canInherit}
|
||||||
<DocumentSignatureSettingsTooltip />
|
isInherited={canInherit && (field.value === null || field.value.length === 0)}
|
||||||
</FormLabel>
|
label={
|
||||||
|
<span className="flex flex-row items-center">
|
||||||
|
<Trans>Default Signature Settings</Trans>
|
||||||
|
<DocumentSignatureSettingsTooltip />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
testId="signature-types"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||||
@@ -383,179 +352,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
<Trans>Controls which signatures are allowed to be used when signing a document.</Trans>
|
<Trans>Controls which signatures are allowed to be used when signing a document.</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
)}
|
)}
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!isPersonalLayoutMode && !isPersonalOrganisation && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="includeSenderDetails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Send on Behalf of Team</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '-1' : field.value.toString()}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="bg-background text-muted-foreground"
|
|
||||||
data-testid="include-sender-details-trigger"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true">
|
|
||||||
<Trans>Yes</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value="false">
|
|
||||||
<Trans>No</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
{canInherit && (
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>Inherit from organisation</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<div className="font-medium text-muted-foreground text-xs">
|
|
||||||
<Trans>Preview</Trans>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
|
||||||
{field.value ? (
|
|
||||||
<Trans>
|
|
||||||
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>"Team Name" has invited you to sign "example document".</Trans>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls the formatting of the message that will be sent when inviting a recipient to sign a
|
|
||||||
document. If a custom message has been provided while configuring the document, it will be used
|
|
||||||
instead.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="includeSigningCertificate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Include the Signing Certificate in the Document</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '-1' : field.value.toString()}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="bg-background text-muted-foreground"
|
|
||||||
data-testid="include-signing-certificate-trigger"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true">
|
|
||||||
<Trans>Yes</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value="false">
|
|
||||||
<Trans>No</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
{canInherit && (
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>Inherit from organisation</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls whether the signing certificate will be included in the document when it is downloaded. The
|
|
||||||
signing certificate can still be downloaded from the logs page separately.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="includeAuditLog"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Include the Audit Logs in the Document</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '-1' : field.value.toString()}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true">
|
|
||||||
<Trans>Yes</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value="false">
|
|
||||||
<Trans>No</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
{canInherit && (
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>Inherit from organisation</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls whether the audit logs will be included in the document when it is downloaded. The audit
|
|
||||||
logs can still be downloaded from the logs page separately.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -566,11 +363,13 @@ export const DocumentPreferencesForm = ({
|
|||||||
const recipients = field.value ?? [];
|
const recipients = field.value ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Default Recipients</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Default Recipients</Trans>}
|
||||||
|
testId="default-recipients"
|
||||||
|
>
|
||||||
{canInherit && (
|
{canInherit && (
|
||||||
<Select
|
<Select
|
||||||
value={field.value === null ? '-1' : '0'}
|
value={field.value === null ? '-1' : '0'}
|
||||||
@@ -638,7 +437,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Recipients that will be automatically added to new documents.</Trans>
|
<Trans>Recipients that will be automatically added to new documents.</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -647,11 +446,13 @@ export const DocumentPreferencesForm = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="delegateDocumentOwnership"
|
name="delegateDocumentOwnership"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Delegate Document Ownership</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Delegate Document Ownership</Trans>}
|
||||||
|
testId="delegate-document-ownership"
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value === null ? '-1' : field.value.toString()}
|
value={field.value === null ? '-1' : field.value.toString()}
|
||||||
@@ -681,65 +482,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Enable team API tokens to delegate document ownership to another team member.</Trans>
|
<Trans>Enable team API tokens to delegate document ownership to another team member.</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="envelopeExpirationPeriod"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Default Envelope Expiration</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<ExpirationPeriodPicker
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls how long recipients have to complete signing before the document expires. After expiration,
|
|
||||||
recipients can no longer sign the document.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="reminderSettings"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Default Signing Reminders</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<ReminderSettingsPicker
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls when and how often reminder emails are sent to recipients who have not yet completed
|
|
||||||
signing.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -748,11 +491,13 @@ export const DocumentPreferencesForm = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="aiFeaturesEnabled"
|
name="aiFeaturesEnabled"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>AI Features</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>AI Features</Trans>}
|
||||||
|
testId="ai-features-enabled"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
@@ -790,7 +535,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
prefer European regions where available.
|
prefer European regions where available.
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -806,7 +551,6 @@ export const DocumentPreferencesForm = ({
|
|||||||
onReset={handleResetToDefaults}
|
onReset={handleResetToDefaults}
|
||||||
showAiFeatures={isAiFeaturesConfigured}
|
showAiFeatures={isAiFeaturesConfigured}
|
||||||
showDocumentVisibility={!isPersonalLayoutMode}
|
showDocumentVisibility={!isPersonalLayoutMode}
|
||||||
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
|
|
||||||
/>
|
/>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
|
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
|
||||||
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||||
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -17,22 +19,27 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { TeamGlobalSettings } from '@prisma/client';
|
import { OrganisationType, type TeamGlobalSettings } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
import { InheritableField } from './inheritable-field';
|
||||||
|
|
||||||
const ZEmailPreferencesFormSchema = z.object({
|
const ZEmailPreferencesFormSchema = z.object({
|
||||||
emailId: z.string().nullable(),
|
emailId: z.string().nullable(),
|
||||||
emailReplyTo: zEmail().nullable(),
|
emailReplyTo: zEmail().nullable(),
|
||||||
// emailReplyToName: z.string(),
|
// emailReplyToName: z.string(),
|
||||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
|
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
|
||||||
|
includeSenderDetails: z.boolean().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TEmailPreferencesFormSchema = z.infer<typeof ZEmailPreferencesFormSchema>;
|
export type TEmailPreferencesFormSchema = z.infer<typeof ZEmailPreferencesFormSchema>;
|
||||||
|
|
||||||
type SettingsSubset = Pick<TeamGlobalSettings, 'emailId' | 'emailReplyTo' | 'emailDocumentSettings'>;
|
type SettingsSubset = Pick<
|
||||||
|
TeamGlobalSettings,
|
||||||
|
'emailId' | 'emailReplyTo' | 'emailDocumentSettings' | 'includeSenderDetails'
|
||||||
|
>;
|
||||||
|
|
||||||
export type EmailPreferencesFormProps = {
|
export type EmailPreferencesFormProps = {
|
||||||
settings: SettingsSubset;
|
settings: SettingsSubset;
|
||||||
@@ -41,14 +48,20 @@ export type EmailPreferencesFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: EmailPreferencesFormProps) => {
|
export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: EmailPreferencesFormProps) => {
|
||||||
|
const { user } = useSession();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
|
||||||
|
|
||||||
|
const placeholderEmail = user.email ?? 'user@example.com';
|
||||||
|
|
||||||
const form = useForm<TEmailPreferencesFormSchema>({
|
const form = useForm<TEmailPreferencesFormSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
emailId: settings.emailId,
|
emailId: settings.emailId,
|
||||||
emailReplyTo: settings.emailReplyTo,
|
emailReplyTo: settings.emailReplyTo,
|
||||||
// emailReplyToName: settings.emailReplyToName,
|
// emailReplyToName: settings.emailReplyToName,
|
||||||
emailDocumentSettings: settings.emailDocumentSettings,
|
emailDocumentSettings: settings.emailDocumentSettings,
|
||||||
|
includeSenderDetails: settings.includeSenderDetails,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZEmailPreferencesFormSchema),
|
resolver: zodResolver(ZEmailPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
@@ -75,7 +88,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={handleFormSubmit}>
|
<form onSubmit={handleFormSubmit}>
|
||||||
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||||
{organisation.organisationClaim.flags.emailDomains && (
|
{organisation.organisationClaim.flags.emailDomains && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -122,10 +135,12 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="emailReplyTo"
|
name="emailReplyTo"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<InheritableField
|
||||||
<FormLabel>
|
canInherit={canInherit}
|
||||||
<Trans>Reply to email</Trans>
|
isInherited={field.value === null}
|
||||||
</FormLabel>
|
label={<Trans>Reply to email</Trans>}
|
||||||
|
testId="email-reply-to"
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
@@ -146,7 +161,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -170,10 +185,13 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="emailDocumentSettings"
|
name="emailDocumentSettings"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<InheritableField
|
||||||
<FormLabel>
|
className="flex-1"
|
||||||
<Trans>Default Email Settings</Trans>
|
canInherit={canInherit}
|
||||||
</FormLabel>
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Default Email Settings</Trans>}
|
||||||
|
testId="email-document-settings"
|
||||||
|
>
|
||||||
{canInherit && (
|
{canInherit && (
|
||||||
<Select
|
<Select
|
||||||
value={field.value === null ? 'INHERIT' : 'CONTROLLED'}
|
value={field.value === null ? 'INHERIT' : 'CONTROLLED'}
|
||||||
@@ -212,10 +230,83 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
|
|||||||
settings will not affect existing documents or templates.
|
settings will not affect existing documents or templates.
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</InheritableField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!isPersonalOrganisation && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="includeSenderDetails"
|
||||||
|
render={({ field }) => (
|
||||||
|
<InheritableField
|
||||||
|
className="flex-1"
|
||||||
|
canInherit={canInherit}
|
||||||
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Send on Behalf of Team</Trans>}
|
||||||
|
testId="include-sender-details"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value === null ? '-1' : field.value.toString()}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="bg-background text-muted-foreground"
|
||||||
|
data-testid="include-sender-details-trigger"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">
|
||||||
|
<Trans>Yes</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value="false">
|
||||||
|
<Trans>No</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{canInherit && (
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="font-medium text-muted-foreground text-xs">
|
||||||
|
<Trans>Preview</Trans>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
||||||
|
{field.value ? (
|
||||||
|
<Trans>
|
||||||
|
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>"Team Name" has invited you to sign "example document".</Trans>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls the formatting of the message that will be sent when inviting a recipient to sign a
|
||||||
|
document. If a custom message has been provided while configuring the document, it will be used
|
||||||
|
instead.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</InheritableField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormStickySaveBar
|
<FormStickySaveBar
|
||||||
isDirty={form.formState.isDirty}
|
isDirty={form.formState.isDirty}
|
||||||
isSubmitting={form.formState.isSubmitting}
|
isSubmitting={form.formState.isSubmitting}
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ import { AnimatePresence, motion } from 'framer-motion';
|
|||||||
import { AlertTriangleIcon } from 'lucide-react';
|
import { AlertTriangleIcon } from 'lucide-react';
|
||||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const getScrollParent = (node: HTMLElement): HTMLElement | null => {
|
||||||
|
let current = node.parentElement;
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
const { overflowY } = getComputedStyle(current);
|
||||||
|
|
||||||
|
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export type FormStickySaveBarProps = {
|
export type FormStickySaveBarProps = {
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
@@ -43,14 +59,18 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefau
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The sentinel sits at the bar's resting position (the end of the form). While the
|
// The sentinel sits at the bar's resting position (the end of the form). While the
|
||||||
// bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of
|
// bar is stuck to the bottom of the scroll container the sentinel is scrolled past
|
||||||
// view); once you reach the form's end it comes into view and the bar settles.
|
// (out of view); once you reach the form's end it comes into view and the bar settles.
|
||||||
|
//
|
||||||
|
// Observe relative to the actual scroll container (not always the viewport) so a
|
||||||
|
// banner shifting the page can't desync the detection from the sticky bar — both
|
||||||
|
// then share the same reference box.
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
setIsStuck(!entry.isIntersecting);
|
setIsStuck(!entry.isIntersecting);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: null,
|
root: getScrollParent(sentinel),
|
||||||
rootMargin: '0px 0px -24px 0px',
|
rootMargin: '0px 0px -24px 0px',
|
||||||
threshold: 0,
|
threshold: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type InheritableFieldProps = {
|
||||||
|
isInherited: boolean;
|
||||||
|
canInherit: boolean;
|
||||||
|
label: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
testId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InheritableField = ({
|
||||||
|
isInherited,
|
||||||
|
canInherit,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
testId,
|
||||||
|
}: InheritableFieldProps) => {
|
||||||
|
if (!canInherit) {
|
||||||
|
return (
|
||||||
|
<FormItem className={className}>
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
{children}
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className={className} data-testid={testId ? `inheritable-${testId}` : undefined}>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
{label}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded px-1.5 py-0.5 font-bold text-[9px] uppercase tracking-wide',
|
||||||
|
isInherited
|
||||||
|
? 'bg-muted text-muted-foreground'
|
||||||
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
|
||||||
|
)}
|
||||||
|
data-testid={testId ? `${testId}-status` : undefined}
|
||||||
|
>
|
||||||
|
{isInherited ? <Trans>Inherited</Trans> : <Trans>Override</Trans>}
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
{children}
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -56,7 +56,7 @@ export const OrganisationUpdateForm = () => {
|
|||||||
await refreshSession();
|
await refreshSession();
|
||||||
|
|
||||||
if (url !== organisation.url) {
|
if (url !== organisation.url) {
|
||||||
await navigate(`/o/${url}/settings`);
|
await navigate(`/o/${url}/settings/general`);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
type TEnvelopeExpirationPeriod,
|
||||||
|
ZEnvelopeExpirationPeriod,
|
||||||
|
} from '@documenso/lib/constants/envelope-expiration';
|
||||||
|
import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||||
|
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||||
|
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import type { TeamGlobalSettings } from '@prisma/client';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
import { InheritableField } from './inheritable-field';
|
||||||
|
|
||||||
|
const ZReminderPreferencesFormSchema = z.object({
|
||||||
|
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
||||||
|
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TReminderPreferencesFormSchema = {
|
||||||
|
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
||||||
|
reminderSettings: TEnvelopeReminderSettings | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SettingsSubset = Pick<TeamGlobalSettings, 'envelopeExpirationPeriod' | 'reminderSettings'>;
|
||||||
|
|
||||||
|
export type ReminderPreferencesFormProps = {
|
||||||
|
settings: SettingsSubset;
|
||||||
|
canInherit: boolean;
|
||||||
|
onFormSubmit: (data: TReminderPreferencesFormSchema) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReminderPreferencesForm = ({ settings, canInherit, onFormSubmit }: ReminderPreferencesFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const form = useForm<TReminderPreferencesFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||||
|
reminderSettings: settings.reminderSettings ?? null,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZReminderPreferencesFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await onFormSubmit(data);
|
||||||
|
} catch {
|
||||||
|
// The page handler surfaces its own error toast. Keep the form dirty so
|
||||||
|
// the save bar stays visible and the user can retry.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleFormSubmit}>
|
||||||
|
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="envelopeExpirationPeriod"
|
||||||
|
render={({ field }) => (
|
||||||
|
<InheritableField
|
||||||
|
className="flex-1"
|
||||||
|
canInherit={canInherit}
|
||||||
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Default Envelope Expiration</Trans>}
|
||||||
|
testId="envelope-expiration-period"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<ExpirationPeriodPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls how long recipients have to complete signing before the document expires. After expiration,
|
||||||
|
recipients can no longer sign the document.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</InheritableField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="reminderSettings"
|
||||||
|
render={({ field }) => (
|
||||||
|
<InheritableField
|
||||||
|
className="flex-1"
|
||||||
|
canInherit={canInherit}
|
||||||
|
isInherited={field.value === null}
|
||||||
|
label={<Trans>Default Signing Reminders</Trans>}
|
||||||
|
testId="reminder-settings"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<ReminderSettingsPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls when and how often reminder emails are sent to recipients who have not yet completed
|
||||||
|
signing.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</InheritableField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormStickySaveBar
|
||||||
|
isDirty={form.formState.isDirty}
|
||||||
|
isSubmitting={form.formState.isSubmitting}
|
||||||
|
onReset={() => form.reset()}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
@@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
|||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_REDIRECT_PATH = '/';
|
const LOGIN_REDIRECT_PATH = formatPath('/');
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: zEmail().min(1),
|
email: zEmail().min(1),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (url !== teamUrl) {
|
if (url !== teamUrl) {
|
||||||
await navigate(`/t/${url}/settings`);
|
await navigate(`/t/${url}/settings/general`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DOCUMENTS_PAGE_SHORTCUT,
|
DOCUMENTS_PAGE_SHORTCUT,
|
||||||
@@ -18,7 +19,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { keepPreviousData } from '@tanstack/react-query';
|
import { keepPreviousData } from '@tanstack/react-query';
|
||||||
import { commandScore } from 'cmdk/dist/command-score';
|
import { defaultFilter as commandScore } from 'cmdk';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@@ -287,7 +288,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
|
|||||||
{
|
{
|
||||||
id: 'settings-main',
|
id: 'settings-main',
|
||||||
label: msg`Settings`,
|
label: msg`Settings`,
|
||||||
path: '/settings',
|
path: '/settings/profile',
|
||||||
icon: SettingsIcon,
|
icon: SettingsIcon,
|
||||||
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
|
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
|
||||||
},
|
},
|
||||||
@@ -389,7 +390,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
|
|||||||
|
|
||||||
const formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `≥${count}` : `${count}`);
|
const formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `≥${count}` : `${count}`);
|
||||||
|
|
||||||
const goToSettings = useCallback(() => push('/settings'), [push]);
|
const goToSettings = useCallback(() => push('/settings/profile'), [push]);
|
||||||
const goToDocuments = useCallback(() => {
|
const goToDocuments = useCallback(() => {
|
||||||
if (teamUrl) {
|
if (teamUrl) {
|
||||||
push(`/t/${teamUrl}/documents?status=ALL`);
|
push(`/t/${teamUrl}/documents?status=ALL`);
|
||||||
@@ -862,7 +863,7 @@ const PromptLanguageCommands = ({
|
|||||||
|
|
||||||
formData.append('lang', lang);
|
formData.append('lang', lang);
|
||||||
|
|
||||||
const response = await fetch('/api/locale', {
|
const response = await fetch(formatPath('/api/locale'), {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { getRootHref } from '@documenso/lib/utils/params';
|
import { getRootHref } from '@documenso/lib/utils/params';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -14,16 +12,16 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
|||||||
import { AppCommandMenu } from './app-command-menu';
|
import { AppCommandMenu } from './app-command-menu';
|
||||||
import { AppNavDesktop } from './app-nav-desktop';
|
import { AppNavDesktop } from './app-nav-desktop';
|
||||||
import { AppNavMobile } from './app-nav-mobile';
|
import { AppNavMobile } from './app-nav-mobile';
|
||||||
import { MenuSwitcher } from './menu-switcher';
|
|
||||||
import { OrgMenuSwitcher } from './org-menu-switcher';
|
import { OrgMenuSwitcher } from './org-menu-switcher';
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLDivElement>;
|
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
/** Span the full viewport width instead of the centered max-w-screen-xl container. */
|
||||||
|
fullWidth?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
export const Header = ({ className, fullWidth = false, ...props }: HeaderProps) => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const { organisations } = useSession();
|
|
||||||
|
|
||||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
@@ -51,12 +49,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent bg-background/95 backdrop-blur duration-200 supports-backdrop-blur:bg-background/60',
|
'sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent bg-background/95 backdrop-blur duration-200 supports-backdrop-blur:bg-background/60',
|
||||||
scrollY > 5 && 'border-b-border',
|
(scrollY > 5 || fullWidth) && 'border-b-border',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mx-auto flex w-full items-center justify-between gap-x-4 px-4 md:justify-normal',
|
||||||
|
fullWidth ? 'md:px-6' : 'max-w-screen-xl md:px-8',
|
||||||
|
)}
|
||||||
|
data-testid="app-header-container"
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to={getRootHref(params)}
|
to={getRootHref(params)}
|
||||||
className="hidden rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:inline"
|
className="hidden rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:inline"
|
||||||
@@ -71,14 +75,16 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
<InboxIcon className="h-5 w-5 flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground" />
|
<InboxIcon className="h-5 w-5 flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground" />
|
||||||
|
|
||||||
{unreadCountData && unreadCountData.count > 0 && (
|
{unreadCountData && unreadCountData.count > 0 && (
|
||||||
<span className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary font-semibold text-[10px] text-primary-foreground">
|
<span className="absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums leading-none">
|
||||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="md:ml-4">{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}</div>
|
<div className="md:ml-4">
|
||||||
|
<OrgMenuSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||||
<button onClick={() => setIsCommandMenuOpen(true)}>
|
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -37,8 +36,8 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
|
|||||||
const menuNavigationLinks = useMemo(() => {
|
const menuNavigationLinks = useMemo(() => {
|
||||||
let teamUrl = currentTeam?.url || null;
|
let teamUrl = currentTeam?.url || null;
|
||||||
|
|
||||||
if (!teamUrl && isPersonalLayout(organisations)) {
|
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
|
||||||
teamUrl = organisations[0].teams[0]?.url || null;
|
teamUrl = organisations[0].teams[0].url;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamUrl) {
|
if (!teamUrl) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import LogoImage from '@documenso/assets/logo.png';
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
@@ -40,8 +39,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
const menuNavigationLinks = useMemo(() => {
|
const menuNavigationLinks = useMemo(() => {
|
||||||
let teamUrl = currentTeam?.url || null;
|
let teamUrl = currentTeam?.url || null;
|
||||||
|
|
||||||
if (!teamUrl && isPersonalLayout(organisations)) {
|
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
|
||||||
teamUrl = organisations[0].teams[0]?.url || null;
|
teamUrl = organisations[0].teams[0].url;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamUrl) {
|
if (!teamUrl) {
|
||||||
@@ -94,7 +93,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
||||||
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 font-semibold text-primary-foreground text-xs">
|
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 font-semibold text-primary-foreground text-xs tabular-nums">
|
||||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -317,7 +317,6 @@ export const IndividualPersonalLayoutCheckoutButton = ({
|
|||||||
const createSubscriptionResponse = await createSubscription({
|
const createSubscriptionResponse = await createSubscription({
|
||||||
organisationId: organisations[0].id,
|
organisationId: organisations[0].id,
|
||||||
priceId,
|
priceId,
|
||||||
isPersonalLayoutMode: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.location.href = createSubscriptionResponse.redirectUrl;
|
window.location.href = createSubscriptionResponse.redirectUrl;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import type { TTemplate } from '@documenso/lib/types/template';
|
import type { TTemplate } from '@documenso/lib/types/template';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
|
|
||||||
import type {
|
import type {
|
||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
TSignFieldWithTokenMutationSchema,
|
TSignFieldWithTokenMutationSchema,
|
||||||
@@ -224,12 +223,27 @@ export const DirectTemplateSigningForm = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const sortedRecipients = template.recipients.sort((a, b) => {
|
||||||
getDictatableNextRecipient({
|
// Sort by signingOrder first (nulls last), then by id
|
||||||
recipients: template.recipients,
|
if (a.signingOrder === null && b.signingOrder === null) {
|
||||||
currentRecipientId: directRecipient.id,
|
return a.id - b.id;
|
||||||
}) ?? undefined
|
}
|
||||||
);
|
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]);
|
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,5 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
@@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
redirectPath: formatPath(
|
||||||
|
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { formatPath } from '@documenso/lib/constants/app';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
|
|||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
let redirectPath = '/signin';
|
let redirectPath = formatPath('/signin');
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
|
|||||||
+17
-1
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { Field, Recipient } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
@@ -27,6 +28,8 @@ import { useEmbedSigningContext } from '~/components/embed/embed-signing-context
|
|||||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
|
||||||
export type DocumentSigningCompleteDialogProps = {
|
export type DocumentSigningCompleteDialogProps = {
|
||||||
@@ -85,7 +88,8 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
position,
|
position,
|
||||||
disableNameInput = false,
|
disableNameInput = false,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const { t } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
@@ -174,6 +178,18 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This dialog owns the completion error toast for every signing surface
|
||||||
|
// so the user gets a specific, actionable message. Callers should run
|
||||||
|
// their own side effects (e.g. embeds posting document-error) and
|
||||||
|
// rethrow rather than toasting themselves.
|
||||||
|
const toastMessage = getSigningCompletionErrorMessage(err.code);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: i18n._(toastMessage.title),
|
||||||
|
description: i18n._(toastMessage.description),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
@@ -19,6 +20,8 @@ import { useId, useMemo, useState } from 'react';
|
|||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||||
|
|
||||||
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
|
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
@@ -100,9 +103,12 @@ export const DocumentSigningForm = ({
|
|||||||
try {
|
try {
|
||||||
await completeDocument({ nextSigner });
|
await completeDocument({ nextSigner });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(toastMessage.title),
|
||||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
description: _(toastMessage.description),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -15,7 +15,6 @@ import type { CompletedField } from '@documenso/lib/types/fields';
|
|||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
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 { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -144,11 +143,31 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
||||||
|
|
||||||
const nextRecipient = useMemo(() => {
|
const nextRecipient = useMemo(() => {
|
||||||
if (documentMeta?.signingOrder !== 'SEQUENTIAL') {
|
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||||
return undefined;
|
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]);
|
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||||
|
|
||||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||||
|
|||||||
@@ -146,14 +146,17 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
|||||||
{isLoading && <DocumentSigningFieldsLoader />}
|
{isLoading && <DocumentSigningFieldsLoader />}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
|
<RadioGroup
|
||||||
|
value={selectedOption}
|
||||||
|
onValueChange={(value) => handleSelectItem(value)}
|
||||||
|
className="z-10 my-0.5 gap-y-1"
|
||||||
|
>
|
||||||
{values?.map((item, index) => (
|
{values?.map((item, index) => (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
className="h-3 w-3 shrink-0"
|
className="h-3 w-3 shrink-0"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
id={`option-${field.id}-${item.id}`}
|
id={`option-${field.id}-${item.id}`}
|
||||||
checked={item.checked}
|
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{!item.value.includes('empty-value-') && item.value && (
|
{!item.value.includes('empty-value-') && item.value && (
|
||||||
@@ -167,14 +170,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<RadioGroup className="my-0.5 gap-y-1">
|
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
|
||||||
{values?.map((item, index) => (
|
{values?.map((item, index) => (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
id={`option-${field.id}-${item.id}`}
|
id={`option-${field.id}-${item.id}`}
|
||||||
checked={item.value === field.customText}
|
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{!item.value.includes('empty-value-') && item.value && (
|
{!item.value.includes('empty-value-') && item.value && (
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve
|
|||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||||
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
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';
|
import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
@@ -291,14 +290,32 @@ export const EnvelopeSigningProvider = ({
|
|||||||
.filter((field) => field.inserted);
|
.filter((field) => field.inserted);
|
||||||
|
|
||||||
const nextRecipient = useMemo(() => {
|
const nextRecipient = useMemo(() => {
|
||||||
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
|
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getDictatableNextRecipient({
|
const sortedRecipients = [...envelope.recipients].sort((a, b) => {
|
||||||
recipients: envelope.recipients,
|
// Sort by signingOrder first (nulls last), then by id
|
||||||
currentRecipientId: recipient.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]);
|
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
||||||
|
|
||||||
const signField = async (
|
const signField = async (
|
||||||
|
|||||||
+528
-36
@@ -1,30 +1,42 @@
|
|||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import {
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
updateEditorSigners,
|
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
||||||
ZEditorRecipientsFormSchema,
|
|
||||||
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
|
import {
|
||||||
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
|
isAssistantLastSigner,
|
||||||
|
isCcRecipient,
|
||||||
|
normalizeRecipientSigningOrders,
|
||||||
|
canRecipientBeModified as utilCanRecipientBeModified,
|
||||||
|
} from '@documenso/lib/utils/recipients';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
||||||
|
import {
|
||||||
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation';
|
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 { 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 { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
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 { plural } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
|
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||||
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
|
import { motion } from 'framer-motion';
|
||||||
|
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useFieldArray, useWatch } from 'react-hook-form';
|
import { useFieldArray, useWatch } from 'react-hook-form';
|
||||||
import { useRevalidator, useSearchParams } from 'react-router';
|
import { useRevalidator, useSearchParams } from 'react-router';
|
||||||
@@ -34,8 +46,6 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
|
|||||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { RecipientStepList } from './recipient-step-list';
|
|
||||||
|
|
||||||
export const EnvelopeEditorRecipientForm = () => {
|
export const EnvelopeEditorRecipientForm = () => {
|
||||||
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
|
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
|
||||||
useCurrentEnvelopeEditor();
|
useCurrentEnvelopeEditor();
|
||||||
@@ -43,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
const { sessionData } = useOptionalSession();
|
const { sessionData } = useOptionalSession();
|
||||||
@@ -50,6 +61,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
const user = sessionData?.user;
|
const user = sessionData?.user;
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||||
|
|
||||||
// AI recipient detection dialog state
|
// AI recipient detection dialog state
|
||||||
@@ -95,8 +107,23 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||||
|
|
||||||
|
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||||
const isFirstRender = useRef(true);
|
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;
|
const { form } = editorRecipients;
|
||||||
|
|
||||||
@@ -134,20 +161,17 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
}, [watchedSigners]);
|
}, [watchedSigners]);
|
||||||
|
|
||||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||||
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep a mounted field array for `signers` so react-hook-form reconciles
|
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
|
||||||
// whole-array `setValue` calls atomically. Without it, reordering the array
|
|
||||||
// leaves stale partial entries in watched values (missing email/name/role),
|
const { fields: signers, remove: removeSigner } = useFieldArray({
|
||||||
// which breaks validation and the autosave sync.
|
|
||||||
useFieldArray({
|
|
||||||
control,
|
control,
|
||||||
name: 'signers',
|
name: 'signers',
|
||||||
|
keyName: 'nativeId',
|
||||||
});
|
});
|
||||||
|
|
||||||
const stepCount = useMemo(() => groupRecipientsBySigningOrder(watchedSigners).steps.length, [watchedSigners]);
|
|
||||||
|
|
||||||
const emptySignerIndex = watchedSigners.findIndex(
|
const emptySignerIndex = watchedSigners.findIndex(
|
||||||
(signer) =>
|
(signer) =>
|
||||||
!signer.name && !signer.email && envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
|
!signer.name && !signer.email && envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
|
||||||
@@ -159,22 +183,39 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName);
|
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(
|
const isUserAlreadyARecipient = watchedSigners.some(
|
||||||
(signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
|
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasDocumentBeenSent = recipients.some(
|
const hasDocumentBeenSent = recipients.some(
|
||||||
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
|
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
|
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 appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
|
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
|
||||||
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
|
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
|
||||||
|
|
||||||
updateEditorSigners(form, updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (shouldFocus) {
|
if (shouldFocus) {
|
||||||
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
|
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
|
||||||
@@ -192,7 +233,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
email: '',
|
email: '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: stepCount + 1,
|
signingOrder: activeRecipientCount + 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,8 +245,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
// If the only signer is the default empty signer lets just replace it with the detected recipients
|
// 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) {
|
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
|
||||||
updateEditorSigners(
|
form.setValue(
|
||||||
form,
|
'signers',
|
||||||
detectedRecipients.map((recipient, index) => ({
|
detectedRecipients.map((recipient, index) => ({
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
@@ -214,6 +255,10 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: index + 1,
|
signingOrder: index + 1,
|
||||||
})),
|
})),
|
||||||
|
{
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -240,7 +285,10 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
nextSigningOrder += 1;
|
nextSigningOrder += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
|
form.setValue('signers', normalizeSigningOrders(currentSigners), {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: plural(detectedRecipients.length, {
|
title: plural(detectedRecipients.length, {
|
||||||
@@ -254,6 +302,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 = () => {
|
const onAddSelfSigner = () => {
|
||||||
if (emptySignerIndex !== -1) {
|
if (emptySignerIndex !== -1) {
|
||||||
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
|
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
|
||||||
@@ -274,7 +348,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
email: currentEditorEmail ?? '',
|
email: currentEditorEmail ?? '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: stepCount + 1,
|
signingOrder: activeRecipientCount + 1,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -283,6 +357,142 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = normalizeSigningOrders(items);
|
||||||
|
|
||||||
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAssistantLastSigner(updatedSigners)) {
|
||||||
|
toast({
|
||||||
|
title: t`Warning: Assistant as last signer`,
|
||||||
|
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = normalizeSigningOrders(
|
||||||
|
currentSigners.map((signer, idx) => ({
|
||||||
|
...signer,
|
||||||
|
role: idx === index ? role : signer.role,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
|
||||||
|
toast({
|
||||||
|
title: t`Warning: Assistant as last signer`,
|
||||||
|
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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];
|
||||||
|
|
||||||
|
if (isCcRecipient(signer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
|
||||||
|
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
|
||||||
|
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
|
||||||
|
|
||||||
|
if (currentSigningOrderIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
|
||||||
|
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
|
||||||
|
nonCcSigners.splice(newPosition, 0, reorderedSigner);
|
||||||
|
|
||||||
|
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
|
||||||
|
|
||||||
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signer.role === RecipientRole.ASSISTANT && 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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form, canRecipientBeModified, toast],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSigningOrderDisable = useCallback(() => {
|
const handleSigningOrderDisable = useCallback(() => {
|
||||||
setShowSigningOrderConfirmation(false);
|
setShowSigningOrderConfirmation(false);
|
||||||
|
|
||||||
@@ -294,8 +504,10 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
updateEditorSigners(form, updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
@@ -376,7 +588,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
}, [formValues]);
|
}, [formValues]);
|
||||||
|
|
||||||
const recipientCountLimit = organisation.organisationClaim.recipientCount;
|
const recipientCountLimit = organisation.organisationClaim.recipientCount;
|
||||||
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
|
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card backdropBlur={false} className="border">
|
<Card backdropBlur={false} className="border">
|
||||||
@@ -432,7 +644,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
|
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||||
onClick={() => onAddSigner()}
|
onClick={() => onAddSigner()}
|
||||||
>
|
>
|
||||||
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
|
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
|
||||||
@@ -582,7 +794,287 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 ||
|
||||||
|
isCcRecipient(signer) ||
|
||||||
|
!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 && isCcRecipient(signer) && (
|
||||||
|
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSigningOrderSequential && !isCcRecipient(signer) && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`signers.${index}.signingOrder`}
|
||||||
|
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={activeRecipientCount}
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
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
|
<FormErrorMessage
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+25
-18
@@ -42,7 +42,11 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
|
|
||||||
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
|
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const { mutateAsync: completeDocument, isPending } = trpc.recipient.completeDocumentWithToken.useMutation();
|
const {
|
||||||
|
mutateAsync: completeDocument,
|
||||||
|
isPending,
|
||||||
|
isSuccess,
|
||||||
|
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromDirectTemplate } =
|
const { mutateAsync: createDocumentFromDirectTemplate } =
|
||||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||||
@@ -106,11 +110,21 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
// The document was already completed by an earlier request (retry,
|
||||||
signerId: recipient.id,
|
// stale tab or concurrent submission). Let the user know this click
|
||||||
documentId: envelope.id,
|
// didn't complete the document, then continue to the completed page.
|
||||||
timestamp: new Date().toISOString(),
|
if (result.status === 'ALREADY_SIGNED') {
|
||||||
});
|
toast({
|
||||||
|
title: t`Document already signed`,
|
||||||
|
description: t`This document was already signed and no further action was taken.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
|
signerId: recipient.id,
|
||||||
|
documentId: envelope.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (onDocumentCompleted) {
|
if (onDocumentCompleted) {
|
||||||
onDocumentCompleted({
|
onDocumentCompleted({
|
||||||
@@ -134,15 +148,11 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
||||||
toast({
|
|
||||||
title: t`Something went wrong`,
|
|
||||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
onDocumentError?.();
|
onDocumentError?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
|
||||||
|
// toast a specific completion error message.
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -210,14 +220,11 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('err', err);
|
console.log('err', err);
|
||||||
toast({
|
|
||||||
title: t`Something went wrong`,
|
|
||||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
onDocumentError?.();
|
onDocumentError?.();
|
||||||
|
|
||||||
|
// Rethrow so DocumentSigningCompleteDialog can toast a specific
|
||||||
|
// completion error message.
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -246,7 +253,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isPending}
|
isSubmitting={isPending || isSuccess}
|
||||||
recipientPayload={recipientPayload}
|
recipientPayload={recipientPayload}
|
||||||
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
|
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
|
||||||
documentTitle={envelope.title}
|
documentTitle={envelope.title}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|
||||||
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { ChevronsUpDown, Plus } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
export const MenuSwitcher = () => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { user } = useSession();
|
|
||||||
|
|
||||||
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
|
||||||
|
|
||||||
const isUserAdmin = isAdmin(user);
|
|
||||||
|
|
||||||
const formatAvatarFallback = (name?: string) => {
|
|
||||||
if (name !== undefined) {
|
|
||||||
return name.slice(0, 1).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
data-testid="menu-switcher"
|
|
||||||
variant="none"
|
|
||||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
|
||||||
>
|
|
||||||
<AvatarWithText
|
|
||||||
avatarSrc={formatAvatarUrl(user.avatarImageId)}
|
|
||||||
avatarFallback={formatAvatarFallback(user.name || user.email)}
|
|
||||||
primaryText={user.name}
|
|
||||||
secondaryText={_(msg`Personal Account`)}
|
|
||||||
rightSideComponent={<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />}
|
|
||||||
textSectionClassName="hidden lg:flex"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')} align="end" forceMount>
|
|
||||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
|
||||||
<Link to="/settings/organisations?action=add-organisation" className="flex items-center justify-between">
|
|
||||||
<Trans>Create Organisation</Trans>
|
|
||||||
<Plus className="ml-2 h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{isUserAdmin && (
|
|
||||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
|
||||||
<Link to="/admin">
|
|
||||||
<Trans>Admin panel</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
|
||||||
<Link to="/inbox">
|
|
||||||
<Trans>Personal Inbox</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
|
||||||
<Link to="/settings/profile">
|
|
||||||
<Trans>User settings</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" onClick={() => setLanguageSwitcherOpen(true)}>
|
|
||||||
<Trans>Language</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="hover:!text-destructive px-4 py-2 text-destructive/90"
|
|
||||||
onSelect={async () => authClient.signOut()}
|
|
||||||
>
|
|
||||||
<Trans>Sign Out</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
|
|
||||||
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -29,7 +29,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children || (
|
{children || (
|
||||||
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
|
<p className="mt-auto font-semibold text-4xl text-foreground tabular-nums leading-8">
|
||||||
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
@@ -55,6 +56,12 @@ export const OrgMenuSwitcher = () => {
|
|||||||
const currentOrganisation = useOptionalCurrentOrganisation();
|
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||||
const currentTeam = useOptionalCurrentTeam();
|
const currentTeam = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const canAccessOrganisationSettings =
|
||||||
|
currentOrganisation &&
|
||||||
|
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole);
|
||||||
|
|
||||||
|
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
|
||||||
|
|
||||||
// Use hovered org for teams display if available,
|
// Use hovered org for teams display if available,
|
||||||
// otherwise use current team's org if in a team,
|
// otherwise use current team's org if in a team,
|
||||||
// finally fallback to selected org
|
// finally fallback to selected org
|
||||||
@@ -161,7 +168,7 @@ export const OrgMenuSwitcher = () => {
|
|||||||
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
|
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
|
||||||
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
|
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
|
||||||
<Link
|
<Link
|
||||||
to={`/o/${org.url}/settings`}
|
to={`/o/${org.url}/settings/general`}
|
||||||
className="mr-2 rounded-sm border p-1 text-muted-foreground transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
|
className="mr-2 rounded-sm border p-1 text-muted-foreground transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
|
||||||
>
|
>
|
||||||
<Settings2Icon className="h-3.5 w-3.5" />
|
<Settings2Icon className="h-3.5 w-3.5" />
|
||||||
@@ -214,7 +221,7 @@ export const OrgMenuSwitcher = () => {
|
|||||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||||
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
|
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
|
||||||
<Link
|
<Link
|
||||||
to={`/t/${team.url}/settings`}
|
to={`/t/${team.url}/settings/general`}
|
||||||
className="mr-2 rounded-sm border p-1 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
className="mr-2 rounded-sm border p-1 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Settings2Icon className="h-3.5 w-3.5" />
|
<Settings2Icon className="h-3.5 w-3.5" />
|
||||||
@@ -258,26 +265,23 @@ export const OrgMenuSwitcher = () => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentOrganisation &&
|
|
||||||
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole) && (
|
|
||||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
|
||||||
<Link to={`/o/${currentOrganisation.url}/settings`}>
|
|
||||||
<Trans>Organisation settings</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
|
||||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
|
||||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
|
||||||
<Trans>Team settings</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||||
<Link to="/inbox">
|
<Link to="/inbox">
|
||||||
<Trans>Personal Inbox</Trans>
|
<Trans>Inbox</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||||
|
<Link
|
||||||
|
to={
|
||||||
|
canAccessOrganisationSettings
|
||||||
|
? `/o/${currentOrganisation?.url}/settings/general`
|
||||||
|
: canAccessTeamSettings
|
||||||
|
? `/t/${currentTeam?.url}/settings/general`
|
||||||
|
: '/settings/profile'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Settings</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
@@ -287,6 +291,14 @@ export const OrgMenuSwitcher = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{IS_BILLING_ENABLED() && (
|
||||||
|
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
|
||||||
|
<Link to="/settings/billing">
|
||||||
|
<Trans>Billing</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="px-4 py-2 text-muted-foreground"
|
className="px-4 py-2 text-muted-foreground"
|
||||||
onClick={() => setLanguageSwitcherOpen(true)}
|
onClick={() => setLanguageSwitcherOpen(true)}
|
||||||
|
|||||||
+2
-6
@@ -1,6 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -13,8 +12,6 @@ export type OrganisationBillingPortalButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBillingPortalButtonProps) => {
|
export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBillingPortalButtonProps) => {
|
||||||
const { organisations } = useSession();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@@ -28,11 +25,10 @@ export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBil
|
|||||||
try {
|
try {
|
||||||
const { redirectUrl } = await manageSubscription({
|
const { redirectUrl } = await manageSubscription({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
isPersonalLayoutMode: isPersonalLayout(organisations),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.open(redirectUrl, '_blank');
|
window.open(redirectUrl, '_blank');
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
description: _(
|
description: _(
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export default function PDFViewer({
|
|||||||
|
|
||||||
type VirtualizedPageListProps = {
|
type VirtualizedPageListProps = {
|
||||||
scrollParentRef: ScrollTarget;
|
scrollParentRef: ScrollTarget;
|
||||||
constraintRef: React.RefObject<HTMLDivElement>;
|
constraintRef: React.RefObject<HTMLDivElement | null>;
|
||||||
pages: PageMeta[];
|
pages: PageMeta[];
|
||||||
numPages: number;
|
numPages: number;
|
||||||
pdf: pdfjsLib.PDFDocumentProxy;
|
pdf: pdfjsLib.PDFDocumentProxy;
|
||||||
|
|||||||
@@ -12,17 +12,19 @@ export type SettingsHeaderProps = {
|
|||||||
export const SettingsHeader = ({ children, title, subtitle, className, hideDivider }: SettingsHeaderProps) => {
|
export const SettingsHeader = ({ children, title, subtitle, className, hideDivider }: SettingsHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
<div className={cn('mb-4 flex flex-row items-center justify-between', className)}>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="font-medium text-lg">{title}</h3>
|
<h2 className="font-bold text-xl leading-tight [text-wrap:balance]">{title}</h2>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
|
<p className="mt-1 max-w-[65ch] text-muted-foreground text-sm leading-normal [overflow-wrap:break-word] [text-wrap:pretty] md:mt-2">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hideDivider && <hr className="my-4" />}
|
{!hideDivider && <hr className="mb-4" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { BracesIcon, CreditCardIcon, Globe2Icon, Lock, Settings2Icon, User, Users, WebhookIcon } from 'lucide-react';
|
|
||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router';
|
|
||||||
|
|
||||||
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const { organisations } = useSession();
|
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
|
||||||
|
|
||||||
const hasManageableBillingOrgs = organisations.some((org) =>
|
|
||||||
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
|
||||||
<Link to="/settings/profile">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<User className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Profile</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isPersonalLayoutMode && (
|
|
||||||
<>
|
|
||||||
<Link to="/settings/document">
|
|
||||||
<Button variant="ghost" className={cn('w-full justify-start')}>
|
|
||||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Preferences</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link className="w-full pl-8" to="/settings/document">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Trans>Document</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link className="w-full pl-8" to="/settings/branding">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Trans>Branding</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link className="w-full pl-8" to="/settings/email">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/public-profile">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Public Profile</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/tokens">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<BracesIcon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>API Tokens</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/webhooks">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<WebhookIcon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Webhooks</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to="/settings/organisations">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Users className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Organisations</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
|
|
||||||
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Billing</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to="/settings/security">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Lock className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Security</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import {
|
|
||||||
BracesIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
Globe2Icon,
|
|
||||||
Lock,
|
|
||||||
MailIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
Settings2Icon,
|
|
||||||
User,
|
|
||||||
Users,
|
|
||||||
WebhookIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router';
|
|
||||||
|
|
||||||
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const { organisations } = useSession();
|
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
|
||||||
|
|
||||||
const hasManageableBillingOrgs = organisations.some((org) =>
|
|
||||||
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)} {...props}>
|
|
||||||
<Link to="/settings/profile">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<User className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Profile</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isPersonalLayoutMode && (
|
|
||||||
<>
|
|
||||||
<Link to="/settings/document">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Document Preferences</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/branding">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<PaletteIcon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Branding Preferences</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/email">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<MailIcon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Email Preferences</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/public-profile">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Public Profile</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/tokens">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<BracesIcon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>API Tokens</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/webhooks">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<WebhookIcon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Webhooks</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to="/settings/organisations">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Users className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Organisations</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
|
|
||||||
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Billing</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to="/settings/security">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
|
|
||||||
>
|
|
||||||
<Lock className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Security</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { type INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
|
||||||
|
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
const SEARCH_THRESHOLD = 5;
|
||||||
|
|
||||||
|
export type SettingsOrgSwitcherProps = {
|
||||||
|
currentOrgUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsOrgSwitcher = ({ currentOrgUrl }: SettingsOrgSwitcherProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { organisations } = useSession();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const manageableOrgs = useMemo(
|
||||||
|
() =>
|
||||||
|
organisations.filter(
|
||||||
|
(org) =>
|
||||||
|
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
|
||||||
|
org.teams.some((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)),
|
||||||
|
),
|
||||||
|
[organisations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentOrg = manageableOrgs.find((org) => org.url === currentOrgUrl);
|
||||||
|
|
||||||
|
const hasManageableBillingOrgs = useMemo(
|
||||||
|
() => organisations.some((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole)),
|
||||||
|
[organisations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
return manageableOrgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return manageableOrgs.filter((org) => org.name.toLowerCase().includes(q));
|
||||||
|
}, [manageableOrgs, query]);
|
||||||
|
|
||||||
|
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||||
|
|
||||||
|
const handleSelect = (orgUrl: string) => {
|
||||||
|
const destinationOrg = manageableOrgs.find((org) => org.url === orgUrl);
|
||||||
|
|
||||||
|
if (!destinationOrg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manageableTeam = destinationOrg.teams.find((team) =>
|
||||||
|
canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole),
|
||||||
|
);
|
||||||
|
|
||||||
|
const destinationGroups = getSettingsNavGroups({
|
||||||
|
organisation: {
|
||||||
|
url: destinationOrg.url,
|
||||||
|
currentOrganisationRole: destinationOrg.currentOrganisationRole,
|
||||||
|
organisationClaim: destinationOrg.organisationClaim,
|
||||||
|
},
|
||||||
|
team: manageableTeam ? { url: manageableTeam.url, currentTeamRole: manageableTeam.currentTeamRole } : null,
|
||||||
|
hasManageableBillingOrgs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The list also contains organisations the user can only reach through a team they
|
||||||
|
// manage — for those `getSettingsNavGroups` returns no organisation group, so we land
|
||||||
|
// them in the team group instead of on an organisation page they aren't authorised for.
|
||||||
|
const destinationGroup = destinationGroups.organisation ?? destinationGroups.team;
|
||||||
|
|
||||||
|
if (!destinationGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
|
||||||
|
void navigate(
|
||||||
|
computeSwitcherContinuityPath({
|
||||||
|
currentPath: pathname,
|
||||||
|
destinationPaths: destinationGroup.items.map((item) => item.path),
|
||||||
|
fallbackPath: destinationGroup.items[0].path,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentOrg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an organisation's plan label. Unknown or custom claims (including
|
||||||
|
// self-hosted custom claim IDs) fall back to "Custom Plan".
|
||||||
|
const getPlanName = (organisationClaimId: string | null) => {
|
||||||
|
const planClaim =
|
||||||
|
organisationClaimId && organisationClaimId in internalClaims
|
||||||
|
? internalClaims[organisationClaimId as INTERNAL_CLAIM_ID]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return planClaim ? t`${planClaim.name} Plan` : t`Custom Plan`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="settings-org-switcher-trigger"
|
||||||
|
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<AvatarWithText
|
||||||
|
className="max-w-none"
|
||||||
|
avatarClass="h-8 w-8"
|
||||||
|
avatarSrc={formatAvatarUrl(currentOrg.avatarImageId)}
|
||||||
|
avatarFallback={currentOrg.name.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<span className="font-semibold text-muted-foreground">{currentOrg.name}</span>}
|
||||||
|
secondaryText={
|
||||||
|
isBillingEnabled ? getPlanName(currentOrg.organisationClaim.originalSubscriptionClaimId) : undefined
|
||||||
|
}
|
||||||
|
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
|
data-testid="settings-org-switcher-content"
|
||||||
|
>
|
||||||
|
{manageableOrgs.length >= SEARCH_THRESHOLD && (
|
||||||
|
<div className="border-b p-2">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t`Search organisations…`}
|
||||||
|
className="h-8 pl-7"
|
||||||
|
data-testid="settings-org-switcher-search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="max-h-72 space-y-1 overflow-auto p-1">
|
||||||
|
{filtered.map((org) => {
|
||||||
|
const isCurrent = org.url === currentOrgUrl;
|
||||||
|
return (
|
||||||
|
<li key={org.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(org.url)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
|
||||||
|
isCurrent && 'bg-muted',
|
||||||
|
)}
|
||||||
|
data-testid={`settings-org-switcher-item-${org.url}`}
|
||||||
|
>
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-8 w-8"
|
||||||
|
avatarSrc={formatAvatarUrl(org.avatarImageId)}
|
||||||
|
avatarFallback={org.name.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{org.name}</span>}
|
||||||
|
secondaryText={
|
||||||
|
isBillingEnabled ? getPlanName(org.organisationClaim.originalSubscriptionClaimId) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="border-t p-1">
|
||||||
|
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-org-switcher-create">
|
||||||
|
<a href="/settings/organisations?action=add-organisation">
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Create organisation</Trans>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Building2Icon, ChevronRightIcon, UserIcon, Users2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export type SettingsScopeBreadcrumbProps = {
|
||||||
|
scope: 'organisation' | 'team' | 'account';
|
||||||
|
scopeName: string;
|
||||||
|
crumbs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsScopeBreadcrumb = ({ scope, scopeName, crumbs }: SettingsScopeBreadcrumbProps) => {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="settings-scope-breadcrumb"
|
||||||
|
className="mb-4 flex flex-wrap items-center gap-2 text-muted-foreground text-sm"
|
||||||
|
data-testid="settings-scope-breadcrumb"
|
||||||
|
>
|
||||||
|
<span>{scopeName}</span>
|
||||||
|
|
||||||
|
{crumbs.map((crumb, idx) => {
|
||||||
|
const isLeaf = idx === crumbs.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={`${crumb}-${idx}`} className="flex items-center gap-2">
|
||||||
|
<ChevronRightIcon className="h-3.5 w-3.5 opacity-50" />
|
||||||
|
<span className={cn(isLeaf && 'font-semibold text-foreground')}>{crumb}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Badge
|
||||||
|
variant={scope === 'organisation' ? 'default' : scope === 'team' ? 'secondary' : 'neutral'}
|
||||||
|
role="presentation"
|
||||||
|
className="ml-auto gap-1.5"
|
||||||
|
data-testid="settings-scope-breadcrumb-chip"
|
||||||
|
>
|
||||||
|
{scope === 'organisation' && (
|
||||||
|
<>
|
||||||
|
<Building2Icon className="h-3.5 w-3.5" />
|
||||||
|
<Trans>Organisation Settings</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{scope === 'team' && (
|
||||||
|
<>
|
||||||
|
<Users2Icon className="h-3.5 w-3.5" />
|
||||||
|
<Trans>Team Settings</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{scope === 'account' && (
|
||||||
|
<>
|
||||||
|
<UserIcon className="h-3.5 w-3.5" />
|
||||||
|
<Trans>Account Settings</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
|
||||||
|
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
const SEARCH_THRESHOLD = 5;
|
||||||
|
|
||||||
|
export type SettingsTeamSwitcherProps = {
|
||||||
|
currentOrgUrl: string;
|
||||||
|
currentTeamUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsTeamSwitcher = ({ currentOrgUrl, currentTeamUrl }: SettingsTeamSwitcherProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { organisations } = useSession();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const currentOrg = organisations.find((org) => org.url === currentOrgUrl);
|
||||||
|
|
||||||
|
const manageableTeams = useMemo(
|
||||||
|
() =>
|
||||||
|
currentOrg ? currentOrg.teams.filter((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) : [],
|
||||||
|
[currentOrg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentTeam = manageableTeams.find((team) => team.url === currentTeamUrl) ?? manageableTeams[0];
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
return manageableTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
return manageableTeams.filter((team) => team.name.toLowerCase().includes(q));
|
||||||
|
}, [manageableTeams, query]);
|
||||||
|
|
||||||
|
const handleSelect = (teamUrl: string) => {
|
||||||
|
const destinationTeam = manageableTeams.find((team) => team.url === teamUrl);
|
||||||
|
|
||||||
|
if (!currentOrg || !destinationTeam) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationGroup = getSettingsNavGroups({
|
||||||
|
organisation: {
|
||||||
|
url: currentOrg.url,
|
||||||
|
currentOrganisationRole: currentOrg.currentOrganisationRole,
|
||||||
|
organisationClaim: currentOrg.organisationClaim,
|
||||||
|
},
|
||||||
|
team: { url: destinationTeam.url, currentTeamRole: destinationTeam.currentTeamRole },
|
||||||
|
hasManageableBillingOrgs: false,
|
||||||
|
}).team;
|
||||||
|
|
||||||
|
if (!destinationGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
|
||||||
|
void navigate(
|
||||||
|
computeSwitcherContinuityPath({
|
||||||
|
currentPath: pathname,
|
||||||
|
destinationPaths: destinationGroup.items.map((item) => item.path),
|
||||||
|
fallbackPath: destinationGroup.items[0].path,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentOrg || !currentTeam) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="settings-team-switcher-trigger"
|
||||||
|
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<AvatarWithText
|
||||||
|
className="max-w-none"
|
||||||
|
avatarClass="h-8 w-8"
|
||||||
|
avatarSrc={formatAvatarUrl(currentTeam.avatarImageId)}
|
||||||
|
avatarFallback={currentTeam.name.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<span className="font-semibold text-muted-foreground">{currentTeam.name}</span>}
|
||||||
|
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
|
data-testid="settings-team-switcher-content"
|
||||||
|
>
|
||||||
|
{manageableTeams.length >= SEARCH_THRESHOLD && (
|
||||||
|
<div className="border-b p-2">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t`Search teams…`}
|
||||||
|
className="h-8 pl-7"
|
||||||
|
data-testid="settings-team-switcher-search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="max-h-72 space-y-1 overflow-auto p-1">
|
||||||
|
{filtered.map((team) => {
|
||||||
|
const isCurrent = team.url === currentTeam.url;
|
||||||
|
return (
|
||||||
|
<li key={team.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(team.url)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
|
||||||
|
isCurrent && 'bg-muted',
|
||||||
|
)}
|
||||||
|
data-testid={`settings-team-switcher-item-${team.url}`}
|
||||||
|
>
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-8 w-8"
|
||||||
|
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||||
|
avatarFallback={team.name.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{team.name}</span>}
|
||||||
|
secondaryText={t(EXTENDED_TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="border-t p-1">
|
||||||
|
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-team-switcher-create">
|
||||||
|
<a href={`/o/${currentOrg.url}/settings/teams?action=add-team`}>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Create team</Trans>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { EASE, POP, SPRING } from './motion';
|
||||||
|
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||||
|
import { useTimedCycle } from './use-timed-cycle';
|
||||||
|
|
||||||
|
const DEMO_BRANDS = [
|
||||||
|
{
|
||||||
|
name: 'Documenso',
|
||||||
|
letter: 'D',
|
||||||
|
domain: 'noreply@app.documenso.com',
|
||||||
|
accent: '#A2E771',
|
||||||
|
ink: '#162C07',
|
||||||
|
tint: '#F2FBEA',
|
||||||
|
sheen: 'rgba(162, 231, 113, 0.32)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Documenso',
|
||||||
|
letter: 'D',
|
||||||
|
domain: 'noreply@app.documenso.com',
|
||||||
|
accent: '#387BC7',
|
||||||
|
ink: '#ffffff',
|
||||||
|
tint: '#EDF3FA',
|
||||||
|
sheen: 'rgba(56, 123, 199, 0.28)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Documenso',
|
||||||
|
letter: 'D',
|
||||||
|
domain: 'noreply@app.documenso.com',
|
||||||
|
accent: '#9747F5',
|
||||||
|
ink: '#ffffff',
|
||||||
|
tint: '#F4EDFE',
|
||||||
|
sheen: 'rgba(151, 71, 245, 0.26)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds each brand is shown before cycling to the next.
|
||||||
|
*/
|
||||||
|
const BRAND_CYCLE_INTERVAL_MS = 2400;
|
||||||
|
|
||||||
|
export const BrandingUpsell = () => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const isReducedMotion = useReducedMotion();
|
||||||
|
const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS));
|
||||||
|
|
||||||
|
const isStatic = isReducedMotion ?? false;
|
||||||
|
|
||||||
|
const brand = DEMO_BRANDS[brandIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsUpsellCard
|
||||||
|
planLabel={<Trans>Teams</Trans>}
|
||||||
|
title={<Trans>Unlock Branding Preferences</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>Put your own brand on every document you send. Branding is available on the Teams plan and above.</Trans>
|
||||||
|
}
|
||||||
|
features={[
|
||||||
|
<Trans key="logo">Your logo on signing pages and emails</Trans>,
|
||||||
|
<Trans key="details">Company details and website in email footers</Trans>,
|
||||||
|
<Trans key="teams">Separate branding per team</Trans>,
|
||||||
|
]}
|
||||||
|
preview={
|
||||||
|
<div className="mx-auto w-full max-w-xs">
|
||||||
|
<div className="flex h-8 items-center justify-between px-1">
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||||
|
<Trans>Brand accent</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{DEMO_BRANDS.map((dotBrand, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{
|
||||||
|
scale: index === brandIndex ? 1.25 : 1,
|
||||||
|
opacity: index === brandIndex ? 1 : 0.42,
|
||||||
|
boxShadow:
|
||||||
|
index === brandIndex ? '0 0 0 3px rgba(15, 23, 42, 0.08)' : '0 0 0 0 rgba(15, 23, 42, 0)',
|
||||||
|
}}
|
||||||
|
transition={SPRING}
|
||||||
|
className="h-[13px] w-[13px] rounded-full"
|
||||||
|
style={{ backgroundColor: dotBrand.accent }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-3 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.tint }}
|
||||||
|
transition={{ duration: 0.45, ease: EASE }}
|
||||||
|
className="flex items-center gap-2.5 border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
{/* The sender identity never changes — only the tile colours tween per brand. */}
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||||
|
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg font-semibold text-sm"
|
||||||
|
>
|
||||||
|
{brand.letter}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Hardcoded inks (not theme tokens): this row sits on the
|
||||||
|
* hardcoded light `tint` band, so it pairs with hardcoded ink
|
||||||
|
* colours the same way the email sibling pairs its hardcoded
|
||||||
|
* avatar surfaces (hardcoded surface => hardcoded ink).
|
||||||
|
*/}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-[#0f172a] text-sm">
|
||||||
|
<p className="truncate">{brand.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-[#64748b] text-xs">
|
||||||
|
<p className="truncate">{brand.domain}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
<Trans>Please sign: Example.pdf</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
|
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */}
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||||
|
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||||
|
className="mt-3 inline-block rounded-md px-3 py-1.5 font-medium text-xs"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
key={brandIndex}
|
||||||
|
initial={isStatic ? false : { scale: 0.96 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ ...POP, delay: 0.06 }}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
<Trans>Sign</Trans>
|
||||||
|
</motion.span>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center gap-2.5 border-t bg-muted px-4 py-2.5">
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||||
|
<Trans>Company details</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.accent }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="h-1.5 w-[54px] rounded-full"
|
||||||
|
style={{ opacity: 0.45 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : undefined}
|
||||||
|
animate={{ backgroundColor: brand.accent }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="h-1.5 w-[34px] rounded-full"
|
||||||
|
style={{ opacity: 0.22 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Keyed remount replays the sweep per brand. No opacity envelope —
|
||||||
|
* keyframe arrays are unreliable on strict-mode remounts; both
|
||||||
|
* endpoints sit outside the overflow-hidden card, so the clip
|
||||||
|
* provides the fade in/out instead.
|
||||||
|
*/}
|
||||||
|
<motion.div
|
||||||
|
key={`sheen-${brandIndex}`}
|
||||||
|
initial={isStatic ? false : { x: '-130%' }}
|
||||||
|
animate={{ x: '240%' }}
|
||||||
|
transition={{ duration: 1.15, ease: 'easeOut' }}
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||||
|
style={{ background: `linear-gradient(105deg, transparent, ${brand.sheen}, transparent)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||||
|
import { BadgeCheckIcon, MailIcon } from 'lucide-react';
|
||||||
|
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||||
|
import { EASE, POP, SPRING } from './motion';
|
||||||
|
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||||
|
import { useTimedCycle } from './use-timed-cycle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named sender identities cycled through while the preview is in its branded
|
||||||
|
* state — one per branded cycle step, shown as the sender name and address.
|
||||||
|
*/
|
||||||
|
const BRANDED_SENDERS = [
|
||||||
|
{ name: 'Support', email: 'support@example.com' },
|
||||||
|
{ name: 'Team', email: 'hello@example.com' },
|
||||||
|
{ name: 'Sales', email: 'sales@example.com' },
|
||||||
|
{ name: 'Example', email: 'noreply@example.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the initial unbranded (Documenso default) state is shown before
|
||||||
|
* the first flip starts. Shown exactly once — the cycle never returns to it.
|
||||||
|
*/
|
||||||
|
const INITIAL_STATE_DURATION_MS = 2500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long each branded sender identity is shown before cycling to the next,
|
||||||
|
* giving the viewer time to read the changed address.
|
||||||
|
*/
|
||||||
|
const BRANDED_STATE_DURATION_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One duration per cycle step: the unbranded state first, then one step per
|
||||||
|
* named sender identity, derived from the identity count so the two cannot
|
||||||
|
* drift.
|
||||||
|
*/
|
||||||
|
const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)];
|
||||||
|
|
||||||
|
export const EmailDomainsUpsell = () => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const isReducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
// Loop from index 1: the unbranded Documenso intro plays exactly once,
|
||||||
|
// then the cycle rotates through the branded senders only.
|
||||||
|
const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1);
|
||||||
|
|
||||||
|
const isBranded = cycleIndex > 0;
|
||||||
|
const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0];
|
||||||
|
|
||||||
|
const isStatic = isReducedMotion ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsUpsellCard
|
||||||
|
planLabel={<Trans>Enterprise</Trans>}
|
||||||
|
title={<Trans>Unlock Email Domains</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>Send documents from your own domain. Email domains are available on the Enterprise plan.</Trans>
|
||||||
|
}
|
||||||
|
features={[
|
||||||
|
<Trans key="journey">Send emails to recipients from your domain</Trans>,
|
||||||
|
<Trans key="dns">Easy DNS setup with auto-generated DKIM and SPF records</Trans>,
|
||||||
|
<Trans key="senders">Named senders with defaults per team, template or document</Trans>,
|
||||||
|
]}
|
||||||
|
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||||
|
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||||
|
ctaExternal
|
||||||
|
preview={
|
||||||
|
<div className="mx-auto w-full max-w-xs">
|
||||||
|
<div className="relative h-8">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={isBranded ? 'chip-on' : 'chip-off'}
|
||||||
|
initial={isStatic ? false : { opacity: 0, y: 8, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||||
|
transition={SPRING}
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 flex items-center gap-2 rounded-full border bg-background px-3 font-mono text-xs',
|
||||||
|
isBranded ? 'border-documenso-300 text-documenso-800' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBranded ? (
|
||||||
|
<BadgeCheckIcon className="h-3.5 w-3.5 shrink-0 text-documenso-700" />
|
||||||
|
) : (
|
||||||
|
<MailIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="truncate">
|
||||||
|
{isBranded ? <Trans>Sending from your domain</Trans> : <Trans>Sending from app.documenso.com</Trans>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-4 overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 border-b p-4">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-semibold text-sm">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={`logo-${cycleIndex}`}
|
||||||
|
initial={isStatic ? false : { opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.22 }}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
* Remounts with its keyed parent on every cycle step so the
|
||||||
|
* pop replays each change. Single-value spring (POP
|
||||||
|
* overshoots past 1) instead of scale keyframes — keyframe
|
||||||
|
* arrays are unreliable on strict-mode remounts.
|
||||||
|
*/}
|
||||||
|
<motion.span
|
||||||
|
initial={isStatic ? false : { scale: 0.8 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ scale: POP }}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
{isBranded ? (
|
||||||
|
<Avatar className="h-8 w-8 border border-solid">
|
||||||
|
{organisation.avatarImageId && (
|
||||||
|
<AvatarImage src={formatAvatarUrl(organisation.avatarImageId)} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-sm">{brandedSender.name[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
) : (
|
||||||
|
<BrandingLogoIcon className="h-8 w-8" />
|
||||||
|
)}
|
||||||
|
</motion.span>
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={`name-${cycleIndex}`}
|
||||||
|
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.28, ease: EASE }}
|
||||||
|
className="flex min-w-0 items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{isBranded ? brandedSender.name : 'Documenso'}</span>
|
||||||
|
|
||||||
|
{/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */}
|
||||||
|
{isBranded && (
|
||||||
|
<motion.span
|
||||||
|
initial={isStatic ? false : { scale: 0, rotate: -40 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ ...POP, delay: 0.12 }}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<BadgeCheckIcon className="h-3.5 w-3.5 text-documenso-700" />
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-muted-foreground text-xs">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.p
|
||||||
|
key={`addr-${cycleIndex}`}
|
||||||
|
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.28, ease: EASE }}
|
||||||
|
className="truncate"
|
||||||
|
>
|
||||||
|
{isBranded ? brandedSender.email : 'noreply@app.documenso.com'}
|
||||||
|
</motion.p>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
<Trans>Please sign: Example.pdf</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
|
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Keyed remount replays the sweep on every cycle step. No opacity
|
||||||
|
* envelope — keyframe arrays are unreliable on strict-mode
|
||||||
|
* remounts; both endpoints sit outside the overflow-hidden card,
|
||||||
|
* so the clip provides the fade in/out instead.
|
||||||
|
*/}
|
||||||
|
<motion.div
|
||||||
|
key={`sheen-${cycleIndex}`}
|
||||||
|
initial={isStatic ? false : { x: '-130%' }}
|
||||||
|
animate={{ x: '240%' }}
|
||||||
|
transition={{ duration: 1.2, ease: 'easeOut' }}
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||||
|
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.32), transparent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Shared motion vocabulary for the settings upsell previews. Values ported
|
||||||
|
* from the design prototype (`design/SSO Upsell.dc.html`).
|
||||||
|
*/
|
||||||
|
export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const;
|
||||||
|
|
||||||
|
export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const;
|
||||||
|
|
||||||
|
export const EASE = [0.22, 0.61, 0.36, 1] as const;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
export type SettingsUpsellCardProps = {
|
||||||
|
planLabel: ReactNode;
|
||||||
|
title: ReactNode;
|
||||||
|
description: ReactNode;
|
||||||
|
features: ReactNode[];
|
||||||
|
preview: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CTA label. Defaults to "Upgrade Plan".
|
||||||
|
*/
|
||||||
|
ctaLabel?: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CTA destination. Defaults to the organisation billing settings page.
|
||||||
|
*/
|
||||||
|
ctaTo?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the CTA as an external link (new tab) instead of an internal route.
|
||||||
|
*/
|
||||||
|
ctaExternal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared split-card layout for claim-gated settings upsells on Documenso
|
||||||
|
* Cloud. The left pane pitches the feature (plan badge, title, description,
|
||||||
|
* feature list, upgrade CTA); the right pane renders a decorative scenario
|
||||||
|
* preview supplied by the caller.
|
||||||
|
*
|
||||||
|
* Callers decide *when* to render this (cloud + missing claim flag).
|
||||||
|
*/
|
||||||
|
export const SettingsUpsellCard = ({
|
||||||
|
planLabel,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
features,
|
||||||
|
preview,
|
||||||
|
ctaLabel,
|
||||||
|
ctaTo,
|
||||||
|
ctaExternal = false,
|
||||||
|
}: SettingsUpsellCardProps) => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
|
||||||
|
|
||||||
|
const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`;
|
||||||
|
|
||||||
|
const ctaContent = (
|
||||||
|
<>
|
||||||
|
{ctaLabel ?? <Trans>Upgrade Plan</Trans>}
|
||||||
|
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 overflow-hidden rounded-xl border-2 ring-4 ring-muted/70 md:grid md:grid-cols-[1.08fr_0.92fr] xl:-mx-8">
|
||||||
|
{/*
|
||||||
|
* `min-w-0` on both grid items: `fr` tracks have an `auto` content
|
||||||
|
* minimum, so long preview content (e.g. a wide mono domain line) would
|
||||||
|
* otherwise widen the right track beyond its 0.92fr share — and
|
||||||
|
* re-balance the whole grid on every preview cycle (layout shift).
|
||||||
|
*/}
|
||||||
|
<div className="flex min-w-0 flex-col items-start p-6 md:p-8">
|
||||||
|
<Badge size="small">
|
||||||
|
<LockIcon className="mr-1 h-3 w-3" />
|
||||||
|
<span className="uppercase">{planLabel}</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<h3 className="mt-4 font-semibold text-xl">{title}</h3>
|
||||||
|
|
||||||
|
<p className="mt-2 max-w-[40ch] text-muted-foreground text-sm">{description}</p>
|
||||||
|
|
||||||
|
<ul className="mt-6 space-y-3">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2.5 text-sm">
|
||||||
|
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-documenso-200">
|
||||||
|
<CheckIcon className="h-3 w-3 text-documenso-800" strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{canManageBilling ? (
|
||||||
|
<Button className="mt-8" asChild>
|
||||||
|
{ctaExternal ? (
|
||||||
|
<a href={ctaHref} target="_blank" rel="noreferrer">
|
||||||
|
{ctaContent}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link to={ctaHref}>{ctaContent}</Link>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="mt-8 text-muted-foreground text-xs">
|
||||||
|
<Trans>Contact your organisation owner to upgrade plans.</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex min-w-0 flex-col justify-center gap-3 border-t bg-muted p-6 md:border-t-0 md:border-l md:p-8"
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||||
|
import { FingerprintIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { EASE, POP, SPRING } from './motion';
|
||||||
|
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||||
|
import { useTimedCycle } from './use-timed-cycle';
|
||||||
|
|
||||||
|
export const SsoPortalUpsell = () => {
|
||||||
|
const isReducedMotion = useReducedMotion();
|
||||||
|
const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsUpsellCard
|
||||||
|
planLabel={<Trans>Enterprise</Trans>}
|
||||||
|
title={<Trans>Unlock the Organisation SSO Portal</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>
|
||||||
|
Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan.
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
features={[
|
||||||
|
<Trans key="oidc">Works with any OIDC provider — Okta, Entra ID, Google and more</Trans>,
|
||||||
|
<Trans key="jit">Accounts are automatically added to your organisation on sign-in</Trans>,
|
||||||
|
<Trans key="control">Restrict sign-ins by email domain and choose the default role</Trans>,
|
||||||
|
]}
|
||||||
|
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||||
|
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||||
|
ctaExternal
|
||||||
|
preview={
|
||||||
|
<div className="mx-auto w-full max-w-xs">
|
||||||
|
<div className="relative h-[236px]">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{sceneIndex === 0 && <PortalScene key="portal" isStatic={isReducedMotion ?? false} />}
|
||||||
|
{sceneIndex === 1 && <RedirectScene key="redirect" />}
|
||||||
|
{sceneIndex === 2 && <SuccessScene key="success" />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute-positioned panel each scene renders in, handling the shared
|
||||||
|
* slide-and-fade transition between scenes.
|
||||||
|
*/
|
||||||
|
const ScenePanel = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.34, ease: EASE }}
|
||||||
|
className="absolute inset-0 flex flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-6 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene 1: the organisation's SSO portal, with a timed faux press on the
|
||||||
|
* "Continue with SSO" button (cursor flies in, button dips, sheen sweeps).
|
||||||
|
*
|
||||||
|
* When `isStatic` is set (reduced motion) every element renders with
|
||||||
|
* `initial={false}`, skipping entrance and press animations.
|
||||||
|
*/
|
||||||
|
const PortalScene = ({ isStatic }: { isStatic: boolean }) => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const rise = (delay: number) => ({
|
||||||
|
initial: isStatic ? false : { y: 10, opacity: 0 },
|
||||||
|
animate: { y: 0, opacity: 1 },
|
||||||
|
transition: { ...SPRING, delay },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScenePanel>
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : { scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ ...POP, delay: 0.04 }}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-documenso-200 font-semibold text-documenso-900">
|
||||||
|
{([...organisation.name][0] ?? 'D').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.p {...rise(0.12)} className="mt-3.5 font-semibold text-sm">
|
||||||
|
<Trans>Welcome to {organisation.name}</Trans>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.p {...rise(0.18)} className="mt-1 text-muted-foreground text-xs">
|
||||||
|
<Trans>Single sign-on</Trans>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<div className="relative mt-4 w-full">
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : { y: 10, opacity: 0, scale: 1 }}
|
||||||
|
animate={{ y: 0, opacity: 1, scale: [1, 1, 0.955, 1] }}
|
||||||
|
transition={{
|
||||||
|
y: { ...SPRING, delay: 0.24 },
|
||||||
|
opacity: { duration: 0.3, delay: 0.24 },
|
||||||
|
scale: { duration: 2.6, times: [0, 0.63, 0.72, 0.84], ease: 'easeOut' },
|
||||||
|
}}
|
||||||
|
className="relative flex h-[38px] w-full items-center justify-center overflow-hidden rounded-md bg-foreground font-semibold text-background text-sm"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Trans>Continue with SSO</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={isStatic ? false : { x: '-130%' }}
|
||||||
|
animate={{ x: '150%' }}
|
||||||
|
transition={{ duration: 0.85, delay: 1.75, ease: 'easeOut' }}
|
||||||
|
className="absolute top-0 bottom-0 left-[20%] w-3/5"
|
||||||
|
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.45), transparent)' }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.svg
|
||||||
|
initial={isStatic ? false : { x: 30, y: 30, opacity: 0, scale: 1 }}
|
||||||
|
animate={{
|
||||||
|
x: [30, 30, 0, 0, 0],
|
||||||
|
y: [30, 30, 0, 0, 0],
|
||||||
|
opacity: [0, 1, 1, 1, 0],
|
||||||
|
scale: [1, 1, 1, 0.82, 1],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 2.6, times: [0, 0.3, 0.63, 0.72, 0.94], ease: EASE }}
|
||||||
|
width={17}
|
||||||
|
height={17}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="absolute right-[26px] -bottom-2.5 fill-foreground stroke-background"
|
||||||
|
>
|
||||||
|
<path d="M4 2.5 19 12l-6.6 1.4L9.7 19.6z" />
|
||||||
|
</motion.svg>
|
||||||
|
</div>
|
||||||
|
</ScenePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene 2: redirecting to the identity provider, with a rotating ring around
|
||||||
|
* a fingerprint tile and a filling progress bar.
|
||||||
|
*/
|
||||||
|
const RedirectScene = () => {
|
||||||
|
return (
|
||||||
|
<ScenePanel>
|
||||||
|
<div className="relative flex h-[46px] w-[46px] items-center justify-center">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 0.95, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
|
||||||
|
className="absolute inset-0 rounded-full border-2"
|
||||||
|
style={{ borderTopColor: '#A2E771' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-muted">
|
||||||
|
<FingerprintIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.6} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ y: 8, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ ...SPRING, delay: 0.08 }}
|
||||||
|
className="mt-3.5 max-w-[22ch] text-sm"
|
||||||
|
>
|
||||||
|
<Trans>Redirecting to your identity provider</Trans>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<div className="mt-4 h-1 w-[140px] overflow-hidden rounded-full bg-border">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: '0%' }}
|
||||||
|
animate={{ width: '100%' }}
|
||||||
|
transition={{ duration: 1.35, ease: 'easeInOut' }}
|
||||||
|
className="h-full rounded-full bg-documenso"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScenePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene 3: signed in, with an expanding pulse ring, a popping green circle,
|
||||||
|
* a drawn checkmark and the signed-in member's email.
|
||||||
|
*/
|
||||||
|
const SuccessScene = () => {
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScenePanel>
|
||||||
|
<div className="relative h-10 w-10">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.7, opacity: 0.85 }}
|
||||||
|
animate={{ scale: 2.1, opacity: 0 }}
|
||||||
|
transition={{ duration: 1.1, ease: 'easeOut' }}
|
||||||
|
className="absolute inset-0 rounded-full border-2"
|
||||||
|
style={{ borderColor: '#A2E771' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.4, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={POP}
|
||||||
|
className="absolute inset-0 flex items-center justify-center rounded-full bg-documenso-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={19}
|
||||||
|
height={19}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.4}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-documenso-900"
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
d="M20 6 9 17l-5-5"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.12, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ y: 10, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ ...SPRING, delay: 0.16 }}
|
||||||
|
className="mt-3.5 font-semibold text-sm"
|
||||||
|
>
|
||||||
|
<Trans>Signed in</Trans>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ y: 10, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ ...SPRING, delay: 0.24 }}
|
||||||
|
className="mt-1 font-mono text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</motion.p>
|
||||||
|
</ScenePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds each scene is shown before advancing: portal, redirect,
|
||||||
|
* success.
|
||||||
|
*/
|
||||||
|
const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000];
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useReducedMotion } from 'framer-motion';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycles an index through `durations.length` steps, waiting `durations[i]`
|
||||||
|
* milliseconds on step `i` before advancing to the next.
|
||||||
|
*
|
||||||
|
* When the cycle wraps past the last step it continues from `loopStartIndex`
|
||||||
|
* (default `0`), letting consumers play intro-only steps exactly once and
|
||||||
|
* then loop through the remaining steps forever.
|
||||||
|
*
|
||||||
|
* Under `prefers-reduced-motion` the cycle never starts and the index stays
|
||||||
|
* at 0, so consumers render their initial state statically.
|
||||||
|
*
|
||||||
|
* Pass module-level constants for `durations` and `loopStartIndex` — their
|
||||||
|
* identities are intentionally not dependencies.
|
||||||
|
*/
|
||||||
|
export const useTimedCycle = (durations: number[], loopStartIndex = 0) => {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
const isReducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReducedMotion || durations.length === 0) {
|
||||||
|
setIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = 0;
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const next = current + 1;
|
||||||
|
|
||||||
|
current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next;
|
||||||
|
|
||||||
|
setIndex(current);
|
||||||
|
timeout = setTimeout(tick, durations[current]);
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout = setTimeout(tick, durations[0]);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [isReducedMotion]);
|
||||||
|
|
||||||
|
return index;
|
||||||
|
};
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import type { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
|
|
||||||
|
|
||||||
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
|
|
||||||
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
|
|
||||||
|
|
||||||
export type TeamEmailDropdownProps = {
|
|
||||||
team: Awaited<ReturnType<typeof getTeamWithEmail>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
|
|
||||||
trpc.team.email.verification.resend.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Email verification has been resent`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`Unable to resend verification at this time. Please try again.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
|
||||||
{!team.teamEmail && team.emailVerification && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={isResendingEmailVerification}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void resendEmailVerification({ teamId: team.id });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isResendingEmailVerification ? (
|
|
||||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<Trans>Resend verification</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{team.teamEmail && (
|
|
||||||
<TeamEmailUpdateDialog
|
|
||||||
teamEmail={team.teamEmail}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Edit</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TeamEmailDeleteDialog
|
|
||||||
team={team}
|
|
||||||
teamName={team.name}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
|
||||||
<X className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Remove</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { getSettingsNavGroups, type SettingsNavGroup, type SettingsNavItem } from '@documenso/lib/utils/settings-nav';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Link, Outlet, useLocation } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
import { SettingsScopeBreadcrumb } from './settings-scope-breadcrumb';
|
||||||
|
import { UnifiedSettingsSidebar } from './unified-settings-sidebar';
|
||||||
|
import { UnifiedSettingsSidebarMobile } from './unified-settings-sidebar-mobile';
|
||||||
|
|
||||||
|
export type UnifiedSettingsScope = 'organisation' | 'team' | 'account';
|
||||||
|
|
||||||
|
export type UnifiedSettingsLayoutProps = {
|
||||||
|
activeScope: UnifiedSettingsScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The team the user last worked in, read from the `preferred-team-url` cookie by the
|
||||||
|
* layout's loader. Used to keep the sidebar's team switcher stable at organisation and
|
||||||
|
* account scope, where the URL carries no team.
|
||||||
|
*/
|
||||||
|
preferredTeamUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a group's items and find the item (and its parent if it's a sub-nav child)
|
||||||
|
* that matches the current pathname most specifically. Returns the labels to use
|
||||||
|
* as breadcrumb crumbs (e.g. ['Preferences', 'Document'] or just ['Members']).
|
||||||
|
*/
|
||||||
|
const findActiveCrumbs = (group: SettingsNavGroup | null, pathname: string): MessageDescriptor[] => {
|
||||||
|
if (!group) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestMatch: SettingsNavItem | null = null;
|
||||||
|
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (item.isSubNavParent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === item.path || pathname.startsWith(`${item.path}/`)) {
|
||||||
|
if (!bestMatch || item.path.length > bestMatch.path.length) {
|
||||||
|
bestMatch = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestMatch) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestMatch.isSubNav) {
|
||||||
|
const parent = group.items.find((it) => it.isSubNavParent);
|
||||||
|
return parent ? [parent.label, bestMatch.label] : [bestMatch.label];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [bestMatch.label];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UnifiedSettingsLayout = ({ activeScope, preferredTeamUrl = null }: UnifiedSettingsLayoutProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { organisations } = useSession();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const contentPaneRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
// Scroll back to the top when navigating between settings pages.
|
||||||
|
useEffect(() => {
|
||||||
|
contentPaneRef.current?.scrollTo(0, 0);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// An organisation is worth showing in the sidebar if it has settings the user can reach —
|
||||||
|
// either the organisation's own, or those of a team inside it.
|
||||||
|
const hasReachableSettings = (org: (typeof organisations)[number]) =>
|
||||||
|
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
|
||||||
|
org.teams.some((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
|
||||||
|
|
||||||
|
const organisation =
|
||||||
|
currentOrganisation ??
|
||||||
|
organisations.find((org) => org.teams.some((t) => t.url === preferredTeamUrl) && hasReachableSettings(org)) ??
|
||||||
|
organisations.find(hasReachableSettings) ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const manageableTeams = organisation?.teams.filter((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
|
||||||
|
|
||||||
|
const teamForSidebar =
|
||||||
|
team ?? manageableTeams?.find((t) => t.url === preferredTeamUrl) ?? manageableTeams?.[0] ?? null;
|
||||||
|
|
||||||
|
const sidebarTeamUrl = teamForSidebar?.url ?? null;
|
||||||
|
|
||||||
|
// Sync the selected team URL in the sidebar into the preferred team URL cookie.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarTeamUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
|
||||||
|
body.append('teamUrl', sidebarTeamUrl);
|
||||||
|
|
||||||
|
void fetch('/api/preferred-team', { method: 'POST', body });
|
||||||
|
}, [sidebarTeamUrl]);
|
||||||
|
|
||||||
|
const groups = getSettingsNavGroups({
|
||||||
|
organisation: organisation
|
||||||
|
? {
|
||||||
|
url: organisation.url,
|
||||||
|
currentOrganisationRole: organisation.currentOrganisationRole,
|
||||||
|
organisationClaim: organisation.organisationClaim,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
team: teamForSidebar ? { url: teamForSidebar.url, currentTeamRole: teamForSidebar.currentTeamRole } : null,
|
||||||
|
hasManageableBillingOrgs: organisations.some((org) =>
|
||||||
|
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const canManageOrg =
|
||||||
|
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
|
||||||
|
|
||||||
|
// Must be derived from the team in the URL context, NOT from `teamForSidebar` — the
|
||||||
|
// latter falls back to any manageable team in the org, which would let a team manager
|
||||||
|
// through the organisation-scope guard (and `useOptionalCurrentTeam()` resolves for any
|
||||||
|
// member regardless of role, which would let a plain member through the team guard).
|
||||||
|
const canManageCurrentTeam = team !== null && canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole);
|
||||||
|
|
||||||
|
// Account pages are available to every user. The organisation and team scopes each
|
||||||
|
// require the manage permission for THAT scope — they are not interchangeable.
|
||||||
|
const isAuthorised = match(activeScope)
|
||||||
|
.with('account', () => true)
|
||||||
|
.with('organisation', () => canManageOrg)
|
||||||
|
.with('team', () => canManageCurrentTeam)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
if (!isAuthorised) {
|
||||||
|
return (
|
||||||
|
<GenericErrorLayout
|
||||||
|
errorCode={401}
|
||||||
|
errorCodeMap={{
|
||||||
|
401: {
|
||||||
|
heading: msg`Unauthorized`,
|
||||||
|
subHeading: msg`401 Unauthorized`,
|
||||||
|
message: msg`You are not authorized to access this page.`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
primaryButton={
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/settings/profile">
|
||||||
|
<Trans>Go to your settings</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
secondaryButton={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeName = match(activeScope)
|
||||||
|
.with('account', () => _(msg`Account`))
|
||||||
|
.with('organisation', () => organisation?.name ?? '')
|
||||||
|
.with('team', () => team?.name ?? organisation?.name ?? '')
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
const activeGroup =
|
||||||
|
activeScope === 'account' ? groups.account : activeScope === 'organisation' ? groups.organisation : groups.team;
|
||||||
|
|
||||||
|
const crumbs = findActiveCrumbs(activeGroup, pathname).map((label) => _(label));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:min-h-0 md:flex-1 md:flex-row">
|
||||||
|
<aside className="hover-scrollbar w-full shrink-0 border-b bg-background md:w-80 md:overflow-y-auto md:border-r md:border-b-0">
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<UnifiedSettingsSidebar
|
||||||
|
groups={groups}
|
||||||
|
currentOrgUrl={organisation?.url ?? null}
|
||||||
|
currentTeamUrl={teamForSidebar?.url ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:hidden">
|
||||||
|
<UnifiedSettingsSidebarMobile
|
||||||
|
groups={groups}
|
||||||
|
activeScope={activeScope}
|
||||||
|
currentOrgUrl={organisation?.url ?? null}
|
||||||
|
currentTeamUrl={teamForSidebar?.url ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main ref={contentPaneRef} className="relative flex-1 px-4 md:overflow-y-auto md:px-12 lg:px-16">
|
||||||
|
<div className="mx-auto w-full max-w-3xl py-6 md:py-8" data-testid="unified-settings-content">
|
||||||
|
<SettingsScopeBreadcrumb scope={activeScope} scopeName={scopeName} crumbs={crumbs} />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { SettingsNavGroups, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { SettingsOrgSwitcher } from './settings-org-switcher';
|
||||||
|
import { SettingsTeamSwitcher } from './settings-team-switcher';
|
||||||
|
|
||||||
|
type MobileScope = SettingsNavScope | 'account';
|
||||||
|
|
||||||
|
export type UnifiedSettingsSidebarMobileProps = {
|
||||||
|
groups: SettingsNavGroups;
|
||||||
|
activeScope: MobileScope;
|
||||||
|
currentOrgUrl: string | null;
|
||||||
|
currentTeamUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UnifiedSettingsSidebarMobile = ({
|
||||||
|
groups,
|
||||||
|
activeScope,
|
||||||
|
currentOrgUrl,
|
||||||
|
currentTeamUrl,
|
||||||
|
}: UnifiedSettingsSidebarMobileProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const visibleScopes = useMemo<MobileScope[]>(() => {
|
||||||
|
const scopes: MobileScope[] = [];
|
||||||
|
if (groups.organisation) {
|
||||||
|
scopes.push('organisation');
|
||||||
|
}
|
||||||
|
if (groups.team) {
|
||||||
|
scopes.push('team');
|
||||||
|
}
|
||||||
|
scopes.push('account');
|
||||||
|
return scopes;
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
// Falls back to the Account group rather than rendering nothing — an empty scope group
|
||||||
|
// would leave the mobile viewport with no settings navigation at all.
|
||||||
|
const activeGroup =
|
||||||
|
(activeScope === 'organisation' ? groups.organisation : activeScope === 'team' ? groups.team : null) ??
|
||||||
|
groups.account;
|
||||||
|
|
||||||
|
const selectableItems = useMemo(() => activeGroup.items.filter((item) => !item.isSubNavParent), [activeGroup.items]);
|
||||||
|
|
||||||
|
// The select matches on exact value, but plenty of settings pages are sub-routes of a
|
||||||
|
// nav item (`/settings/security/passkeys`, `/t/x/settings/webhooks/:id`, …). Resolving
|
||||||
|
// to the longest matching item path keeps the trigger populated on those pages instead
|
||||||
|
// of rendering an empty box — mirrors the desktop sidebar's prefix highlighting.
|
||||||
|
const selectedPath = useMemo(() => {
|
||||||
|
let bestMatch: string | undefined;
|
||||||
|
|
||||||
|
for (const item of selectableItems) {
|
||||||
|
if (pathname !== item.path && !pathname.startsWith(`${item.path}/`)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestMatch || item.path.length > bestMatch.length) {
|
||||||
|
bestMatch = item.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}, [selectableItems, pathname]);
|
||||||
|
|
||||||
|
const handleScopeChange = (scope: MobileScope) => {
|
||||||
|
if (scope === activeScope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scope === 'organisation' && groups.organisation) {
|
||||||
|
void navigate(groups.organisation.items[0].path);
|
||||||
|
} else if (scope === 'team' && groups.team) {
|
||||||
|
void navigate(groups.team.items[0].path);
|
||||||
|
} else if (scope === 'account') {
|
||||||
|
void navigate(groups.account.items[0].path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The tab row stays full-bleed (its border-b acts as a full-width divider); the
|
||||||
|
// sections under it get `px-4` to line up with the content pane's own `px-4`
|
||||||
|
// inset, and `pb-4` keeps the last control off the aside's bottom border.
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 pb-4" data-testid="unified-settings-sidebar-mobile">
|
||||||
|
{visibleScopes.length > 1 ? (
|
||||||
|
<div className="flex border-border border-b" role="tablist">
|
||||||
|
{visibleScopes.map((scope) => {
|
||||||
|
const isActive = scope === activeScope;
|
||||||
|
const accent =
|
||||||
|
scope === 'organisation'
|
||||||
|
? 'border-emerald-500 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: scope === 'team'
|
||||||
|
? 'border-blue-500 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'border-foreground text-foreground';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={scope}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => handleScopeChange(scope)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 border-b-2 py-2 text-center font-bold text-xs uppercase tracking-wide',
|
||||||
|
isActive ? accent : 'border-transparent text-muted-foreground',
|
||||||
|
)}
|
||||||
|
data-testid={`unified-settings-mobile-tab-${scope}`}
|
||||||
|
>
|
||||||
|
{scope === 'organisation' && <Trans>Organisation</Trans>}
|
||||||
|
{scope === 'team' && <Trans>Team</Trans>}
|
||||||
|
{scope === 'account' && <Trans>Account</Trans>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-2 text-center font-bold text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
{activeScope === 'organisation' && <Trans>Organisation</Trans>}
|
||||||
|
{activeScope === 'team' && <Trans>Team</Trans>}
|
||||||
|
{activeScope === 'account' && <Trans>Account</Trans>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The organisation switcher is always present in a scoped view — at team scope it's
|
||||||
|
the only way for a user who can't manage the organisation to move between them,
|
||||||
|
since the Organisation tab is absent when they have no organisation pages. */}
|
||||||
|
{activeScope !== 'account' && currentOrgUrl && (
|
||||||
|
<div className="flex flex-col gap-2 px-4">
|
||||||
|
<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />
|
||||||
|
|
||||||
|
{activeScope === 'team' && (
|
||||||
|
<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="mb-1 font-bold text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||||
|
<Trans>Jump to</Trans>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={selectedPath}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
void navigate(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger data-testid="unified-settings-mobile-section-trigger">
|
||||||
|
<SelectValue placeholder={_(msg`Select a section`)} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{selectableItems.map((item) => (
|
||||||
|
<SelectItem key={item.path} value={item.path}>
|
||||||
|
{item.isSubNav ? `${_(msg`Preferences`)} › ${_(item.label)}` : _(item.label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import type { SettingsNavGroups, SettingsNavItem, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@documenso/ui/primitives/collapsible';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { NavLink, useLocation } from 'react-router';
|
||||||
|
|
||||||
|
import { SettingsOrgSwitcher } from './settings-org-switcher';
|
||||||
|
import { SettingsTeamSwitcher } from './settings-team-switcher';
|
||||||
|
|
||||||
|
export type UnifiedSettingsSidebarProps = {
|
||||||
|
groups: SettingsNavGroups;
|
||||||
|
currentOrgUrl: string | null;
|
||||||
|
currentTeamUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UnifiedSettingsSidebar = ({ groups, currentOrgUrl, currentTeamUrl }: UnifiedSettingsSidebarProps) => {
|
||||||
|
return (
|
||||||
|
<aside className="flex w-full flex-col" data-testid="unified-settings-sidebar">
|
||||||
|
{currentOrgUrl && (
|
||||||
|
<div className="p-4">
|
||||||
|
<SidebarGroup
|
||||||
|
heading={<Trans>Organisation Settings</Trans>}
|
||||||
|
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
|
||||||
|
switcher={<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />}
|
||||||
|
items={groups.organisation?.items ?? []}
|
||||||
|
scope="organisation"
|
||||||
|
emptyState={
|
||||||
|
<p
|
||||||
|
className="rounded-md border border-dashed px-3 py-2 text-muted-foreground text-xs"
|
||||||
|
data-testid="unified-settings-organisation-empty-state"
|
||||||
|
>
|
||||||
|
<Trans>
|
||||||
|
You don't have permission to manage this organisation. Switch to another one above, or continue in
|
||||||
|
your team settings below.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.team && currentOrgUrl && (
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<SidebarGroup
|
||||||
|
heading={<Trans>Team Settings</Trans>}
|
||||||
|
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
|
||||||
|
switcher={<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />}
|
||||||
|
items={groups.team.items}
|
||||||
|
scope={groups.team.scope}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('p-4', currentOrgUrl && 'border-t')}>
|
||||||
|
<SidebarGroup
|
||||||
|
heading={<Trans>Account Settings</Trans>}
|
||||||
|
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
|
||||||
|
items={groups.account.items}
|
||||||
|
scope={groups.account.scope}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type SidebarGroupProps = {
|
||||||
|
className?: string;
|
||||||
|
headingClassName?: string;
|
||||||
|
heading: ReactNode;
|
||||||
|
activeBgClassName: string;
|
||||||
|
switcher?: ReactNode;
|
||||||
|
items: SettingsNavItem[];
|
||||||
|
scope: SettingsNavScope;
|
||||||
|
emptyState?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupedNavEntry =
|
||||||
|
| { kind: 'flat'; item: SettingsNavItem }
|
||||||
|
| { kind: 'collapsible'; parent: SettingsNavItem; children: SettingsNavItem[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a flat item list and group consecutive `isSubNav` items under their preceding
|
||||||
|
* `isSubNavParent`. Anything else renders as a flat entry.
|
||||||
|
*/
|
||||||
|
const groupNavEntries = (items: SettingsNavItem[]): GroupedNavEntry[] => {
|
||||||
|
const entries: GroupedNavEntry[] = [];
|
||||||
|
let currentCollapsible: { parent: SettingsNavItem; children: SettingsNavItem[] } | null = null;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isSubNavParent) {
|
||||||
|
currentCollapsible = { parent: item, children: [] };
|
||||||
|
entries.push({ kind: 'collapsible', ...currentCollapsible });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isSubNav && currentCollapsible) {
|
||||||
|
currentCollapsible.children.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCollapsible = null;
|
||||||
|
entries.push({ kind: 'flat', item });
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarGroup = ({
|
||||||
|
className,
|
||||||
|
headingClassName,
|
||||||
|
heading,
|
||||||
|
activeBgClassName,
|
||||||
|
switcher,
|
||||||
|
items,
|
||||||
|
scope,
|
||||||
|
emptyState,
|
||||||
|
}: SidebarGroupProps) => {
|
||||||
|
const grouped = groupNavEntries(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="unified-settings-sidebar-group">
|
||||||
|
<div
|
||||||
|
className={cn('mb-2 font-semibold text-muted-foreground text-xs uppercase tracking-widest', headingClassName)}
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</div>
|
||||||
|
{switcher && <div className="mb-2">{switcher}</div>}
|
||||||
|
|
||||||
|
{items.length === 0 && emptyState}
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
{grouped.map((entry) => {
|
||||||
|
if (entry.kind === 'flat') {
|
||||||
|
return (
|
||||||
|
<FlatNavItem
|
||||||
|
key={`${entry.item.key}-${entry.item.path}`}
|
||||||
|
item={entry.item}
|
||||||
|
activeBgClassName={activeBgClassName}
|
||||||
|
scope={scope}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleNavSection
|
||||||
|
key={`${entry.parent.key}-${entry.parent.path}`}
|
||||||
|
parent={entry.parent}
|
||||||
|
childItems={entry.children}
|
||||||
|
activeBgClassName={activeBgClassName}
|
||||||
|
scope={scope}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatNavItemProps = {
|
||||||
|
item: SettingsNavItem;
|
||||||
|
activeBgClassName: string;
|
||||||
|
scope: SettingsNavScope;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FlatNavItem = ({ item, activeBgClassName, scope }: FlatNavItemProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
// The General items link to the settings root, which prefixes every other
|
||||||
|
// item's path — those require an exact match. Everything else highlights on
|
||||||
|
// its sub-routes too (e.g. Security on /settings/security/passkeys).
|
||||||
|
const isActive =
|
||||||
|
item.key === 'general' ? pathname === item.path : pathname === item.path || pathname.startsWith(`${item.path}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink to={item.path} className="group block" data-testid={`unified-settings-nav-${scope}-${item.key}`} end>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-full justify-start font-normal text-muted-foreground',
|
||||||
|
isActive && cn(activeBgClassName, 'font-medium'),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="mr-2 h-4 w-4" />}
|
||||||
|
{_(item.label)}
|
||||||
|
</Button>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollapsibleNavSectionProps = {
|
||||||
|
parent: SettingsNavItem;
|
||||||
|
childItems: SettingsNavItem[];
|
||||||
|
activeBgClassName: string;
|
||||||
|
scope: SettingsNavScope;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollapsibleNavSection = ({ parent, childItems, activeBgClassName, scope }: CollapsibleNavSectionProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const hasActiveChild = childItems.some((child) => pathname === child.path || pathname.startsWith(`${child.path}/`));
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(hasActiveChild);
|
||||||
|
|
||||||
|
// Auto-open when navigating to a sub-route. Closing while on a sub-route is
|
||||||
|
// respected (state stays closed) until the user navigates away and back.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasActiveChild) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [hasActiveChild]);
|
||||||
|
|
||||||
|
const ParentIcon = parent.icon ? parent.icon : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-full justify-start font-normal text-muted-foreground',
|
||||||
|
// Emphasise the parent when one of its children is the active page.
|
||||||
|
hasActiveChild && 'font-medium text-foreground',
|
||||||
|
)}
|
||||||
|
data-testid={`unified-settings-nav-${scope}-${parent.key}`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
{ParentIcon && <ParentIcon className="mr-2 h-4 w-4" />}
|
||||||
|
{_(parent.label)}
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn('ml-auto h-4 w-4 transition-transform duration-200', isOpen && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
{/* data-[state=closed]:hidden — the `flex` display class would otherwise
|
||||||
|
defeat the `hidden` attribute Radix sets when closed, leaving the
|
||||||
|
empty content box (and its mt-1) inflating the gap below the trigger. */}
|
||||||
|
<CollapsibleContent className="mt-1 flex flex-col gap-1 data-[state=closed]:hidden">
|
||||||
|
{childItems.map((child) => {
|
||||||
|
const isActive = pathname === child.path || pathname.startsWith(`${child.path}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={`${child.key}-${child.path}`}
|
||||||
|
to={child.path}
|
||||||
|
className="group block pl-6"
|
||||||
|
data-testid={`unified-settings-nav-${scope}-${child.key}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-full justify-start font-normal text-muted-foreground',
|
||||||
|
isActive && cn(activeBgClassName, 'font-medium'),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{_(child.label)}
|
||||||
|
</Button>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -76,7 +76,7 @@ export const OrganisationTeamsTable = () => {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to={`/t/${row.original.url}/settings`}>
|
<Link to={`/t/${row.original.url}/settings/general`}>
|
||||||
<Trans>Manage</Trans>
|
<Trans>Manage</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -29,8 +29,6 @@ export const UserOrganisationsTable = () => {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(data);
|
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
data: data || [],
|
data: data || [],
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
@@ -44,31 +42,13 @@ export const UserOrganisationsTable = () => {
|
|||||||
header: _(msg`Organisation`),
|
header: _(msg`Organisation`),
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link
|
<Link to={`/o/${row.original.url}`} preventScrollReset={true}>
|
||||||
to={isPersonalLayoutMode ? `/settings/organisations` : `/o/${row.original.url}`}
|
|
||||||
preventScrollReset={true}
|
|
||||||
>
|
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
|
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
|
||||||
avatarClass="h-12 w-12"
|
avatarClass="h-12 w-12"
|
||||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||||
primaryText={
|
primaryText={<span className="font-semibold text-foreground/80">{row.original.name}</span>}
|
||||||
<span className="font-semibold text-foreground/80">
|
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`}
|
||||||
{isPersonalLayoutMode
|
|
||||||
? _(
|
|
||||||
msg({
|
|
||||||
message: `Personal`,
|
|
||||||
context: `Personal organisation (adjective)`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: row.original.name}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
secondaryText={
|
|
||||||
isPersonalLayoutMode
|
|
||||||
? _(msg`Your personal organisation`)
|
|
||||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
@@ -92,7 +72,7 @@ export const UserOrganisationsTable = () => {
|
|||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
{canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && (
|
{canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && (
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to={`/o/${row.original.url}/settings`}>
|
<Link to={`/o/${row.original.url}/settings/general`}>
|
||||||
<Trans>Manage</Trans>
|
<Trans>Manage</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -117,7 +97,7 @@ export const UserOrganisationsTable = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
}, [isPersonalLayoutMode]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -130,9 +110,6 @@ export const UserOrganisationsTable = () => {
|
|||||||
error={{
|
error={{
|
||||||
enable: isLoadingError,
|
enable: isLoadingError,
|
||||||
}}
|
}}
|
||||||
columnVisibility={{
|
|
||||||
actions: !isPersonalLayoutMode,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
skeleton={{
|
||||||
enable: isLoading,
|
enable: isLoading,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
@@ -154,14 +131,12 @@ export const UserOrganisationsTable = () => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{!isPersonalLayoutMode && (
|
<TableCell>
|
||||||
<TableCell>
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
<div className="flex flex-row justify-end space-x-2">
|
<Skeleton className="h-10 w-20 rounded" />
|
||||||
<Skeleton className="h-10 w-20 rounded" />
|
<Skeleton className="h-10 w-16 rounded" />
|
||||||
<Skeleton className="h-10 w-16 rounded" />
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ function initPosthog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces hydration recoveries (React 19 discards the server HTML and
|
||||||
|
* re-renders on the client instead of dying) so we can track how often
|
||||||
|
* extensions/early clicks interfere with hydration in the wild.
|
||||||
|
*/
|
||||||
|
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
|
||||||
|
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
|
||||||
|
|
||||||
|
if (extractPostHogConfig()) {
|
||||||
|
void import('posthog-js').then(({ default: posthog }) => {
|
||||||
|
if (posthog.__loaded) {
|
||||||
|
posthog.capture('$hydration_recoverable_error', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const locale = detect(fromHtmlTag('lang')) || 'en';
|
const locale = detect(fromHtmlTag('lang')) || 'en';
|
||||||
|
|
||||||
@@ -44,6 +64,7 @@ async function main() {
|
|||||||
<HydratedRouter />
|
<HydratedRouter />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
{ onRecoverableError },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+10
-7
@@ -1,5 +1,6 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { getBasePath } from '@documenso/lib/constants/app';
|
||||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||||
import { createPublicEnv } from '@documenso/lib/utils/env';
|
import { createPublicEnv } from '@documenso/lib/utils/env';
|
||||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
useMatches,
|
useMatches,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||||
|
|
||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
import stylesheet from './app.css?url';
|
import stylesheet from './app.css?url';
|
||||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||||
@@ -68,6 +68,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
|||||||
lang,
|
lang,
|
||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
disableAnimations,
|
disableAnimations,
|
||||||
|
basePath: getBasePath(),
|
||||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||||
// routes that need it) can carry the matching nonce attribute.
|
// routes that need it) can carry the matching nonce attribute.
|
||||||
@@ -90,10 +91,10 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const { theme } = useLoaderData<typeof loader>() || {};
|
const { theme, basePath } = useLoaderData<typeof loader>() || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
<ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
|
||||||
<LayoutContent>{children}</LayoutContent>
|
<LayoutContent>{children}</LayoutContent>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
@@ -111,6 +112,8 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const [theme] = useTheme();
|
const [theme] = useTheme();
|
||||||
|
|
||||||
|
const basePath = data.basePath ?? '';
|
||||||
|
|
||||||
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
||||||
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
||||||
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
||||||
@@ -126,11 +129,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links nonce={nonce(cspNonce)} />
|
<Links nonce={nonce(cspNonce)} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||||
@@ -46,6 +47,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
|||||||
|
|
||||||
const { user, organisations } = useSession();
|
const { user, organisations } = useSession();
|
||||||
|
|
||||||
|
const { layoutMode } = useChildRouteFlags();
|
||||||
|
|
||||||
const teamUrl = params.teamUrl;
|
const teamUrl = params.teamUrl;
|
||||||
const orgUrl = params.orgUrl;
|
const orgUrl = params.orgUrl;
|
||||||
|
|
||||||
@@ -108,23 +111,26 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
|||||||
return (
|
return (
|
||||||
<OrganisationProvider organisation={currentOrganisation}>
|
<OrganisationProvider organisation={currentOrganisation}>
|
||||||
<TeamProvider team={currentTeam || null}>
|
<TeamProvider team={currentTeam || null}>
|
||||||
<OrganisationBillingBanner />
|
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
|
||||||
|
<OrganisationBillingBanner />
|
||||||
|
|
||||||
<OrganisationQuotaBanner />
|
<OrganisationQuotaBanner />
|
||||||
|
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
|
||||||
{banner && !hideHeader && <AppBanner banner={banner} />}
|
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||||
|
|
||||||
{!hideHeader && <Header />}
|
{!hideHeader && <Header fullWidth={layoutMode === 'settings'} />}
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={cn({
|
className={cn({
|
||||||
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader,
|
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader && layoutMode !== 'settings',
|
||||||
})}
|
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
|
||||||
>
|
})}
|
||||||
<Outlet />
|
>
|
||||||
</main>
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</TeamProvider>
|
</TeamProvider>
|
||||||
</OrganisationProvider>
|
</OrganisationProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`} hideDivider>
|
<SettingsHeader hideDivider title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`}>
|
||||||
<ClaimCreateDialog licenseFlags={licenseFlags} />
|
<ClaimCreateDialog licenseFlags={licenseFlags} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function AdminEmailTransportsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title={t`Email Transports`} subtitle={t`Manage all email transports`} hideDivider>
|
<SettingsHeader hideDivider title={t`Email Transports`} subtitle={t`Manage all email transports`}>
|
||||||
<EmailTransportCreateDialog />
|
<EmailTransportCreateDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,11 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title={t`Manage organisation`} subtitle={t`Manage the ${organisation.name} organisation`}>
|
<SettingsHeader
|
||||||
|
hideDivider
|
||||||
|
title={t`Manage organisation`}
|
||||||
|
subtitle={t`Manage the ${organisation.name} organisation`}
|
||||||
|
>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to={`/admin/organisation-insights/${organisationId}`}>
|
<Link to={`/admin/organisation-insights/${organisationId}`}>
|
||||||
<Trans>View insights</Trans>
|
<Trans>View insights</Trans>
|
||||||
@@ -269,10 +273,10 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
<GenericOrganisationAdminForm organisation={organisation} />
|
<GenericOrganisationAdminForm organisation={organisation} />
|
||||||
|
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
hideDivider
|
||||||
title={t`Organisation usage`}
|
title={t`Organisation usage`}
|
||||||
subtitle={t`Current usage against organisation limits.`}
|
subtitle={t`Current usage against organisation limits.`}
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
hideDivider
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OrganisationUsagePanel
|
<OrganisationUsagePanel
|
||||||
@@ -308,6 +312,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
hideDivider
|
||||||
title={t`Manage subscription`}
|
title={t`Manage subscription`}
|
||||||
subtitle={t`Manage the ${organisation.name} organisation subscription`}
|
subtitle={t`Manage the ${organisation.name} organisation subscription`}
|
||||||
className="mt-16"
|
className="mt-16"
|
||||||
@@ -425,6 +430,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
hideDivider
|
||||||
title={t`Danger Zone`}
|
title={t`Danger Zone`}
|
||||||
subtitle={t`Irreversible actions for this organisation`}
|
subtitle={t`Irreversible actions for this organisation`}
|
||||||
className="mt-16"
|
className="mt-16"
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
|
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
|
||||||
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
<Link to={`/o/${org.url}/settings`}>
|
<Link to={`/o/${org.url}/settings/general`}>
|
||||||
<SettingsIcon className="h-4 w-4" />
|
<SettingsIcon className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +212,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||||
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
<Link to={`/t/${team.url}/settings`}>
|
<Link to={`/t/${team.url}/settings/general`}>
|
||||||
<SettingsIcon className="h-4 w-4" />
|
<SettingsIcon className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export default function OrganisationSettingsTeamsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={`/o/${organisation.url}/settings`}>
|
<Link to={`/o/${organisation.url}/settings/general`}>
|
||||||
<Trans>Manage Organisation</Trans>
|
<Trans>Manage Organisation</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -184,7 +184,7 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`/t/${team.url}/settings`}>
|
<Link to={`/t/${team.url}/settings/general`}>
|
||||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Settings</Trans>
|
<Trans>Settings</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
|
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Outlet } from 'react-router';
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const currentOrganisation = useCurrentOrganisation();
|
const currentOrganisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const { layoutMode } = useChildRouteFlags();
|
||||||
|
|
||||||
// Note: We use a key to force a re-render if the team context changes.
|
// Note: We use a key to force a re-render if the team context changes.
|
||||||
// This is required otherwise you would see the wrong page content.
|
// This is required otherwise you would see the wrong page content.
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8" key={currentOrganisation.url}>
|
<div
|
||||||
|
className={cn({
|
||||||
|
'mx-auto w-full max-w-screen-xl px-4 md:px-8': layoutMode !== 'settings',
|
||||||
|
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
|
||||||
|
})}
|
||||||
|
key={currentOrganisation.url}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { redirect } from 'react-router';
|
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
|
||||||
|
|
||||||
export function loader({ params }: Route.LoaderArgs) {
|
|
||||||
if (params.orgUrl) {
|
|
||||||
throw redirect(`/o/${params.orgUrl}/settings/general`);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw redirect('/');
|
|
||||||
}
|
|
||||||
@@ -1,173 +1,45 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { extractCookieFromDocument } from '@documenso/lib/client-only/cookies';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import {
|
|
||||||
Building2Icon,
|
|
||||||
CreditCardIcon,
|
|
||||||
GroupIcon,
|
|
||||||
MailboxIcon,
|
|
||||||
Settings2Icon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
Users2Icon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { FaUsers } from 'react-icons/fa6';
|
|
||||||
import { Link, NavLink, Outlet } from 'react-router';
|
|
||||||
|
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
|
import type { Route } from './+types/o.$orgUrl.settings._layout';
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags(msg`Organisation Settings`);
|
return appMetaTags(msg`Organisation Settings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsLayout() {
|
export const handle: RouteHandle = {
|
||||||
const { t } = useLingui();
|
layoutMode: 'settings',
|
||||||
|
};
|
||||||
|
|
||||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
/**
|
||||||
const organisation = useCurrentOrganisation();
|
* Only runs on the initial document request (SSR) so the first paint has the
|
||||||
|
* correct preferred team without a hydration mismatch.
|
||||||
const organisationSettingRoutes = [
|
*/
|
||||||
{
|
export function loader({ request }: Route.LoaderArgs) {
|
||||||
path: `/o/${organisation.url}/settings/general`,
|
return {
|
||||||
label: t`General`,
|
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
|
||||||
icon: Building2Icon,
|
};
|
||||||
},
|
}
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/document`,
|
/**
|
||||||
label: t`Preferences`,
|
* Runs instead of the server loader on client-side navigations, otherwise every
|
||||||
icon: Settings2Icon,
|
* settings page switch would trigger a `.data` round-trip to the server just to
|
||||||
hideHighlight: true,
|
* read this cookie.
|
||||||
},
|
*
|
||||||
{
|
* The cookie is not `HttpOnly` so it can be read straight from the document.
|
||||||
path: `/o/${organisation.url}/settings/document`,
|
*/
|
||||||
label: t`Document`,
|
export function clientLoader() {
|
||||||
isSubNav: true,
|
return {
|
||||||
},
|
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
|
||||||
{
|
};
|
||||||
path: `/o/${organisation.url}/settings/branding`,
|
}
|
||||||
label: t`Branding`,
|
|
||||||
isSubNav: true,
|
export default function OrganisationSettingsLayout({ loaderData }: Route.ComponentProps) {
|
||||||
},
|
return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />;
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/email`,
|
|
||||||
label: t`Email`,
|
|
||||||
isSubNav: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/email-domains`,
|
|
||||||
label: t`Email Domains`,
|
|
||||||
icon: MailboxIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/teams`,
|
|
||||||
label: t`Teams`,
|
|
||||||
icon: FaUsers,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/members`,
|
|
||||||
label: t`Members`,
|
|
||||||
icon: Users2Icon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/groups`,
|
|
||||||
label: t`Groups`,
|
|
||||||
icon: GroupIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/sso`,
|
|
||||||
label: t`SSO`,
|
|
||||||
icon: ShieldCheckIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `/o/${organisation.url}/settings/billing`,
|
|
||||||
label: t`Billing`,
|
|
||||||
icon: CreditCardIcon,
|
|
||||||
},
|
|
||||||
].filter((route) => {
|
|
||||||
if (!isBillingEnabled && route.path.includes('/billing')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) &&
|
|
||||||
route.path.includes('/email-domains')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
|
|
||||||
route.path.includes('/sso')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
|
|
||||||
return (
|
|
||||||
<GenericErrorLayout
|
|
||||||
errorCode={401}
|
|
||||||
errorCodeMap={{
|
|
||||||
401: {
|
|
||||||
heading: msg`Unauthorized`,
|
|
||||||
subHeading: msg`401 Unauthorized`,
|
|
||||||
message: msg`You are not authorized to access this page.`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
primaryButton={
|
|
||||||
<Button asChild>
|
|
||||||
<Link to={`/o/${organisation.url}`}>
|
|
||||||
<Trans>Go Back</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
secondaryButton={null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="font-semibold text-4xl">
|
|
||||||
<Trans>Organisation Settings</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
|
||||||
{/* Navigation */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{organisationSettingRoutes.map((route) => (
|
|
||||||
<NavLink
|
|
||||||
to={route.path}
|
|
||||||
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
|
|
||||||
key={route.path}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', {
|
|
||||||
'group-aria-[current]:bg-secondary': !route.hideHighlight,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
|
|
||||||
<Trans>{route.label}</Trans>
|
|
||||||
</Button>
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-12 md:col-span-9">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
type TBrandingPreferencesFormSchema,
|
type TBrandingPreferencesFormSchema,
|
||||||
} from '~/components/forms/branding-preferences-form';
|
} from '~/components/forms/branding-preferences-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
@@ -26,16 +26,12 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OrganisationSettingsBrandingPage() {
|
export default function OrganisationSettingsBrandingPage() {
|
||||||
const { organisations } = useSession();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
|
||||||
|
|
||||||
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
|
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -122,17 +118,22 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
|
|
||||||
const settingsHeaderText = t`Branding Preferences`;
|
const settingsHeaderText = t`Branding Preferences`;
|
||||||
|
|
||||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
const settingsHeaderSubtitle = team
|
||||||
? t`Here you can set your general branding preferences.`
|
? t`Here you can set branding preferences for your team.`
|
||||||
: team
|
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||||
? t`Here you can set branding preferences for your team.`
|
|
||||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
const brandingPreferencesFormEnabled =
|
||||||
|
organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div>
|
||||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
<SettingsHeader
|
||||||
|
title={settingsHeaderText}
|
||||||
|
subtitle={settingsHeaderSubtitle}
|
||||||
|
hideDivider={!brandingPreferencesFormEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
|
{brandingPreferencesFormEnabled ? (
|
||||||
<section>
|
<section>
|
||||||
<BrandingPreferencesForm
|
<BrandingPreferencesForm
|
||||||
context="Organisation"
|
context="Organisation"
|
||||||
@@ -167,6 +168,8 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
) : IS_DOCUMENSO_CLOUD() ? (
|
||||||
|
<BrandingUpsell />
|
||||||
) : (
|
) : (
|
||||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
@@ -181,7 +184,7 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
|
|
||||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
|
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||||
<Trans>Update Billing</Trans>
|
<Trans>Update Billing</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CertificatePreferencesForm,
|
||||||
|
type TCertificatePreferencesFormSchema,
|
||||||
|
} from '~/components/forms/certificate-preferences-form';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
|
||||||
|
export default function OrganisationSettingsCertificatesPage() {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||||
|
organisationReference: organisation.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||||
|
|
||||||
|
const onCertificatePreferencesFormSubmit = async (data: TCertificatePreferencesFormSchema) => {
|
||||||
|
try {
|
||||||
|
const { includeSigningCertificate, includeAuditLog } = data;
|
||||||
|
|
||||||
|
if (includeSigningCertificate === null || includeAuditLog === null) {
|
||||||
|
throw new Error('Should not be possible.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateOrganisationSettings({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
data: {
|
||||||
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Certificate preferences updated`,
|
||||||
|
description: t`Your certificate preferences have been updated`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong!`,
|
||||||
|
description: t`We were unable to update your certificate preferences at this time, please try again later`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center rounded-lg py-32">
|
||||||
|
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title={t`Certificates`}
|
||||||
|
subtitle={t`Here you can set certificate and audit log preferences for your organisation. Teams will inherit these settings by default.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<CertificatePreferencesForm
|
||||||
|
canInherit={false}
|
||||||
|
settings={organisationWithSettings.organisationGlobalSettings}
|
||||||
|
onFormSubmit={onCertificatePreferencesFormSubmit}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
|
||||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useLoaderData } from 'react-router';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DocumentPreferencesForm,
|
DocumentPreferencesForm,
|
||||||
@@ -21,23 +17,12 @@ export function meta() {
|
|||||||
return appMetaTags(msg`Document Preferences`);
|
return appMetaTags(msg`Document Preferences`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loader = () => {
|
|
||||||
return {
|
|
||||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrganisationSettingsDocumentPage() {
|
export default function OrganisationSettingsDocumentPage() {
|
||||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
const { organisations } = useSession();
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
|
||||||
|
|
||||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||||
organisationReference: organisation.url,
|
organisationReference: organisation.url,
|
||||||
});
|
});
|
||||||
@@ -51,24 +36,16 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
documentTimezone,
|
documentTimezone,
|
||||||
documentDateFormat,
|
documentDateFormat,
|
||||||
includeSenderDetails,
|
|
||||||
includeSigningCertificate,
|
|
||||||
includeAuditLog,
|
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
defaultRecipients,
|
defaultRecipients,
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
envelopeExpirationPeriod,
|
|
||||||
reminderSettings,
|
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
documentVisibility === null ||
|
documentVisibility === null ||
|
||||||
documentLanguage === null ||
|
documentLanguage === null ||
|
||||||
documentDateFormat === null ||
|
documentDateFormat === null ||
|
||||||
includeSenderDetails === null ||
|
|
||||||
includeSigningCertificate === null ||
|
|
||||||
includeAuditLog === null ||
|
|
||||||
aiFeaturesEnabled === null
|
aiFeaturesEnabled === null
|
||||||
) {
|
) {
|
||||||
throw new Error('Should not be possible.');
|
throw new Error('Should not be possible.');
|
||||||
@@ -81,17 +58,12 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
documentTimezone,
|
documentTimezone,
|
||||||
documentDateFormat,
|
documentDateFormat,
|
||||||
includeSenderDetails,
|
|
||||||
includeSigningCertificate,
|
|
||||||
includeAuditLog,
|
|
||||||
defaultRecipients,
|
defaultRecipients,
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
|
||||||
reminderSettings: reminderSettings ?? undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,18 +91,15 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settingsHeaderText = t`Document Preferences`;
|
const settingsHeaderText = t`Document Preferences`;
|
||||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
const settingsHeaderSubtitle = t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
||||||
? t`Here you can set your general document preferences.`
|
|
||||||
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div>
|
||||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<DocumentPreferencesForm
|
<DocumentPreferencesForm
|
||||||
canInherit={false}
|
canInherit={false}
|
||||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
|
||||||
settings={organisationWithSettings.organisationGlobalSettings}
|
settings={organisationWithSettings.organisationGlobalSettings}
|
||||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||||
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
||||||
@@ -27,8 +27,9 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
|
|||||||
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
|
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||||
|
|
||||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
|
||||||
|
|
||||||
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -96,10 +97,23 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
|||||||
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
||||||
}, [organisation]);
|
}, [organisation]);
|
||||||
|
|
||||||
|
const pageHeader = t`Email Domain Settings`;
|
||||||
|
const pageSubtitle = t`Manage your email domain settings.`;
|
||||||
|
|
||||||
if (!IS_BILLING_ENABLED()) {
|
if (!IS_BILLING_ENABLED()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
|
||||||
|
|
||||||
|
<EmailDomainsUpsell />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoadingEmailDomain) {
|
if (isLoadingEmailDomain) {
|
||||||
return <SpinnerBox className="py-32" />;
|
return <SpinnerBox className="py-32" />;
|
||||||
}
|
}
|
||||||
@@ -132,7 +146,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
|
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
|
||||||
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -10,6 +9,7 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
|
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||||
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
|
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
@@ -19,12 +19,9 @@ export function meta() {
|
|||||||
|
|
||||||
export default function OrganisationSettingsEmailDomains() {
|
export default function OrganisationSettingsEmailDomains() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { organisations } = useSession();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
|
||||||
|
|
||||||
const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains;
|
const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains;
|
||||||
|
|
||||||
if (!IS_BILLING_ENABLED()) {
|
if (!IS_BILLING_ENABLED()) {
|
||||||
@@ -33,7 +30,11 @@ export default function OrganisationSettingsEmailDomains() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title={t`Email Domains`} subtitle={t`Here you can add email domains to your organisation.`}>
|
<SettingsHeader
|
||||||
|
hideDivider
|
||||||
|
title={t`Email Domains`}
|
||||||
|
subtitle={t`Here you can add email domains to your organisation.`}
|
||||||
|
>
|
||||||
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
|
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ export default function OrganisationSettingsEmailDomains() {
|
|||||||
<section>
|
<section>
|
||||||
<OrganisationEmailDomainsDataTable />
|
<OrganisationEmailDomainsDataTable />
|
||||||
</section>
|
</section>
|
||||||
|
) : IS_DOCUMENSO_CLOUD() ? (
|
||||||
|
<EmailDomainsUpsell />
|
||||||
) : (
|
) : (
|
||||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
@@ -55,7 +58,7 @@ export default function OrganisationSettingsEmailDomains() {
|
|||||||
|
|
||||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
|
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||||
<Trans>Update Billing</Trans>
|
<Trans>Update Billing</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function OrganisationSettingsGeneral() {
|
|||||||
|
|
||||||
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
|
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { emailId, emailReplyTo, emailDocumentSettings } = data;
|
const { emailId, emailReplyTo, emailDocumentSettings, includeSenderDetails } = data;
|
||||||
|
|
||||||
await updateOrganisationSettings({
|
await updateOrganisationSettings({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
@@ -36,6 +36,7 @@ export default function OrganisationSettingsGeneral() {
|
|||||||
emailReplyTo: emailReplyTo || null,
|
emailReplyTo: emailReplyTo || null,
|
||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings,
|
emailDocumentSettings,
|
||||||
|
includeSenderDetails: includeSenderDetails ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ export default function OrganisationSettingsGeneral() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div>
|
||||||
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
|
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user