Compare commits

..

9 Commits

Author SHA1 Message Date
ephraimduncan 26f8a56248 fix(pdf): address AcroForm import review feedback 2026-05-27 07:10:42 +00:00
Ephraim Duncan fc4de113de Merge branch 'main' into feat/acroform-field-import 2026-05-27 06:46:53 +00:00
ephraimduncan 5e11db2444 refactor(pdf): simplify AcroForm import code 2026-05-27 01:02:43 +00:00
ephraimduncan 88e836ddbc refactor(pdf): deslop AcroForm import cleanup 2026-05-27 00:39:43 +00:00
ephraimduncan 874243700f fix(pdf): stabilize AcroForm imports 2026-05-27 00:16:03 +00:00
ephraimduncan 824117d47e refactor(pdf): move AcroForm import from upload to editor button
Per-product direction: AcroForm widget to Documenso field creation
should not happen automatically on upload. It must be a deliberate,
opt-in action on a draft envelope.

- Revert AcroForm extraction from create-envelope (route) and
  create-envelope-items upload paths. They no longer thread
  acroFormFields into envelope items or run the extractor.

- Stop flattening on upload (flattenForm: false) so widgets survive
  in the stored PDF until the user opts in.

- New tRPC mutation envelope.field.importFromPdf is the single entry
  point. It loads each item's stored PDF, extracts widgets, creates
  Field rows assigned to the first signable recipient (creating a
  placeholder Recipient 1 SIGNER when none exist), flattens the PDF
  in place, swaps documentDataId, and emits FIELD_CREATED audit log
  entries on DOCUMENT envelopes.

- Editor fields panel gains an "Import from PDF form" button next to
  "Detect with AI", gated to DRAFT envelopes. Success toasts the
  count and revalidates the editor.

- Rewrite acroform-import.spec.ts e2e to the new flow: upload
  preserves widgets and creates zero fields; service call creates
  fields, flattens PDF, audits, and cleans up old DocumentData.

- Invert four DOCUMENT-upload assertions in form-flattening.spec.ts
  to match the new preserve-widgets, no-auto-flatten contract.
  Template and template-to-doc flatten behavior is unchanged.
2026-05-22 18:48:08 +00:00
ephraimduncan d33714a4e5 refactor(pdf): rename Documenteno typo to Documenso in acroform extractor 2026-05-21 13:34:05 +00:00
ephraimduncan b620a8c6d7 fix(pdf): repair acroform extractor heuristic + xfa detection + skip-don't-throw
Eight findings from PR review of feat/acroform-field-import, all verified
against actual source in plan mode before fixing.

P1.1 — getTextFieldFormatHint never produced a hint in practice. PdfDict.get()
returns PdfRef for indirect entries (Adobe almost always emits /AA and /F as
indirect), and String(js) returned "[object Object]" because PdfString and
PdfStream don't override toString. Now we thread a RefResolver via
PDF.context.resolve through every dict lookup, use PdfDict.getDict(key,
resolver) so refs are auto-deref'd, and decode JS bodies via asString() (for
strings) or TextDecoder + getDecodedData() (for streams). The MaxLen probe had
the same bug — also fixed via getNumber(key, resolver). Branch ordering is
restructured so format actions take precedence over every name token, not
just within their own type bucket.

P1.2 — Signed-signature path had no test. Added a stub-based mock test that
drives extractAcroFormFieldsFromPDF against a SignatureField whose isSigned()
returns true and asserts hasSignedSignature: true, fields: [], and an
unsupported entry with reason: 'signed-signature'. Mirrored with a negative
control where isSigned() returns false.

P2.1 — hasXfa reached into PDFForm._acroForm (private readonly) via a cast.
Replaced with public catalog access: pdf.context.catalog.getDict() →
getDict('AcroForm', resolver) → has('XFA'). Removed the
fields.length === 0 short-circuit so pure-XFA docs with no /Fields surface
as xfa-hybrid instead of falling through.

P2.2 — When no signable recipient resolved, the AcroForm branch threw
AppError(NOT_FOUND) inside prisma.$transaction and tore down the entire
envelope creation. Replaced with logger.warn + early skip from the AcroForm
branch only. Matches UNSAFE_createEnvelopeItems' silent-skip behaviour.

P2.6 — Coverage gaps closed with 9 new test cases: encrypted (mock), xfa-
hybrid (mock), signed signature (mock + negative control), listbox unsupported
(mock), no-page-match (mock), format-action precedence (extends fixture),
/TU label fallback (extends fixture), required CHECKBOX (extends fixture
with Ff bit 2), hidden + off-page widgets (extends fixture).

P2.3 / P2.4 / P2.5 — Plan doc updated to match shipped implementation: rot=180
y formula corrected from `top` to `bottom`, heuristic regexes documented as
the lenient substring patterns actually shipped (with explicit false-positive
acknowledgements), signed-signature detection mechanism updated from raw /V
probe to SignatureField.isSigned().

Verification: 26/26 unit tests pass (was 17, +9). lib + trpc + remix
typecheck clean (lib retains 5 pre-existing unrelated errors). biome clean.
2026-05-21 13:23:12 +00:00
ephraimduncan b8a11df768 feat(pdf): import AcroForm widgets as Documenso fields on upload
Detect AcroForm widgets (text, checkbox, radio, dropdown, signature) at upload
time and reuse their geometry as Documenso fields instead of stripping them via
form.flatten(). Imported fields land in the editor as ordinary Field rows
assigned to the first signable recipient, removing the manual re-placement step
users hit when preparing PDFs in Adobe Acrobat.

Extraction runs before normalizePdf so widget geometry is still readable.
Text fields go through a name+format heuristic that maps DATE/NUMBER/EMAIL/
NAME/INITIALS/TEXT, with AcroForm /AA format actions taking precedence over
name tokens. Coordinates are converted via per-rotation transforms (0/90/180/
270) against the rendered page dimensions; widgets fully off-page are
dropped, partial overlap is clamped. Signed signatures (SignatureField.
isSigned()) are detected and skip both the import and the form flatten so
the signature stays valid. Encrypted PDFs, XFA hybrids, malformed PDFs, and
internal extractor errors all return an empty result with skipReason set so
the upload proceeds untouched.

Every imported field carries fieldMeta.source = 'acroform' (new optional on
ZBaseFieldMeta) for future provenance queries. DOCUMENT envelopes emit a
per-field FIELD_CREATED audit entry matching create-envelope-fields.ts.
Recipient assignment picks the first Recipient with role SIGNER or APPROVER
sorted by (signingOrder asc nulls last, id asc); when no signable recipient
exists, a placeholder Recipient 1 SIGNER is created mirroring the
placeholder-pipeline behaviour.
2026-05-21 04:05:12 +00:00
702 changed files with 9951 additions and 35459 deletions
@@ -0,0 +1,430 @@
---
date: 2026-05-21
title: Acroform Field Detection And Reuse
---
## Problem
Users routinely prepare PDFs in Adobe Acrobat (or other PDF editors) with AcroForm fields — signatures, text inputs, dates, checkboxes — and upload them to Documenso. Today those widgets are either stripped (`DOCUMENT` upload flattens via `form.flatten()` in `normalize-pdf.ts`) or preserved as static interactive controls (`TEMPLATE` upload), but never reused as Documenso fields. Users have to re-place every field in the editor.
Issue: https://github.com/documenso/documenso/issues/2697 (labels: `type: enhancement`, `apps: web`).
## Goal
On upload, detect AcroForm fields, map supported types to Documenso fields, persist their page geometry, then flatten the PDF so no duplicate interactive controls remain. Imported fields should be ordinary `Field` rows — visible in the editor, assignable to recipients, signable like any other field.
## Background
The placeholder pipeline (`{{signature, r1}}` style) already does almost everything we need:
- `packages/lib/server-only/pdf/auto-place-fields.ts` extracts `PlaceholderInfo[]` (with `fieldAndMeta: TFieldAndMeta`, top-left percentages, page index), then `convertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)` returns `tx.field.createMany` payloads.
- `packages/trpc/server/envelope-router/create-envelope.ts` per file: `convertToPdf` → optional `insertFormValuesInPdf``normalizePdf({ flattenForm: type !== 'TEMPLATE' })``extractPdfPlaceholders(normalized)``putPdfFileServerSide(cleanedPdf)` → forwards `{ title, documentDataId, placeholders }`.
- `packages/lib/server-only/envelope-item/create-envelope-items.ts` runs the same per-file pipeline when files are appended to an existing envelope.
- `packages/lib/server-only/envelope/create-envelope.ts` consumes `envelopeItems[].placeholders`, creates placeholder `SIGNER` recipients (`recipient.${i}@documenso.com`) when `data.recipients` is empty, then calls `convertPlaceholdersToFieldInputs` + `tx.field.createMany` inside its existing transaction.
`@libpdf/core` exposes the AcroForm primitives we need (verified in `node_modules/@libpdf/core/dist/index.d.mts`):
- `PDF.isEncrypted: boolean`, `PDF.getForm(): PDFForm | null`, `PDF.getPages()[i].ref` + `.getRotation()`.
- `PDFForm.getFields(): FormField[]`.
- `FormField`: `name`, `partialName`, `alternateName` (/TU), `isRequired()`, `isReadOnly()`, `acroField(): PdfDict` (raw dict access), `type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'listbox' | 'signature' | 'button' | 'unknown' | 'non-terminal'`.
- `WidgetAnnotation`: `rect`, `width`, `height`, `pageRef: PdfRef | null`, `isHidden()`, `isPrintable()`, `getOnValue()`.
- Typed subclasses: `CheckboxField.isChecked()` / `getOnValues()` / `getOnValue()`, `DropdownField.getOptions()`, `RadioField.getOptions()`, `SignatureField`, `TextField.getText()`.
`ZBaseFieldMeta` (in `packages/lib/types/field-meta.ts`) already carries `label`, `required`, `readOnly`. We extend it once with `source?: 'acroform'` so imported fields are introspectable without UI changes.
## Scope
In scope: server-side extraction at upload time for v2 envelopes (both new envelopes and items appended to existing envelopes), per-field `FIELD_CREATED` audit log entries for imported fields, and the one-line schema extension to record provenance. Out of scope: editor UI changes (badges, banners, review modals), signing surface changes, v1 path, listbox, button/unknown/non-terminal field types, true radio-group consolidation, recipient inference beyond first-signer selection.
## Field Mapping
### Type resolution
| AcroForm type | Documenso field | Rule |
| --- | --- | --- |
| `signature` (unsigned) | `SIGNATURE` | Import. |
| `signature` (signed — `SignatureField.isSigned()` returns true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). |
| `text` | resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
| `checkbox` | `CHECKBOX` | One Documenso field per widget. |
| `radio` | `RADIO` | One Documenso field per widget. Store group's `getOptions()` in each `fieldMeta.values`. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
| `dropdown` | `DROPDOWN` | Preserve `getOptions()` in `fieldMeta.values`, current selection in `fieldMeta.defaultValue`. |
| `listbox`, `button`, `unknown`, `non-terminal` | — | Skip, return as `AcroFormUnsupportedFieldInfo`. Never block upload. |
### Text-field heuristic (resolved in order — first match wins)
AcroForm format actions take precedence over every name token. If `/AA → /F → /JS` references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
1. **DATE** if `acroField()` carries an additional-actions date format (`/AA``/F``JS` containing `AFDate_FormatEx` or `AFDate_Format`).
2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action.
3. **DATE** if name/alternateName matches `/date|dob|birth/i`.
4. **NUMBER** if name/alternateName matches `/amount|qty|count|number/i` AND `/MaxLen <= 10`.
5. **EMAIL** if name/alternateName matches `/email|e[-_]?mail/i`.
6. **NAME** if name/alternateName matches `/name/i`.
7. **INITIALS** if name/alternateName matches `/initial/i`.
8. Else **TEXT**.
All regexes are case-insensitive and run against `partialName` then `alternateName`.
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. `CustomerName`, `BirthDate`) that strict word-boundary patterns miss. Expected false positives — `username` → NAME, `birth_name` → DATE, `initialize` → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
### Metadata mapping
For every imported field:
- `fieldMeta.required = field.isRequired()` (boolean; omit when false).
- `fieldMeta.readOnly = field.isReadOnly()` (boolean; omit when false). Read-only fields **are** imported (rendered for reference in the editor); the renderer already honours `readOnly`.
- `fieldMeta.label`: `alternateName ?? partialName` for label-supporting types (TEXT, NUMBER, DATE, INITIALS, NAME, EMAIL, DROPDOWN, RADIO, CHECKBOX). SIGNATURE has no label slot — drop it.
- `fieldMeta.source = 'acroform'` on every imported field (see Schema Extension).
### CHECKBOX required semantics
AcroForm "required" on a checkbox means "must be checked". Documenso CHECKBOX has both `required` (field must be present) and `validationRule`/`validationLength` (e.g. "at least N of M"). Mapping:
```ts
if (field.isRequired()) {
fieldMeta.required = true;
fieldMeta.validationRule = 'at-least';
fieldMeta.validationLength = 1;
}
```
This approximates "must be checked to submit" for a single-widget checkbox field.
### Default values (only when `formValues` was NOT provided on this upload)
`formValues` (via `insertFormValuesInPdf`) is authoritative when present — it bakes values into the flattened background, so the imported fields stay empty for the signer to fill. When `formValues` is absent, we copy PDF defaults into `fieldMeta` so the editor preview matches the source PDF:
| Source | Target |
| --- | --- |
| `TextField.getText()` (non-empty) | `fieldMeta.text` (TEXT/DATE/INITIALS/NAME/EMAIL) or `fieldMeta.value` (NUMBER) |
| `DropdownField` current selection | `fieldMeta.defaultValue` |
| `CheckboxField.isChecked()` | `fieldMeta.values[0].checked = true` |
| `RadioField` current selection | `fieldMeta.values[i].checked = true` on the matching option |
Always emit `inserted: false` and `customText: ''` — the signer still confirms each field, defaults are editor-only hints.
All metadata flows through `ZEnvelopeFieldAndMetaSchema.parse(...)` so imported fields match what the editor already expects.
## Pre-extraction Guards
Run in this order before any AcroForm work:
1. **Encrypted PDFs**: if `pdfDoc.isEncrypted` → log `{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }`, return `{ fields: [], unsupported: [] }`. Upload proceeds with zero imported fields.
2. **XFA hybrid**: detect via the catalog's `AcroForm` dict carrying an `XFA` key (best-effort via raw dict access; if @libpdf/core's public surface can't read it, fall through — mirrored AcroForm fields in XFA hybrids are fine to import). When detected, log `{ event: 'acroform-import.skip', reason: 'xfa-hybrid' }` and return empty results.
3. **`getForm()` is null** → return empty results silently.
4. **Top-level try/catch** around steps 16: any throw → `logger.error({ event: 'acroform-import.error', envelopeItemTitle, err })`, return `{ fields: [], unsupported: [] }`. Upload proceeds untouched. Never bubble.
## Coordinate Handling
Reuse the placeholder convention (top-left percentages, see `auto-place-fields.ts:121-138`):
1. Build a page lookup once: `pages = pdfDoc.getPages(); pageByRef = new Map(pages.map((p, i) => [p.ref, i]))`.
2. For each widget:
1. Read `widget.rect = [x1, y1, x2, y2]` (bottom-left, points).
2. Normalize: `left = min(x1, x2)`, `right = max`, `bottom = min(y1, y2)`, `top = max`.
3. Resolve `pageIndex` via `pageByRef.get(widget.pageRef)`. Skip if no match.
4. Read `page.width`, `page.height`, `rot = page.getRotation()` (degrees, normalized to `0|90|180|270`).
5. Apply inverse rotation transform so the field lands at the rendered top-left percentage:
- `rot === 0`: `x = left`, `y = pageH - top`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
- `rot === 90`: `x = bottom`, `y = left`, `w = top - bottom`, `h = right - left`. Page dims swap: `(pageH, pageW)`.
- `rot === 180`: `x = pageW - right`, `y = bottom`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
- `rot === 270`: `x = pageH - top`, `y = pageW - right`, `w = top - bottom`, `h = right - left`. Page dims swap.
6. Out-of-bounds policy: if the entire rect is outside the rotated page bounds, skip + emit `AcroFormUnsupportedFieldInfo` with `reason: 'off-page'`. Otherwise clamp to `[0, renderedW] × [0, renderedH]`.
7. Convert to percentages against the rendered page dimensions from step 5.
8. Apply the existing `MIN_HEIGHT_THRESHOLD` / `DEFAULT_FIELD_HEIGHT_PERCENT` fallback used by placeholders.
3. Skip widgets that are `isHidden()` or have zero/negative `width`/`height` after normalization.
## Ordering
Sort imported fields before `createMany` by `(pageIndex asc, top-to-bottom, left-to-right)`. Concretely: ascending `pageIndex`, then ascending `y` (top-of-page first), then ascending `x` within `±2%` y-buckets so a row of fields stays a row. This matches how a signer visually scans the page; it does not rely on AcroForm `/Tabs` metadata (often wrong).
## Audit Logging
Every imported field emits one `FIELD_CREATED` entry matching `create-envelope-fields.ts:264`'s shape:
```ts
await tx.documentAuditLog.createMany({
data: createdFields.map((f) => createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: { fieldId: f.secondaryId, fieldRecipientEmail, fieldRecipientId, fieldType: f.type },
})),
});
```
The placeholder branch in `create-envelope.ts` is silent today and stays silent — AcroForm import does not retroactively change that. Distinguishing imported vs. placeholder vs. user-placed fields is done via `fieldMeta.source`, not a new audit type.
## Schema Extension
One field added to `ZBaseFieldMeta` in `packages/lib/types/field-meta.ts`:
```ts
export const ZBaseFieldMeta = z.object({
// existing...
source: z.enum(['acroform']).optional(),
});
```
No DB migration (fieldMeta is JSON). No editor change. No API contract change beyond the optional field. Forwards-compatible: future sources (`'placeholder'`, `'figma'`, etc.) extend the enum.
## Plan
### 1. Add the AcroForm extractor
New file `packages/lib/server-only/pdf/acroform-fields.ts`:
```ts
export type AcroFormFieldImportInfo = {
source: 'acroform';
fieldName: string;
widgetIndex: number;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type AcroFormUnsupportedFieldInfo = {
fieldName: string;
acroFormType: string;
reason: 'unsupported-type' | 'hidden' | 'off-page' | 'zero-size' | 'no-page-match' | 'signed-signature';
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/** True when a signed signature widget was found — caller MUST set flattenForm: false for that item. */
hasSignedSignature: boolean;
/** True when extraction returned empty for a reason that should be surfaced in logs but not propagated. */
skipReason?: 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
};
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
): Promise<AcroFormExtractionResult>;
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[];
```
`extractAcroFormFieldsFromPDF`:
- Wraps everything in try/catch (top-level guard).
- Loads via `PDF.load(new Uint8Array(pdf))`.
- Runs pre-extraction guards (encrypted, XFA, null form) — returns early with `skipReason` set.
- Builds the page-ref → index + rotation lookup once.
- Iterates `form.getFields()`, applies the type-resolution heuristic, geometry pipeline, default-value mapping.
- Records signed-signature widgets in `unsupported` with `reason: 'signed-signature'` AND sets `hasSignedSignature = true`.
- Logger is module-scoped (no apiRequestMetadata in this file — pure function).
`convertAcroFormFieldsToFieldInputs` mirrors `convertPlaceholdersToFieldInputs` — pure point→percentage transform, no DB access. After mapping, sort by `(page, y, x)` as in Ordering.
Kept separate from `auto-place-fields.ts`: placeholders are text-driven and emit white rectangles via `whiteoutRegions`; AcroForm import is widget-driven and relies on the post-extraction `form.flatten()` to clean the PDF. Sharing types prematurely would couple both paths.
### 2. Extract AcroForm fields before flattening
In both upload entry points the order becomes:
```
convertToPdf (router only)
→ insertFormValuesInPdf if formValues
→ extractAcroFormFieldsFromPDF(pdf) // new — must run BEFORE normalizePdf
→ const shouldFlatten = type !== 'TEMPLATE' && !extraction.hasSignedSignature
→ normalizePdf({ flattenForm: shouldFlatten })
→ extractPdfPlaceholders(normalized)
→ putPdfFileServerSide(cleanedPdf)
→ forward { placeholders, acroFormFields, formValuesProvided }
```
Why before `normalizePdf`: for `DOCUMENT` uploads `normalizePdf` calls `form.flatten()` and destroys widget geometry. Extraction must read the unflattened buffer. `formValues` filling stays first so user-prefilled values still bake into the flattened background.
When `extraction.hasSignedSignature` is true, also `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`.
`formValuesProvided` (boolean) is forwarded to the converter so the default-value mapping can skip prefill when the user-supplied values pipeline already filled the PDF.
Template flattening policy is unchanged in this plan: templates continue to preserve AcroForm widgets (no `form.flatten()`), so imported fields will visually duplicate the still-interactive PDF widgets in the template preview. Flipping templates to flatten is a follow-up — it's a breaking change for API users relying on template `formValues`.
### 3. Thread `acroFormFields` through `createEnvelope`
Extend `CreateEnvelopeOptions.data.envelopeItems[number]` (`packages/lib/server-only/envelope/create-envelope.ts:70-75`):
```ts
envelopeItems: {
title?: string;
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
acroFormFields?: AcroFormFieldImportInfo[]; // new
formValuesProvided?: boolean; // new — already-applied prefill
}[];
```
Inside the existing transaction (alongside the `itemsWithPlaceholders` branch at `:431-538`), add an `itemsWithAcroFormFields` branch:
- Run AFTER the placeholder branch so `availableRecipients` reflects any placeholder signers it created.
- Recipient resolution:
- **First-signer rule**: pick `availableRecipients.filter(r => r.role === SIGNER || r.role === APPROVER).sort((a, b) => (a.signingOrder ?? Infinity) - (b.signingOrder ?? Infinity) || a.id - b.id)[0]`.
- If none: the placeholder branch may have created `Recipient 1` already — reuse. If still none (no recipients, no placeholders), create one placeholder `SIGNER` via the same `recipient.1@documenso.com` shape used by the placeholder branch.
- All imported fields → that one recipient. User reassigns in editor.
- Call `convertAcroFormFieldsToFieldInputs(item.acroFormFields, resolver, envelopeItem.id)` then `tx.field.createMany(...)` with the same `{ envelopeId, envelopeItemId, recipientId, type, page, positionX, positionY, width, height, customText: '', inserted: false, fieldMeta }` shape used by the placeholder branch.
- Immediately after, emit per-field `FIELD_CREATED` audit log entries (see Audit Logging).
### 4. Mirror in `UNSAFE_createEnvelopeItems`
`packages/lib/server-only/envelope-item/create-envelope-items.ts:47-77` — carry `acroFormFields` and `formValuesProvided` alongside `placeholders` in `envelopeItemsToCreate`. Inside the existing `if (envelope.recipients.length > 0)` block (`:111-160`), after the placeholder loop, run the AcroForm loop using the same first-signer rule (SIGNER|APPROVER, signingOrder asc, id asc). Emit per-field `FIELD_CREATED` entries with `apiRequestMetadata`. If `envelope.recipients.length === 0`, skip — appending widgets to a recipient-less envelope is the user's setup phase and is handled when they add recipients (matches current placeholder behavior on append).
### 5. Log unsupported fields, never block upload
In both entry points, after extraction:
```ts
if (extraction.unsupported.length > 0) {
logger.info({
event: 'acroform-import.unsupported',
envelopeItemTitle,
count: extraction.unsupported.length,
byReason: groupBy(extraction.unsupported, u => u.reason),
});
}
if (extraction.skipReason) {
logger.info({ event: 'acroform-import.skip', envelopeItemTitle, reason: extraction.skipReason });
}
```
No new error type, no upload rejection, no response-shape change. A UI surface for warnings comes later once the upload response has a stable warning shape.
### 6. Schema extension
`packages/lib/types/field-meta.ts`: add `source: z.enum(['acroform']).optional()` to `ZBaseFieldMeta`. Single-line change, no callers need updating because the field is optional.
## Files
| File | Change |
| --- | --- |
| `packages/lib/types/field-meta.ts` | Add `source?: 'acroform'` to `ZBaseFieldMeta`. |
| `packages/lib/server-only/pdf/acroform-fields.ts` | **new** — extractor, converter, types, pre-extraction guards, heuristics, geometry pipeline, ordering. |
| `packages/lib/server-only/envelope/create-envelope.ts` | Extend `CreateEnvelopeOptions.envelopeItems[]` with `acroFormFields` + `formValuesProvided`. Add AcroForm branch beside placeholder branch (~`:431-538`). Emit per-field `FIELD_CREATED` audit entries. |
| `packages/trpc/server/envelope-router/create-envelope.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` in the per-file loop (`:110-141`). Downgrade `flattenForm` when `hasSignedSignature`. Forward `acroFormFields` + `formValuesProvided` into the `envelopeItems` payload (`:135-140`). Log unsupported + skipReason. |
| `packages/lib/server-only/envelope-item/create-envelope-items.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` (`:48-77`). Same flatten downgrade. Carry `acroFormFields` + `formValuesProvided` in `envelopeItemsToCreate`. Add AcroForm loop inside `envelope.recipients.length > 0` (`:111-160`). Per-field audit entries. Log unsupported + skipReason. |
| `packages/lib/server-only/pdf/acroform-fields.test.ts` | **new** — unit suite (see Tests). |
| `packages/app-tests/e2e/scenarios/acroform-import.spec.ts` | **new** — e2e suite (see Tests). |
| `scripts/generate-acroform-test-pdf.mjs` | **new** — one-off generator (committed) producing `assets/acroform-import-test.pdf` + rotated variants. |
| `assets/acroform-import-test.pdf` | **new** — base fixture: one of each supported type. |
| `assets/acroform-import-rotated-90.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-rotated-180.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-rotated-270.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-signed.pdf` | **new** — fixture with one signed signature widget + supported widgets. |
No DB schema change. No new tRPC route. No public API surface change beyond the optional `fieldMeta.source`.
## Tests
### Unit (`packages/lib/server-only/pdf/acroform-fields.test.ts`)
Drive from the committed fixture set; synthesize edge-case PDFs inline via `@libpdf/core`'s form builder where a static file is overkill.
Type resolution:
- text / signature / checkbox / radio / dropdown widgets each produce the expected Documenso field type.
- Heuristic positives: `signed_date` / `dob` → DATE; `initial` / `initials` → INITIALS; `customer_email` → EMAIL; `full_name` / `fname` → NAME; field with `AFNumber_Format` action → NUMBER; field with `MaxLen: 5` + name `qty` → NUMBER; plain `customer_id` → TEXT.
- AcroForm format actions take precedence over name tokens (a field named `customer_name` with an `AFDate_FormatEx` action → DATE).
Metadata:
- `isRequired` / `isReadOnly` round-trip into `fieldMeta`.
- `alternateName` (or `partialName` fallback) → `fieldMeta.label` on label-supporting types; SIGNATURE has no label.
- Required CHECKBOX → `required: true` + `validationRule: 'at-least'` + `validationLength: 1`.
- Every imported field has `fieldMeta.source = 'acroform'`.
Default values:
- TextField with non-empty value AND `formValuesProvided = false``fieldMeta.text` set.
- TextField with non-empty value AND `formValuesProvided = true``fieldMeta.text` NOT set.
- DropdownField selection → `fieldMeta.defaultValue`.
- CheckboxField checked → `values[0].checked = true`.
- RadioField selected → matching `values[i].checked = true`.
Geometry:
- Bottom-left widget rect `[100, 600, 200, 620]` on a 612×792 page → top-left percentages within ±0.01% of expected.
- 90° rotated page: same widget rect → rotated coordinates as defined in Coordinate Handling step 5.
- 180° and 270° rotated pages: same.
- Hidden widgets (annotation flags hidden bit) → skipped.
- Widgets with zero/negative dimensions → skipped.
- Widgets with `pageRef` not in `pdfDoc.getPages()``unsupported` with `reason: 'no-page-match'`.
- Widget rect entirely off-page → `unsupported` with `reason: 'off-page'`.
- Widget rect partially off-page → clamped, imported.
Ordering:
- Two pages × four widgets in scrambled creation order → output sorted by `(page, y, x)`.
Skips and unsupported:
- listbox / button / unknown / non-terminal → `unsupported`, never thrown.
- Encrypted PDF → `skipReason: 'encrypted'`, `fields: []`, no throw.
- XFA hybrid PDF (best-effort detect) → `skipReason: 'xfa-hybrid'` when detectable; otherwise extraction proceeds normally.
- Signed signature widget (`SignatureField.isSigned()` returns true) → `unsupported` with `reason: 'signed-signature'` AND `hasSignedSignature: true`.
- Buffer corruption → top-level try/catch, returns empty + `skipReason: 'error'`, no throw.
### E2E (`packages/app-tests/e2e/scenarios/acroform-import.spec.ts`)
- Upload `assets/acroform-import-test.pdf` as a `DOCUMENT` via the v2 envelope router with one provided SIGNER recipient → assert envelope has one `Field` per supported widget, types match, `positionX/Y/width/height` within ±1% of expected, every field's recipient is that one SIGNER, stored PDF (`documentData`) loaded via `PDF.load` reports `getForm() === null` or `getFields().length === 0`. Audit log contains N `FIELD_CREATED` entries.
- Upload with `formValues` populated → `formValues` persists, imported fields exist but have no default values set in fieldMeta, the flattened PDF reflects the prefilled values.
- Upload with two recipients: one CC + one SIGNER → all imported fields assigned to the SIGNER (CC skipped).
- Upload with two recipients: one SIGNER (signingOrder=1) + one APPROVER (signingOrder=2) → all imported fields assigned to the SIGNER.
- Upload with zero recipients → placeholder `Recipient 1` created (shared with the placeholder branch's behavior; if both placeholders and AcroForm fields exist in the same file, only one `Recipient 1` exists).
- Upload `assets/acroform-import-signed.pdf` → signed signature widget skipped, other widgets imported, stored PDF is NOT flattened (`getForm() !== null`, widgets still present).
- Append `assets/acroform-import-test.pdf` to an existing envelope with one SIGNER via `UNSAFE_createEnvelopeItems` → new `envelopeItem.id` carries the imported fields, all assigned to that SIGNER.
- Append to a recipient-less envelope → AcroForm extraction runs, fields are NOT created (skipped, matching placeholder behavior).
- Upload `TEMPLATE` → template still preserves AcroForm widgets in the stored PDF (current behavior unchanged), imported fields ALSO exist (visual duplication acknowledged in Risks).
- Upload PDF with one `listbox` + one supported `text` field → upload succeeds, only the text field becomes a Documenso field, log line emitted for the listbox.
- Upload rotated PDFs (90/180/270 fixtures) → field geometry lands within ±1% of the expected rendered position on each page.
### Regression
```bash
npx tsc --noEmit -p apps/remix/tsconfig.json
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/form-flattening.spec.ts
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/acroform-import.spec.ts
```
## Behavior Matrix
| Upload | Has AcroForm | Signed sig? | `formValues`? | `recipients`? | Result |
| --- | --- | --- | --- | --- | --- |
| `DOCUMENT` | yes | no | none | 1 SIGNER/APPROVER | All imported fields → that recipient. Stored PDF flat. Per-field `FIELD_CREATED` audit. |
| `DOCUMENT` | yes | no | none | N≥2 mixed roles | All imported fields → first SIGNER|APPROVER by (signingOrder asc, id asc). CC/VIEWER skipped. User reassigns in editor. |
| `DOCUMENT` | yes | no | none | only CC/VIEWER | Treated as "no signable recipients" — placeholder `Recipient 1` SIGNER created. |
| `DOCUMENT` | yes | no | none | none | One placeholder `Recipient 1` SIGNER created (reused if placeholder branch already made one). |
| `DOCUMENT` | yes | no | provided | any | `formValues` filled → flattened values visible → empty supported fields imported with `source: 'acroform'`, no `fieldMeta.text`/`defaultValue` prefill. |
| `DOCUMENT` | yes | yes | any | any | Signed signature(s) skipped + logged. `flattenForm` downgraded to false → stored PDF retains widgets. Other supported widgets imported normally. |
| `DOCUMENT` | no | n/a | any | any | Unchanged. |
| `TEMPLATE` | yes | n/a | any | any | Imported fields created **and** PDF still contains interactive widgets (known artifact, follow-up). |
| Encrypted PDF | n/a | n/a | any | any | Extraction skipped + logged. Upload proceeds with zero AcroForm imports. |
| XFA hybrid (detected) | n/a | n/a | any | any | Extraction skipped + logged. Same as encrypted. |
| Append to existing envelope w/ recipients | yes | no | n/a | n/a | Imported fields → first SIGNER|APPROVER of the envelope. Per-field audit. |
| Append to existing envelope w/o recipients | yes | n/a | n/a | n/a | Skipped (matches current placeholder behavior on append). |
## Out of Scope / Follow-ups
- Flipping `TEMPLATE` uploads to flatten after import — breaking for API users relying on template AcroForm `formValues`.
- Editor UI surface: "Imported from PDF" badge (using `fieldMeta.source`), warning toast for skipped widgets, encrypted/XFA banner. Data is captured now; UI ships separately.
- A signed-AcroForm-signature → completed Documenso signature mapping.
- True radio-group consolidation (one Documenso field per AcroForm radio group instead of per widget) — needs `fieldMeta` schema extension for multi-position groups.
- Same-name multi-widget non-radio fields (one AcroForm text field rendered on N pages) — currently emit N independent Documenso fields; future work could sync values at signing time via a shared `groupId` in fieldMeta.
- Listbox support.
- Recipient inference from PDF authoring metadata (Adobe's role/recipient hints, tab order grouping).
- AcroForm `/Tabs` ordering as a signal — current spatial sort suffices.
## Risks
- **Rotated pages**: covered by inverse-rotation transform with 90/180/270 fixtures gating the unit suite. Skewed rotations (non-cardinal) are not supported; should be rejected as `off-page` if their normalized rect doesn't land within page bounds.
- **Radio groups**: emitting one Documenso field per widget will look right visually but signing semantics differ from a single PDF radio group (each option becomes independently checkable). Gating fixture in e2e covers visual placement; signing semantics divergence is documented and ships as a known limitation.
- **Template behavior**: leaving `TEMPLATE` uploads unflattened means imported fields and live widgets coexist. Acceptable for v1, but the template preview will show duplicated controls.
- **Signed signature + flattenForm downgrade**: a `DOCUMENT` upload containing a signed signature now stores an un-flattened PDF. Existing code paths that assume `DOCUMENT` PDFs are always flat (signing renderer, downstream conversion) MUST be re-verified — add an integration check in the e2e suite that signing still works on the signed-fixture envelope.
- **Recipient ambiguity**: AcroForm widgets don't encode Documenso recipients. Deterministic "all to first signer" + editor review is the safest first cut; smarter assignment is a follow-up.
- **XFA detection**: best-effort; if @libpdf/core's public surface doesn't expose the catalog AcroForm dict, we fall through and import any mirrored AcroForm fields. Acceptable — XFA-only PDFs with no mirror produce empty AcroForm extraction and the upload proceeds. Worst case is a noisy log line on a misclassified hybrid.
- **Heuristic false positives**: expanded heuristic (NAME/EMAIL/NUMBER/DATE/INITIALS) increases the chance of mis-typing a field. Mitigation: every imported field is editable in the editor before sending. False negatives fall through to TEXT (always safe).
@@ -1,122 +0,0 @@
---
date: 2026-05-28
title: Custom Brand Logo Url
---
# Problem
`brandingUrl` (the configured "Brand Website") is persisted and editable in branding
settings, but historically it was never consumed anywhere. It flowed into the database,
the settings form, and the admin read-only view, but never affected any rendered output.
We want `brandingUrl` to actually do something, with deliberately different behavior per
surface.
# Relationship we're going for
`brandingUrl` is an **email-only** linking concept. It is intentionally **not** used on
in-app signing surfaces.
| Surface | Custom branding logo configured | `brandingUrl` behavior |
| --- | --- | --- |
| Transactional emails (logo) | Logo shown | Logo links to `brandingUrl` when it is a safe http(s) URL; otherwise plain image |
| Transactional emails (footer) | n/a | `brandingUrl` rendered as a link in the footer when it is a safe http(s) URL |
| Signing pages (V1 + V2, normal + direct-template) | Logo shown | Ignored — logo is a plain image with no link |
| Signing pages (no custom logo) | Documenso fallback shown | Fallback keeps its internal `/` link |
| Embedded signing | Logo shown | Ignored (logo not linked) |
| Embedded authoring/editor | Logo shown | Ignored |
| Settings / admin branding previews | n/a | Unchanged (display only) |
Rationale:
- On signing pages the recipient is mid-task; sending them off to an external marketing
site via the logo is undesirable, so the custom logo is a plain image there.
- In emails the logo and a footer link to the brand's own site are a normal, expected
pattern and reinforce that the email is legitimately from that brand.
# Decisions
## Scope
- Use `brandingUrl` only in transactional email rendering:
- The shared email logo component links the custom branding logo to `brandingUrl`.
- The shared email footer renders `brandingUrl` as a link.
- On signing surfaces, render a configured custom branding logo as a plain image with no
link wrapper. Leave the Documenso fallback logo's internal `/` link untouched.
- Do not change embedded signing, embedded authoring/editor, or settings/admin previews.
- No Prisma schema or database migration. `brandingUrl` already exists and is editable.
## URL safety
Rendering must be defensive because old/imported data can bypass the branding form's URL
validation. Only treat the stored value as a usable Brand Website when it parses as an
absolute `http:` or `https:` URL.
- Empty, missing, invalid, relative, or non-http(s) values are treated as "no Brand
Website" and produce a plain logo / no footer link.
- Do not mutate stored settings or run a cleanup migration.
- Factored into a single shared helper so both email logo and footer apply identical rules:
- `packages/email/utils/branding-url.ts` -> `getSafeBrandingUrl(value): string | null`.
## Email rendering
- New shared component `packages/email/template-components/template-branding-logo.tsx`
(`TemplateBrandingLogo`) renders either:
- the custom branding logo, wrapped in a `Link` to the safe `brandingUrl` with
`target="_blank"` when one exists, or a plain `Img` when not; or
- the Documenso fallback logo (`/static/logo.png`) when custom branding is disabled or
no logo is set.
- This component replaced the duplicated `brandingEnabled && brandingLogo ? <Img/> : <fallback/>`
ternary that was copy-pasted across all transactional email templates.
- `packages/email/template-components/template-footer.tsx` renders `brandingUrl` as a
footer link (via `getSafeBrandingUrl`) when branding is enabled and the URL is safe.
The branding context already exposes `brandingUrl` (`packages/email/providers/branding.tsx`),
populated by `teamGlobalSettingsToBranding` / `organisationGlobalSettingsToBranding`
(which spread `...settings`), so no additional plumbing into the email branding context was
required.
## Signing rendering
- `apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx`:
custom logo renders as a bare `<img>`. `brandingUrl` is not read; the local branding type
and loader payload no longer carry it.
- `apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx` (V2,
shared by normal and direct-template signing): custom logo renders as a bare `<img>`; the
Documenso fallback keeps its `<Link to="/">`.
- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx`: V1 loader branding payload no
longer includes `brandingUrl`.
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` and
`get-envelope-for-direct-template-signing.ts`: `brandingUrl` removed from the V2
`EnvelopeForSigningResponse.settings` schema/payload since it is not consumed there.
# History
An earlier iteration of this plan wired `brandingUrl` into the in-app signing pages so a
custom logo linked to the Brand Website (external `<a target="_blank">`, internal `/`
fallback otherwise) and added `brandingUrl` to the V1/V2 signing payloads. That direction
was reversed: signing-page logos are now plain images and `brandingUrl` is email-only. The
signing payload additions were removed.
# Test coverage
`packages/app-tests/e2e/signing-branding.spec.ts`:
- V1 normal `/sign/:token`: custom logo is a plain image, not inside a link, and no
`brandingUrl` link is present.
- V2 normal `/sign/:token` and V2 direct-template: same plain-image assertions.
- V2 with no custom logo: Documenso fallback still links to `/`.
- Embedded signing: no custom-logo Brand Website link is rendered.
# Acceptance criteria
- A custom branding logo on any signing surface (V1, V2 normal, V2 direct-template, embedded)
renders as a plain image with no link, and `brandingUrl` is never rendered as a link there.
- Documenso fallback logos continue linking to `/`.
- In transactional emails, when a custom logo and a safe `brandingUrl` are configured, the
email logo links to `brandingUrl` (new tab) and the footer shows the Brand Website link.
- In transactional emails, when `brandingUrl` is empty/invalid/relative/non-http(s), the logo
is a plain image and no footer Brand Website link is shown.
- URL safety is enforced through the single shared `getSafeBrandingUrl` helper.
- Settings/admin branding previews are unchanged.
- No schema or migration changes.
+1 -9
View File
@@ -48,7 +48,7 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[SIGNING]]
# The transport to use for document signing. Available options: local (default) | gcloud-hsm | csc
# The transport to use for document signing. Available options: local (default) | gcloud-hsm
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: The passphrase to use for the local file-based signing transport.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
@@ -70,14 +70,6 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS=
# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH=
# OPTIONAL: The base URL of the Cloud Signature Consortium (CSC) provider for the csc signing transport.
NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=
# OPTIONAL: The OAuth client ID registered with the CSC provider for the csc signing transport.
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=
# OPTIONAL: The OAuth client secret registered with the CSC provider for the csc signing transport.
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
# OPTIONAL: Default signature level for envelopes created on a CSC instance when the caller doesn't specify one. Available options: AES (default) | QES. Explicit AES/QES requests always pass through unchanged.
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
+1 -1
View File
@@ -29,6 +29,6 @@
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
"editor.defaultFormatter": "biomejs.biome"
}
}
+1
View File
@@ -65,6 +65,7 @@ Documenso is an open-source document signing platform built as a **monorepo** us
| Package | Description |
| ---------------------------- | ------------------------- |
| `@documenso/app-tests` | E2E tests (Playwright) |
| `@documenso/tailwind-config` | Shared Tailwind config |
| `@documenso/tsconfig` | Shared TypeScript configs |
## Tech Stack
+1 -1
View File
@@ -182,7 +182,7 @@ For full instructions, requirements, and configuration details, see the [Self Ho
#### Railway
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
#### Render
+1 -1
View File
@@ -60,7 +60,7 @@ We support a variety of deployment methods, and are actively working on adding m
## Railway
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX)
## Render
@@ -1,81 +0,0 @@
---
title: iframe
description: Embed the signing experience directly in your application using an iframe.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<Callout type="warn" title="iframes are not recommended">
Embedding via iframe is not recommended. We strongly recommend using the [official SDKs](/docs/developers/embedding/sdks) instead.
</Callout>
### Basic iframe Embedding
```html
<iframe
src="https://app.documenso.com/embed/sign/abc123xyz"
width="100%"
height="800"
frameborder="0"
allow="clipboard-write"
></iframe>
```
<Callout title="Use the correct embed URL">
The URL you embed depends on the embed mode youre using (for example direct links vs sign-token embeds). Use the
embed URL provided by Documenso for your flow.
</Callout>
### iframe Customization
You can customize the embedded signing experience by passing **encoded options in the iframe URL fragment** (everything
after `#`).
Documenso expects the fragment to be **base64** of:
- `encodeURIComponent(JSON.stringify(options))`
#### Supported options
| Option | Type | Description |
| ------ | ---- | ----------- |
| `name` | `string` | Prefill signer name. |
| `email` | `string` | Prefill signer email. |
| `lockName` | `boolean` | Lock the name field (prevents editing). |
| `lockEmail` | `boolean` | Lock the email field (prevents editing). |
| `language` | `string` | Force the embed language (e.g. `en`). |
| `darkModeDisabled` | `boolean` | Disable dark mode behavior. |
| `allowDocumentRejection` | `boolean` | Allow or disallow document rejection. |
| `css` | `string` | Inject custom CSS into the embed. |
| `cssVars` | `object` | Override embed CSS variables (see the CSS Variables page). |
#### Example
```ts
const buildEmbedSrc = (host: string, token: string) => {
const options = {
name: 'Ada Lovelace',
email: 'ada@example.com',
lockName: true,
lockEmail: true,
language: 'en',
darkModeDisabled: false,
allowDocumentRejection: true,
css: ':root { --radius: 12px; }',
cssVars: {},
};
const encodedOptions = btoa(encodeURIComponent(JSON.stringify(options)));
return `${new URL(`/embed/sign/${token}`, host).toString()}#${encodedOptions}`;
};
```
A complete example can be found in the [Embeds repository](https://github.com/documenso/embeds/blob/main/packages/mitosis/src/sign-document.lite.tsx).
<Callout type="info" title="Why use the URL fragment?">
The fragment is **not sent to the server** as part of the HTTP request, but it is available to the embedded app in
the browser. This makes it a convenient way to pass client-side configuration without changing the base embed URL.
</Callout>
@@ -1,4 +1,4 @@
{
"title": "Embedding",
"pages": ["sdks", "direct-links", "css-variables", "editor", "iframe"]
"pages": ["sdks", "direct-links", "css-variables", "editor"]
}
@@ -8,7 +8,6 @@
"privacy",
"terms",
"security",
"verify-email",
"support"
]
}
@@ -1,68 +0,0 @@
---
title: Verifying Emails from Documenso
description: How to confirm that an email is genuinely from Documenso, and what to do if you receive a suspicious message.
---
import { Callout } from 'fumadocs-ui/components/callout';
## Check the Sender Domain
All email sent by Documenso originates from one of the following domains. If you receive an email claiming to be from Documenso and the sender address does not end in one of these domains, treat it as suspicious.
| Domain | Used for |
| ------------------------ | -------------------------------------------------------------- |
| `app.documenso.com` | Transactional email |
| `documensomail.com` | Transactional email |
| `documensoemail.com` | Transactional email |
| Custom domain | [Enterprise organisations](/docs/users/organisations/email-domains) using a custom email domain |
Typical sender addresses include:
- `noreply@app.documenso.com`
- `noreply@free.documensomail.com`
- `noreply@send.documensoemail.com`
<Callout type="warn">
A misspelling such as `documenso-email.com`, `documensoemaiI.com` (capital i instead of l), or any other variation is not a Documenso domain.
</Callout>
## Types of Email Documenso Sends
Documenso sends email only for the following purposes:
- **Account verification** — confirming your email address when you sign up or change it
- **Password reset** — a link to reset your password that you requested
- **Document invitations** — notifying you that a document has been shared with you to sign, approve, or view
- **Signing reminders** — follow-up reminders for pending document actions
- **Completed document notifications** — confirmation that all parties have signed a document
- **Team invitations** — inviting you to join an organisation or team
## What Documenso Will Never Do
- Ask for your password via email
- Send you an attachment and ask you to open it to verify your identity
- Ask you to confirm payment details or billing information over email
- Send unsolicited marketing emails if you have not opted in
## How to Tell If an Email Is Legitimate
1. **Check the sender address** — the domain must be `documenso.com` or `documensomail.com`
2. **Look at the link destination** — hover over any link before clicking; it should point to `app.documenso.com`
3. **Watch for urgency or threats** — legitimate Documenso emails do not threaten account suspension to pressure you into clicking a link immediately
4. **Verify the action yourself** — if in doubt, log in to [app.documenso.com](https://app.documenso.com) directly (not via the email link) and check whether the document or notification exists there
## Report a Suspicious Email
If you receive an email that appears to impersonate Documenso:
1. Do not click any links or download any attachments
2. Forward the email as an attachment to **support@documenso.com**
3. Delete the email from your inbox
You can also report phishing emails directly to your email provider using their built-in reporting tools.
## Related
- [Security Policy](/docs/policies/security) — Documenso's security practices and vulnerability disclosure process
- [Create an Account](/docs/users/getting-started/create-account) — What to expect during sign-up
- [Security Settings](/docs/users/settings/security) — Enable two-factor authentication and manage sessions
@@ -186,9 +186,9 @@ Documenso requires a certificate to digitally sign documents.
### Transport Selection
| Variable | Description | Default |
| -------------------------------- | ------------------------------------------------- | ------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local`, `gcloud-hsm`, or `csc` | `local` |
| Variable | Description | Default |
| -------------------------------- | ---------------------------------------- | ------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local` or `gcloud-hsm` | `local` |
### Local Signing
@@ -210,36 +210,11 @@ Documenso requires a certificate to digitally sign documents.
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | Base64-encoded certificate chain |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | Google Secret Manager path for certificate retrieval |
### Cloud Signature Consortium (CSC)
Routes signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures (AES/QES). Instance-wide; set `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` to enable. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup walkthrough.
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Without a valid license, the instance will refuse to start in `csc` mode.
| Variable | Description | Default |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------- |
| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller doesn't specify one. `AES` or `QES`. Explicit requests pass through. | `AES` |
The OAuth callback URL registered with the CSC provider is fixed at `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` — register this exact URL with the TSP.
#### Derived Public Variables
The following client-visible variable is **derived automatically** from the private transport at server startup. Do not set it manually — any value set in the environment is overwritten on boot.
| Variable | Derived from | Value |
| ------------------------------------- | -------------------------------------------------- | ------------------------------------------------- |
| `NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` | `NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc'` | `'true'` when CSC mode is active, else `'false'` |
The authoring UI uses this flag to gate features that AES/QES envelopes cannot support (parallel signing, assistant role, dictate next signer). Deriving it from the private transport prevents the client-side flag from drifting from the real server-side configuration.
### Signature Options
| Variable | Description | Default |
| ------------------------------------------- | ----------------------------------------------------------- | ---------- |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures. Optional for `local` / `gcloud-hsm` (signatures omit the timestamp when unset). **Required** when `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` — the instance refuses to start without it. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes#timestamp-authority-resolution). | |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures | |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info embedded in PDF signatures | Webapp URL |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Use `adbe.pkcs7.detached` instead of `ETSI.CAdES.detached` | `false` |
@@ -1,213 +0,0 @@
---
title: CSC (AES / QES)
description: Configure Cloud Signature Consortium signing for Advanced and Qualified Electronic Signatures via a third-party Trust Service Provider.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
The `csc` signing transport routes signatures through a third-party Trust Service Provider (TSP) using the [Cloud Signature Consortium API v1.0.4.0](https://cloudsignatureconsortium.org/). Each recipient authenticates directly with the TSP (Strong Customer Authentication) and the TSP returns a per-recipient signature bound to the document hash. Documenso assembles the resulting PAdES signature inside the PDF.
This transport enables **Advanced Electronic Signatures (AES)** and **Qualified Electronic Signatures (QES)** under eIDAS. See [Signature Levels](/docs/compliance/signature-levels) for the legal framework.
<Callout type="warn">
CSC mode is **instance-wide**: one CSC provider per Documenso install. All envelopes created
while the instance runs in `csc` mode use AES or QES. Switching `NEXT_PRIVATE_SIGNING_TRANSPORT`
is a one-way operational migration — see [Switching Transports](#switching-transports).
</Callout>
<Callout type="warn">
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The
instance refuses to start in `csc` mode without it.
</Callout>
## Prerequisites
{/* prettier-ignore */}
<Steps>
<Step>
### A TSP account
Establish a relationship with a CSC-compatible Trust Service Provider. The TSP issues qualified or advanced certificates to your signers, holds the private keys in its HSM, and exposes a CSC v1.0.4.0-compliant API.
</Step>
<Step>
### OAuth client credentials
Register Documenso as an OAuth client with the TSP. You will receive a client ID and client secret, and must supply Documenso's callback URL when registering:
```
${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback
```
The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL` and the route mount path. There is no env var to override it; ensuring the registered URL matches your instance's webapp URL exactly is the operator's responsibility.
</Step>
<Step>
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
### S3 storage (strongly recommended)
CSC produces multiple `DocumentData` rows per envelope item (one per recipient signature, plus the materialised and source rows). Database-backed storage base64-inflates each row by ~33% and is impractical at meaningful PDF sizes. Configure [S3 storage](/docs/self-hosting/configuration/storage) before enabling CSC.
</Step>
</Steps>
## Environment Variables
| Variable | Description | Default |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Set to `csc` | |
| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller does not specify one. `AES` or `QES`. Explicit requests always pass through. | `AES` |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | **Required.** Comma-separated RFC 3161 TSA URLs. Always used for B-LTA archival timestamps at seal time, and also serves as the B-T sign-time fallback when the TSP does not expose `signatures/timestamp`. The instance refuses to start in CSC mode without it. See [Timestamp Authority Resolution](#timestamp-authority-resolution). | |
<Callout type="info">
`NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` is set automatically from
`NEXT_PRIVATE_SIGNING_TRANSPORT` at server startup. Do not set it manually — see
[Environment Variables](/docs/self-hosting/configuration/environment#derived-public-variables).
</Callout>
## Configuration Example
```bash
NEXT_PRIVATE_SIGNING_TRANSPORT=csc
NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=https://api.example-tsp.com/csc/v1
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=documenso-prod
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=...
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=QES
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=http://timestamp.example.com
```
Register `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` (e.g. `https://sign.example.com/api/csc/oauth/callback`) as the OAuth callback URL with the TSP.
## Default Signature Level
`NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` selects the legal tier applied to envelopes that do not specify one explicitly. It is a default, not a capability gate: callers may still create AES or QES envelopes explicitly regardless of this setting.
| Configured value | Caller passes nothing | Caller passes `AES` | Caller passes `QES` |
| ---------------- | --------------------- | ------------------- | ------------------- |
| `AES` (default) | Envelope is `AES` | Envelope is `AES` | Envelope is `QES` |
| `QES` | Envelope is `QES` | Envelope is `AES` | Envelope is `QES` |
Any value other than `AES` or `QES` causes the instance to refuse to start. This prevents silent qualified-to-advanced downgrades from a typo.
## Timestamp Authority Resolution
AES/QES envelopes use TSA-attested timestamps in two distinct phases. Resolution differs per phase.
### Sign time — PAdES B-T per recipient
Each recipient's CMS embeds a signature timestamp (CMS unsigned attribute) so proven time is bound to the recipient's signature itself. Resolution order:
1. If the TSP advertises `signatures/timestamp` in its `info` response (CSC §11.10), the TSP endpoint is used. The call is authorised with **this recipient's** service-scope bearer token — the same one authorising the `signatures/signHash` call alongside it.
2. Otherwise, the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is used (RFC 3161 over HTTP).
Selection is made at boot from the discovered transport, not at runtime; there is no try-then-fall-through. If the chosen source fails, the recipient's sign attempt fails.
### Seal time — PAdES B-LTA archival
The seal-document job emits a single archival `/DocTimeStamp` over the fully-signed envelope (plus DSS for the existing signatures and the timestamp's own chain). This phase is **env-only**: the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is always used.
The archival anchor is the operator's long-term trust anchor and SHOULD point at a dedicated qualified archival TSA (e.g. DigiCert) independent of the per-recipient TSP. We deliberately do not fall back to the TSP at seal time: archive longevity should not be coupled to a TSP that may rotate or revoke, and the seal-document job has no recipient context to carry a service-scope bearer.
### Boot-time guard
The instance refuses to start in CSC mode unless `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set (`CSC_PROVIDER_NO_TSA` at transport construction). The env var is required unconditionally — even when the TSP advertises its own `signatures/timestamp`, seal-time B-LTA archival uses the env TSA. Catching this at boot prevents the failure mode where an envelope signs successfully at B-T and then hangs in `WAITING_FOR_SIGNATURE_COMPLETION` when the seal job throws.
## Switching Transports
`NEXT_PRIVATE_SIGNING_TRANSPORT` is a one-way operational migration. Existing envelopes route per the `signatureLevel` column they were created with — the runtime branching looks at the envelope, not the env var. After a switch:
- Envelopes already at `SES` continue to use the new transport for sealing, but the new transport's signer must produce SES-compatible signatures (only `local` and `gcloud-hsm` qualify).
- Envelopes already at `AES` / `QES` will fail at sign or seal time if the new transport is not `csc`.
Plan migrations during a quiet window with no in-flight envelopes.
## Behavioural Notes
CSC mode changes a number of envelope-authoring behaviours that operators should communicate to users.
### Mutation lock at distribution
For AES/QES envelopes, all authoring routes refuse mutations once the envelope leaves DRAFT. This locks the PDF before any recipient begins Strong Customer Authentication, closing the PDF-swap window that would otherwise allow an owner to replace the PDF between view and sign and break the legal "what you see is what you sign" guarantee.
In practice: edit envelope, recipients, fields, and items freely while DRAFT; once sent, no changes are accepted (including from the API).
### Sequential signing only
Parallel signing produces conflicting incremental updates over the same base PDF, breaking the per-recipient `/ByteRange` invariant. The signing order is forced to `SEQUENTIAL` on AES/QES envelopes — at the schema layer, at send time, and in the UI (the parallel-signing toggle is hidden).
### Assistant role and Dictate Next Signer disabled
Both features modify the recipient set after the envelope is sent, which is incompatible with the AES/QES mutation lock. They are hidden in the UI and rejected at the server schema layer.
### Sidecar PDFs at download
The signed PDF must remain byte-identical to what each recipient's TSP signature authorised — Documenso cannot decorate it after signing. Audit logs and the Certificate of Completion are generated on demand and delivered as separate PDFs:
- `GET /sign/{token}/download` returns the signed PDF only (or a ZIP for multi-item envelopes).
- `GET /sign/{token}/download?version=bundle` returns a ZIP containing the signed PDFs, audit log PDF, and Certificate of Completion.
- The completion email attaches all three.
## Recipient Flow
For context when supporting end users, here is what a recipient experiences on an AES/QES envelope:
1. Opens the email link, lands on the signing page.
2. Documenso redirects to the TSP for Strong Customer Authentication (first visit only; cached for the session lifetime).
3. Fills fields as normal.
4. Clicks Sign → redirected to the TSP for a second authentication round (issues a per-document Signature Activation Data token).
5. Returns to Documenso; the signing call completes within ~15 seconds.
6. Sees the standard completion screen.
If the TSP returns no eligible credentials for the recipient (e.g. they have not enrolled), they see a blocking page directing them to enrol with the TSP and retry.
## Error Codes
CSC-specific error codes surfaced through the standard error channels:
| Code | Meaning | Recovery |
| -------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- |
| `CSC_UNLICENSED` | License flag absent at transport-create | Operator: enable Enterprise Edition, restart |
| `CSC_PROVIDER_INFO_FAILED` | `info` discovery failed at startup | Operator: check TSP availability and `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` |
| `CSC_PROVIDER_NO_TSA` | `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is unset | Operator: configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` |
| `CSC_CREDENTIAL_LIST_EMPTY`| TSP returned no credentials for the user | Recipient: enrol with the TSP |
| `CSC_CERT_INVALID` | Certificate refused at credential validation | Recipient: contact the TSP |
| `CSC_ALGORITHM_REFUSED` | Signature algorithm fails policy | Operator/recipient: TSP does not meet policy (see below) |
| `CSC_SAD_EXPIRED_PRE_SIGN` | Signature Activation Data expired before signing | Recipient: retry from Sign |
| `CSC_TSP_TIMEOUT` | 15-second synchronous timeout reached | Recipient: retry (idempotent — the TSP enforces single-use SAD binding) |
| `CSC_EMBED_FAILED` | Sign-time digest diverged from prep capture | Recipient: retry from Sign |
| `CSC_BASE_DOCUMENT_MUTATED`| Document data changed between prep and sign | Operator: investigate (structural guard violation) |
| `CSC_INSTANCE_MODE_MISMATCH`| Envelope created with wrong level for transport | Caller: use a level matching the instance transport |
| `CSC_REQUEST_FAILED` | TSP HTTP transport failure — network error, non-2xx, or malformed response | Operator: check TSP availability; carries the TSP HTTP status and error in the message |
## Algorithm Policy
Documenso refuses TSP credentials that do not meet the following minimums, at the OAuth callback boundary and again at sign time:
| Class | Allowed | Refused |
| ----- | ---------------------------------- | ------------------------------------------------------ |
| RSA | `key.len >= 2048` | Missing `key.len`, `key.len < 2048` |
| ECDSA | P-256, P-384, P-521 | Missing `key.curve`, P-192, P-224, other curves |
| Hash | SHA-256, SHA-384, SHA-512 | SHA-1, MD5 |
| Other | — | DSA |
This is the union of CSC v1.0.4.0 §11.5 requirements and current cryptographic guidance.
## Related
- [Signature Levels](/docs/compliance/signature-levels) — AES / QES legal framework
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) — overview of all signing transports
- [Environment Variables](/docs/self-hosting/configuration/environment) — full env reference
- [Enterprise Edition](/docs/policies/enterprise-edition) — license requirements
@@ -24,11 +24,6 @@ Self-hosted Documenso instances require a signing certificate. You can generate
description="Hardware-based key protection with Google Cloud KMS."
href="/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm"
/>
<Card
title="CSC (AES / QES)"
description="Route signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures."
href="/docs/self-hosting/configuration/signing-certificate/csc-qes"
/>
<Card
title="Timestamp Server"
description="Add trusted timestamps and customise signature appearance."
@@ -43,7 +38,7 @@ Self-hosted Documenso instances require a signing certificate. You can generate
## Certificate Options
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM', 'CSC (AES / QES)']}>
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM']}>
<Tab value="Self-Signed">
A self-signed certificate is sufficient for most use cases where your industry has no special signing regulations.
@@ -84,18 +79,6 @@ For organisations requiring hardware-based key protection, Documenso supports Go
See [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) for setup instructions.
</Tab>
<Tab value="CSC (AES / QES)">
For Advanced and Qualified Electronic Signatures under eIDAS, Documenso integrates with third-party Trust Service Providers via the Cloud Signature Consortium API. Each recipient authenticates directly with the TSP, which holds the private key and issues the signature.
- Per-recipient identity verification by an accredited TSP
- Legally equivalent to a handwritten signature within the EU (QES)
- Requires an [Enterprise Edition](/docs/policies/enterprise-edition) license
- Instance-wide setting; one CSC provider per Documenso install
See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for setup instructions.
</Tab>
</Tabs>
@@ -1,4 +1,4 @@
{
"title": "Signing Certificate",
"pages": ["...index", "local", "google-cloud-hsm", "csc-qes", "timestamp-server", "troubleshooting"]
"pages": ["...index", "local", "google-cloud-hsm", "timestamp-server", "troubleshooting"]
}
@@ -24,7 +24,7 @@ Before deploying, you need:
The fastest way to deploy Documenso on Railway is using the official template:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
This template automatically provisions:
@@ -39,11 +39,7 @@ Navigate to [documen.so/free](https://documen.so/free) to create a free account.
Provide your name, email address, and create a password. Alternatively, sign up with Google for faster access.
<img
src="/get-started-images/documenso-registration-form.webp"
alt="Documenso registration form with name, email, and password fields"
style={{width: '500px', height: '650px', objectFit: 'contain' }}
/>
{/* TODO: Add screenshot of registration form */}
</Step>
@@ -7,14 +7,14 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
<Callout type="error">
Account deletion is permanent and irreversible. Your account, signatures, and personal data will be
permanently removed, and any active subscription will be cancelled. How your organisations and
documents are handled is explained below.
Account deletion is permanent and irreversible. All documents, signatures, templates, and account
data will be permanently removed. Any active subscription will be cancelled.
</Callout>
## Before Deleting
- Download any documents you need to keep
- Cancel any active subscriptions
- Disable two-factor authentication (required before deletion)
## Delete Your Account
@@ -36,31 +36,6 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
If you have two-factor authentication enabled, you must disable it before deleting your account.
</Callout>
## What Happens to Your Organisations
When you delete your account, the organisations you **own** are permanently deleted along with all of
their teams. If an owned organisation has an active subscription, it is scheduled for cancellation at
the end of the current billing period.
Organisations that you are only a **member** of are not deleted. You are simply removed from them, and
the organisation continues to operate as normal.
## What Happens to Your Documents
The way your documents and templates are handled depends on whether you owned the organisation they
belong to:
- **Organisations you owned** — Completed and in-progress documents are retained in an anonymized form
(reassigned to an internal system account) so the other parties keep their records. Draft documents
and templates are permanently removed.
- **Organisations you were a member of** — Your documents and templates are transferred to the
organisation owner, so they remain accessible to the organisation after you leave.
<Callout type="warn">
Documents that are retained in anonymized form are no longer associated with your account and cannot
be recovered or accessed by you after deletion. Download anything you need to keep beforehand.
</Callout>
---
## See Also
@@ -1,11 +1,10 @@
import { createMDX } from 'fumadocs-mdx/next';
import type { NextConfig } from 'next';
const withMDX = createMDX();
const config: NextConfig = {
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
// biome-ignore lint/suspicious/useAwait: config file
async rewrites() {
return [
{
@@ -14,7 +13,6 @@ const config: NextConfig = {
},
];
},
// biome-ignore lint/suspicious/useAwait: config file
async redirects() {
return [
// ============================================================
+2 -2
View File
@@ -16,7 +16,7 @@
"fumadocs-ui": "16.5.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.2.9",
"next": "16.2.6",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
@@ -30,7 +30,7 @@
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

@@ -68,7 +68,7 @@ export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logoSrc} alt="Documenso" style={{ height: 28 }} />
<img src={logoSrc} alt="Documenso" height="28" />
<span
style={{
color: '#D4D4D8',
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.2.9"
"next": "16.2.6"
},
"devDependencies": {
"@types/node": "^20",
+1 -8
View File
@@ -73,12 +73,5 @@ if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
echo "╚═════════════════════════════════════════════════════════════════════╝"
fi
NEXT_PUBLIC_WEBAPP_URL=$(load_env_var "NEXT_PUBLIC_WEBAPP_URL")
if [ -z "$NEXT_PUBLIC_WEBAPP_URL" ]; then
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
echo "[INFO]: NEXT_PUBLIC_WEBAPP_URL not set, defaulting to $NEXT_PUBLIC_WEBAPP_URL"
fi
echo "[INFO]: Starting Stripe webhook listener..."
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to "$NEXT_PUBLIC_WEBAPP_URL/api/stripe/webhook"
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook
+3 -14
View File
@@ -1,15 +1,5 @@
@import "@documenso/ui/styles/theme.css";
/* Content sources: this app plus the shared `ui` and `email` packages it renders. */
@source "./**/*.{ts,tsx}";
@source "../../../packages/ui/primitives/**/*.{ts,tsx}";
@source "../../../packages/ui/components/**/*.{ts,tsx}";
@source "../../../packages/ui/icons/**/*.{ts,tsx}";
@source "../../../packages/ui/lib/**/*.{ts,tsx}";
@source "../../../packages/email/templates/**/*.{ts,tsx}";
@source "../../../packages/email/template-components/**/*.{ts,tsx}";
@source "../../../packages/email/providers/**/*.{ts,tsx}";
/* Inter Variable Fonts */
@font-face {
font-family: "Inter";
@@ -74,9 +64,8 @@
@layer base {
:root {
/* Consumed by the `--font-*` theme tokens in @documenso/ui/styles/theme.css. */
--font-family-sans: "Inter";
--font-family-signature: "Caveat";
--font-family-noto: "Noto Sans", "Noto Sans Korean", "Noto Sans Japanese", "Noto Sans Chinese";
--font-sans: "Inter";
--font-signature: "Caveat";
--font-noto: "Noto Sans", "Noto Sans Korean", "Noto Sans Japanese", "Noto Sans Chinese";
}
}
@@ -73,7 +73,7 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
</AlertDescription>
</div>
<div className="shrink-0">
<div className="flex-shrink-0">
<Dialog onOpenChange={() => setEnteredEmail('')}>
<DialogTrigger asChild>
<Button variant="destructive">
@@ -82,7 +82,7 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
</DialogTrigger>
<DialogContent>
<DialogHeader className="twv3-space-y-4">
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Delete Account</Trans>
</DialogTitle>
@@ -71,7 +71,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
</AlertDescription>
</div>
<div className="shrink-0">
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">
@@ -80,7 +80,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
</DialogTrigger>
<DialogContent>
<DialogHeader className="twv3-space-y-4">
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Delete Document</Trans>
</DialogTitle>
@@ -90,7 +90,7 @@ export const AdminOrganisationCreateDialog = ({ trigger, ownerUserId, ...props }
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="shrink-0" variant="secondary">
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create organisation</Trans>
</Button>
)}
@@ -109,7 +109,7 @@ export const AdminOrganisationCreateDialog = ({ trigger, ownerUserId, ...props }
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -128,7 +128,7 @@ export const AdminOrganisationDeleteDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="organisationName"
@@ -151,7 +151,7 @@ export const AdminOrganisationDeleteDialog = ({
control={form.control}
name="sendEmailToOwner"
render={({ field }) => (
<FormItem className="twv3-space-x-3 twv3-space-y-0 flex flex-row items-start">
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
id="admin-delete-organisation-send-email"
@@ -76,7 +76,7 @@ export const AdminOrganisationMemberDeleteDialog = ({
</DialogTrigger>
<DialogContent>
<DialogHeader className="twv3-space-y-4">
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Remove Organisation Member</Trans>
</DialogTitle>
@@ -1,155 +0,0 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
export type AdminOrganisationSyncSubscriptionDialogProps = {
organisationId: string;
trigger?: React.ReactNode;
};
const ZAdminOrganisationSyncSubscriptionFormSchema = z.object({
syncClaims: z.boolean(),
});
type TAdminOrganisationSyncSubscriptionFormSchema = z.infer<typeof ZAdminOrganisationSyncSubscriptionFormSchema>;
export const AdminOrganisationSyncSubscriptionDialog = ({
organisationId,
trigger,
}: AdminOrganisationSyncSubscriptionDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const form = useForm<TAdminOrganisationSyncSubscriptionFormSchema>({
resolver: zodResolver(ZAdminOrganisationSyncSubscriptionFormSchema),
defaultValues: {
syncClaims: false,
},
});
const { mutateAsync: syncSubscription } = trpc.admin.organisation.subscription.sync.useMutation();
const onFormSubmit = async (values: TAdminOrganisationSyncSubscriptionFormSchema) => {
try {
await syncSubscription({
organisationId,
syncClaims: values.syncClaims,
});
toast({
title: t`Subscription synced`,
description: t`The organisation subscription has been synced with Stripe.`,
duration: 5000,
});
await navigate(0);
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`Failed to sync subscription`,
description: error.message,
variant: 'destructive',
duration: 10000,
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sync Stripe subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fetch the latest subscription data from Stripe and apply it to this organisation.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="syncClaims"
render={({ field }) => (
<FormItem className="twv3-space-x-3 twv3-space-y-0 flex flex-row items-center">
<FormControl>
<Checkbox
id="admin-sync-subscription-sync-claims"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="admin-sync-subscription-sync-claims"
className="font-normal text-muted-foreground text-sm leading-snug"
>
<Trans>
Sync claims. This will overwrite the current claim with the one resolved from the Stripe
subscription.
</Trans>
</label>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Sync</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -67,7 +67,7 @@ export const AdminSwapSubscriptionDialog = ({
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
const { mutateAsync: swapSubscription } = trpc.admin.organisation.subscription.swap.useMutation();
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
const onSubmit = async () => {
if (!selectedOrgId) {
@@ -128,7 +128,7 @@ export const AdminSwapSubscriptionDialog = ({
</DialogDescription>
</DialogHeader>
<fieldset className="twv3-space-y-4 flex flex-col" disabled={isSubmitting}>
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm">
<Trans>Target Organisation</Trans>
@@ -76,7 +76,7 @@ export const AdminTeamMemberDeleteDialog = ({
</DialogTrigger>
<DialogContent>
<DialogHeader className="twv3-space-y-4">
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Remove Team Member</Trans>
</DialogTitle>
@@ -82,7 +82,7 @@ export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDial
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="shrink-0" variant="secondary">
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create User</Trans>
</Button>
)}
@@ -101,7 +101,7 @@ export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDial
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="email"
@@ -77,7 +77,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
</AlertDescription>
</div>
<div className="shrink-0">
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">
@@ -86,7 +86,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
</DialogTrigger>
<DialogContent>
<DialogHeader className="twv3-space-y-4">
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Delete Account</Trans>
</DialogTitle>
@@ -74,7 +74,7 @@ export const AdminUserDisableDialog = ({ className, userToDisable }: AdminUserDi
</AlertDescription>
</div>
<div className="shrink-0">
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">
@@ -83,7 +83,7 @@ export const AdminUserDisableDialog = ({ className, userToDisable }: AdminUserDi
</DialogTrigger>
<DialogContent>
<DialogHeader className="twv3-space-y-4">
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Disable Account</Trans>
</DialogTitle>
@@ -74,7 +74,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
</AlertDescription>
</div>
<div className="shrink-0">
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button>
@@ -83,7 +83,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
</DialogTrigger>
<DialogContent>
<DialogHeader className="twv3-space-y-4">
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Enable Account</Trans>
</DialogTitle>
@@ -90,7 +90,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
</AlertDescription>
</div>
<div className="shrink-0">
<div className="flex-shrink-0">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="destructive">
@@ -99,7 +99,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
</DialogTrigger>
<DialogContent>
<DialogHeader className="twv3-space-y-4">
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Reset Two Factor Authentication</Trans>
</DialogTitle>
@@ -71,7 +71,7 @@ export const AiFeaturesEnableDialog = ({ open, onOpenChange, onEnabled }: AiFeat
</DialogTitle>
</DialogHeader>
<div className="twv3-space-y-4">
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
<Trans>
Turn on AI detection to automatically find recipients and fields in your documents. AI providers do not
@@ -158,7 +158,7 @@ export const AiFieldDetectionDialog = ({
</DialogTitle>
</DialogHeader>
<div className="twv3-space-y-4">
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
<Trans>
We'll scan your document to find form fields like signature lines, text inputs, checkboxes, and more.
@@ -166,14 +166,14 @@ export const AiFieldDetectionDialog = ({
</Trans>
</p>
<Alert className="twv3-space-y-0 flex items-center gap-2" variant="neutral">
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
</AlertDescription>
</Alert>
<div className="twv3-space-y-1.5">
<div className="space-y-1.5">
<Label htmlFor="context">
<Trans>Context</Trans>
</Label>
@@ -145,7 +145,7 @@ export const AiRecipientDetectionDialog = ({
</Trans>
</p>
<Alert className="twv3-space-y-0 mt-4 flex items-center gap-2" variant="neutral">
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
@@ -50,7 +50,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
<Button className="shrink-0" variant="secondary">
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create claim</Trans>
</Button>
</DialogTrigger>
@@ -2,7 +2,6 @@ import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
@@ -29,7 +28,6 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [backportEmailTransport, setBackportEmailTransport] = useState(false);
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
onSuccess: () => {
@@ -69,33 +67,19 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
await updateClaim({
id: claim.id,
data,
backportEmailTransport,
})
}
licenseFlags={licenseFlags}
formSubmitTrigger={
<>
<div className="twv3-space-x-2 flex items-center">
<Checkbox
id="backport-email-transport"
checked={backportEmailTransport}
onCheckedChange={(checked) => setBackportEmailTransport(checked === true)}
/>
<label htmlFor="backport-email-transport" className="text-muted-foreground text-sm">
<Trans>Backport email transport</Trans>
</label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
</>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
@@ -0,0 +1,243 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export type DocumentMoveToFolderDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveDocumentFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
export const DocumentMoveToFolderDialog = ({
documentId,
open,
onOpenChange,
currentFolderId,
...props
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
defaultValues: {
folderId: currentFolderId,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,
},
{
enabled: open,
},
);
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
useEffect(() => {
if (!open) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await updateDocument({
documentId,
data: {
folderId: data.folderId ?? null,
},
});
const documentsPath = formatDocumentsPath(team.url);
if (data.folderId) {
await navigate(`${documentsPath}/f/${data.folderId}`);
} else {
await navigate(documentsPath);
}
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the document to does not exist.`),
variant: 'destructive',
});
return;
}
if (error.code === AppErrorCode.UNAUTHORIZED) {
toast({
title: _(msg`Error`),
description: _(msg`You are not allowed to move this document.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the document.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a folder to move this document to.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting || currentFolderId === null}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,198 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useCurrentTeam } from '~/providers/team';
import { StackAvatar } from '../general/stack-avatar';
const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
recipients: z.array(z.number()).min(1, {
message: 'You must select at least one item.',
}),
});
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useSession();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === user.id;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =
(!isOwner && !isCurrentTeamDocument) ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
defaultValues: {
recipients: [],
},
});
const {
handleSubmit,
formState: { isSubmitting },
} = form;
const selectedRecipients = useWatch({
control: form.control,
name: 'recipients',
});
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
toast({
title: _(msg`Document re-sent`),
description: _(msg`Your document has been re-sent successfully.`),
duration: 5000,
});
setIsOpen(false);
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be re-sent at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle asChild>
<h1 className="text-center text-xl">
<Trans>Who do you want to remind?</Trans>
</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem key={recipient.id} className="flex flex-row items-center justify-between gap-x-3">
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full border border-neutral-400"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,95 +0,0 @@
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
EmailTransportForm,
type EmailTransportFormValues,
emailTransportFormToConfig,
} from '../forms/email-transport-form';
export type EmailTransportCreateDialogProps = {
trigger?: React.ReactNode;
};
export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: createTransport, isPending } = trpc.admin.emailTransport.create.useMutation({
onSuccess: () => {
toast({
title: t`Transport created.`,
});
setOpen(false);
},
onError: (error) => {
toast({
title: t`Failed to create transport.`,
description: error.message,
variant: 'destructive',
});
},
});
const onFormSubmit = async (values: EmailTransportFormValues) => {
await createTransport({
name: values.name,
fromName: values.fromName,
fromAddress: values.fromAddress,
config: emailTransportFormToConfig(values),
});
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="shrink-0">
<Trans>Add transport</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fill in the details to create a new email transport.</Trans>
</DialogDescription>
</DialogHeader>
<EmailTransportForm
onFormSubmit={onFormSubmit}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};
@@ -1,114 +0,0 @@
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
export type EmailTransportDeleteDialogProps = {
transportId: string;
transportName: string;
subscriptionClaimCount: number;
organisationClaimCount: number;
trigger: React.ReactNode;
};
export const EmailTransportDeleteDialog = ({
transportId,
transportName,
subscriptionClaimCount,
organisationClaimCount,
trigger,
}: EmailTransportDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const isInUse = subscriptionClaimCount + organisationClaimCount > 0;
const { mutateAsync: deleteTransport, isPending } = trpc.admin.emailTransport.delete.useMutation({
onSuccess: () => {
toast({
title: t`Transport deleted.`,
});
setOpen(false);
},
onError: () => {
toast({
title: t`Failed to delete transport.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Are you sure you want to delete the following transport?</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">{transportName}</AlertDescription>
</Alert>
{isInUse && (
<Alert variant="destructive">
<AlertDescription>
<Trans>Warning, this email transport is currently being used by:</Trans>
<ul className="mt-2 list-disc pl-5">
{subscriptionClaimCount > 0 && (
<li>
<Plural value={subscriptionClaimCount} one="# Subscription claim" other="# Subscription claims" />
</li>
)}
{organisationClaimCount > 0 && (
<li>
<Plural value={organisationClaimCount} one="# Organisation claim" other="# Organisation claims" />
</li>
)}
</ul>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () => deleteTransport({ id: transportId })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,126 +0,0 @@
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZSendTestEmailFormSchema = z.object({
to: z.string().email(),
});
type TSendTestEmailFormSchema = z.infer<typeof ZSendTestEmailFormSchema>;
export type EmailTransportSendTestDialogProps = {
transportId: string;
trigger: React.ReactNode;
};
export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTransportSendTestDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: sendTest } = trpc.admin.emailTransport.sendTest.useMutation({
onSuccess: () => {
toast({
title: t`Test email sent.`,
});
setOpen(false);
},
onError: (error) => {
toast({
title: t`Test failed.`,
description: error.message,
variant: 'destructive',
});
},
});
const form = useForm<TSendTestEmailFormSchema>({
resolver: zodResolver(ZSendTestEmailFormSchema),
defaultValues: {
to: '',
},
});
const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => {
await sendTest({ id: transportId, to });
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Send Test Email</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Send a test email using this transport to verify the configuration.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" placeholder={t`test@example.com`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Send</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,104 +0,0 @@
import { trpc } from '@documenso/trpc/react';
import type { TFindEmailTransportsResponse } from '@documenso/trpc/server/admin-router/email-transport/find-email-transports.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
EmailTransportForm,
type EmailTransportFormValues,
emailTransportFormToConfig,
} from '../forms/email-transport-form';
export type EmailTransportUpdateDialogProps = {
transport: TFindEmailTransportsResponse['data'][number];
trigger: React.ReactNode;
};
export const EmailTransportUpdateDialog = ({ transport, trigger }: EmailTransportUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: updateTransport, isPending } = trpc.admin.emailTransport.update.useMutation();
const onFormSubmit = async (values: EmailTransportFormValues) => {
try {
await updateTransport({
id: transport.id,
data: {
name: values.name,
fromName: values.fromName,
fromAddress: values.fromAddress,
config: emailTransportFormToConfig(values),
},
});
toast({
title: t`Transport updated.`,
});
setOpen(false);
} catch {
toast({
title: t`Failed to save transport.`,
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Edit Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Modify the details of the email transport.</Trans>
</DialogDescription>
</DialogHeader>
<EmailTransportForm
isEdit
defaultValues={{
// Pre-fill the non-secret connection settings; secrets stay blank
// and are preserved on save unless re-entered.
...(transport.config ?? {}),
name: transport.name,
fromName: transport.fromName,
fromAddress: transport.fromAddress,
type: transport.type,
}}
onFormSubmit={onFormSubmit}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Save changes</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};
@@ -1,134 +0,0 @@
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
export type EnvelopeCancelDialogProps = {
id: string;
title: string;
trigger?: React.ReactNode;
onCancel?: () => Promise<void> | void;
};
export const EnvelopeCancelDialog = ({ id, title, trigger, onCancel }: EnvelopeCancelDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const trpcUtils = trpcReact.useUtils();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const { mutateAsync: cancelEnvelope, isPending } = trpcReact.envelope.cancel.useMutation({
onSuccess: async () => {
toast({
title: t`Document cancelled`,
description: t`"${title}" has been successfully cancelled`,
duration: 5000,
});
await trpcUtils.document.findDocumentsInternal.invalidate();
await onCancel?.();
setOpen(false);
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`This document could not be cancelled at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
if (open) {
setReason('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are about to cancel <strong>"{title}"</strong>
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document signing process will be stopped</Trans>
</li>
<li>
<Trans>Recipients will be notified that the document was cancelled</Trans>
</li>
<li>
<Trans>The document will remain in your dashboard marked as Cancelled</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Label htmlFor="cancel-reason">
<Trans>Reason (optional)</Trans>
</Label>
<Textarea
id="cancel-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t`Add an optional reason for cancelling this document`}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void cancelEnvelope({ envelopeId: id, reason: reason || undefined })}
variant="destructive"
>
<Trans>Cancel document</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -166,7 +166,7 @@ export const EnvelopeDeleteDialog = ({
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), () => (
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
@@ -1,15 +1,13 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -33,13 +31,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
import { InfoIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type EnvelopeDistributeDialogProps = {
onDistribute?: () => Promise<void>;
@@ -69,7 +66,7 @@ export const EnvelopeDistributeDialog = ({
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
const { toast } = useToast();
const { t, i18n } = useLingui();
const { t } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
@@ -139,27 +136,6 @@ export const EnvelopeDistributeDialog = ({
});
}, [recipientsWithIndex, envelope.authOptions]);
/**
* Whether any fields significantly overlap each other. This is surfaced as a
* non-blocking warning since overlapping fields still allow sending, but can
* complicate the signing process or cause fields to behave unexpectedly.
*/
const hasOverlappingEnvelopeFields = useMemo(
() =>
hasOverlappingFields(
envelope.fields.map((field) => ({
id: field.id,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
})),
),
[envelope.fields],
);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -198,13 +174,9 @@ export const EnvelopeDistributeDialog = ({
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
title: t`Something went wrong`,
description: t`This envelope could not be distributed at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
@@ -228,11 +200,6 @@ export const EnvelopeDistributeDialog = ({
};
useEffect(() => {
// Default the distribution method tab to the envelope's configured setting.
if (isOpen && envelope.documentMeta) {
setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod);
}
// Resync the whole envelope if the envelope is mid saving.
if (isOpen && (isAutosaving || autosaveError)) {
void handleSync();
@@ -262,24 +229,6 @@ export const EnvelopeDistributeDialog = ({
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
{hasOverlappingEnvelopeFields && (
<Alert variant="warning" className="mb-4 flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</Alert>
)}
<Tabs
onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -163,14 +163,14 @@ export const EnvelopeDownloadDialog = ({
{isLoadingEnvelopeItems
? Array.from({ length: 1 }).map((_, index) => (
<div key={index} className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
<Skeleton className="h-10 w-10 shrink-0 rounded-lg" />
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
<div className="flex w-full flex-col gap-2">
<Skeleton className="h-4 w-28 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
</div>
<Skeleton className="h-10 w-20 shrink-0 rounded-lg" />
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
</div>
))
: envelopeItems.map((item) => (
@@ -178,7 +178,7 @@ export const EnvelopeDownloadDialog = ({
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
>
<div className="shrink-0">
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
@@ -194,7 +194,7 @@ export const EnvelopeDownloadDialog = ({
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -1,7 +1,6 @@
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@@ -12,12 +11,10 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
@@ -40,15 +37,6 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
@@ -67,14 +55,8 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
await duplicateEnvelope({ envelopeId });
} catch {
toast({
title: t`Something went wrong`,
@@ -88,20 +70,7 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (isDuplicating) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
@@ -118,49 +87,6 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</DialogDescription>
</DialogHeader>
<div className="twv3-space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="twv3-space-x-2 flex items-center">
<Checkbox
id="envelopeDuplicateIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeDuplicateIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="twv3-space-x-2 flex items-center">
<Checkbox
id="envelopeDuplicateIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeFields" className={!includeRecipients ? 'opacity-50' : ''}>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isDuplicating}>
@@ -225,7 +225,7 @@ export const EnvelopeItemEditDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="title"
@@ -253,13 +253,13 @@ export const EnvelopeItemEditDialog = ({
</FormLabel>
{replacementFile ? (
<div className="twv3-space-y-2 mt-1.5">
<div className="mt-1.5 space-y-2">
<div
data-testid="envelope-item-edit-selected-file"
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
>
<div className="twv3-space-x-2 flex min-w-0 items-center">
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 items-center space-x-2">
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="truncate font-medium text-sm">{replacementFile.file.name}</p>
<p className="text-muted-foreground text-xs">
@@ -309,7 +309,7 @@ export const EnvelopeItemEditDialog = ({
)}
>
<input {...getInputProps()} />
<div className="twv3-space-x-2 flex items-center text-muted-foreground text-sm">
<div className="flex items-center space-x-2 text-muted-foreground text-sm">
<UploadIcon className="h-4 w-4" />
<span>
<Trans>Drop PDF here or click to select</Trans>
@@ -1,5 +1,4 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
@@ -25,16 +24,14 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'> & {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: TEnvelopeRecipientLite[];
};
envelopeType?: EnvelopeType;
trigger?: React.ReactNode;
};
@@ -46,11 +43,11 @@ export const ZEnvelopeRedistributeFormSchema = z.object({
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: EnvelopeRedistributeDialogProps) => {
export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => {
const recipients = envelope.recipients;
const { toast } = useToast();
const { t, i18n } = useLingui();
const { t } = useLingui();
const [isOpen, setIsOpen] = useState(false);
@@ -72,34 +69,17 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
try {
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
const successMessage = match(envelopeType)
.with(EnvelopeType.DOCUMENT, () => ({
title: t`Document resent`,
description: t`Your document has been resent successfully.`,
}))
.with(EnvelopeType.TEMPLATE, () => ({
title: t`Template resent`,
description: t`Your template has been resent successfully.`,
}))
.otherwise(() => ({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
}));
toast({
title: successMessage.title,
description: successMessage.description,
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
duration: 5000,
});
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
title: t`Something went wrong`,
description: t`This envelope could not be resent at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
@@ -116,12 +116,12 @@ export const EnvelopeSaveAsTemplateDialog = ({
</DialogDescription>
</DialogHeader>
<div className="twv3-space-y-4">
<div className="space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="twv3-space-x-2 flex items-center">
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeRecipients"
checked={field.value}
@@ -144,7 +144,7 @@ export const EnvelopeSaveAsTemplateDialog = ({
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="twv3-space-x-2 flex items-center">
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeFields"
checked={field.value}
@@ -1,159 +0,0 @@
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
export type EnvelopesBulkCancelDialogProps = {
envelopeIds: string[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkCancelDialog = ({
envelopeIds,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkCancelDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const trpcUtils = trpc.useUtils();
const [reason, setReason] = useState('');
useEffect(() => {
if (open) {
setReason('');
}
}, [open]);
const { mutateAsync: bulkCancelEnvelopes, isPending } = trpc.envelope.bulk.cancel.useMutation({
onSuccess: async (result) => {
await trpcUtils.document.findDocumentsInternal.invalidate();
if (result.failedIds.length > 0) {
toast({
title: t`Documents partially cancelled`,
description: t`${plural(result.cancelledCount, {
one: '# document cancelled.',
other: '# documents cancelled.',
})} ${plural(result.failedIds.length, {
one: '# document could not be cancelled.',
other: '# documents could not be cancelled.',
})}`,
variant: 'destructive',
});
} else {
toast({
title: t`Documents cancelled`,
description: plural(result.cancelledCount, {
one: '# document has been cancelled.',
other: '# documents have been cancelled.',
}),
variant: 'default',
});
}
onSuccess?.();
onOpenChange(false);
},
onError: () => {
toast({
title: t`Error`,
description: t`An error occurred while cancelling the documents.`,
variant: 'destructive',
});
},
});
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Cancel Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopeIds.length}
one="You are about to cancel the selected document."
other="You are about to cancel # documents."
/>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Only pending documents you have permission to manage will be cancelled.</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document signing process will be stopped</Trans>
</li>
<li>
<Trans>Recipients will be notified that the document was cancelled</Trans>
</li>
<li>
<Trans>The documents will remain in your dashboard marked as Cancelled</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Label htmlFor="bulk-cancel-reason">
<Trans>Reason (optional)</Trans>
</Label>
<Textarea
id="bulk-cancel-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t`Add an optional reason for cancelling these documents`}
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={(e) => {
e.preventDefault();
void bulkCancelEnvelopes({ envelopeIds, reason: reason || undefined });
}}
loading={isPending}
variant="destructive"
>
<Trans>Cancel documents</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -28,7 +28,7 @@ export type EnvelopesBulkMoveDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: (folderId: string | null) => Promise<void> | void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
@@ -99,12 +99,11 @@ export const EnvelopesBulkMoveDialog = ({
await trpcUtils.template.findTemplates.invalidate();
}
await onSuccess?.(data.folderId);
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
@@ -173,7 +172,7 @@ export const EnvelopesBulkMoveDialog = ({
</FormLabel>
<FormControl>
<div className="twv3-space-y-2 max-h-96 overflow-y-auto">
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -103,7 +103,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="name"
@@ -113,7 +113,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
@@ -126,14 +126,14 @@ export const FolderMoveDialog = ({ foldersData, folder, isOpen, onOpenChange }:
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="twv3-space-y-2 max-h-96 overflow-y-auto">
<div className="max-h-96 space-y-2 overflow-y-auto">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
@@ -105,7 +105,7 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="twv3-space-y-4">
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
@@ -136,7 +136,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="shrink-0" variant="secondary">
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create organisation</Trans>
</Button>
)}
@@ -199,7 +199,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -305,7 +305,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
};
return (
<div className="twv3-space-y-4">
<div className="space-y-4">
<Tabs
className="flex w-full items-center justify-center"
defaultValue="monthlyPrice"
@@ -327,7 +327,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
<button
onClick={() => onChange('')}
className={cn(
'twv3-space-x-2 flex cursor-pointer items-center rounded-md border p-4 transition-all hover:border-primary hover:shadow-xs',
'flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:border-primary hover:shadow-sm',
{
'border-primary ring-2 ring-primary/10 ring-offset-1': '' === value,
},
@@ -360,7 +360,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
key={plan[billingPeriod]?.id}
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
className={cn(
'twv3-space-x-2 flex cursor-pointer items-center rounded-md border p-4 transition-all hover:border-primary hover:shadow-xs',
'flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:border-primary hover:shadow-sm',
{
'border-primary ring-2 ring-primary/10 ring-offset-1': plan[billingPeriod]?.id === value,
},
@@ -382,7 +382,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
<Link
to="https://documen.so/enterprise-cta"
target="_blank"
className="twv3-space-x-2 flex items-center rounded-md border bg-muted/30 p-4"
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
>
<div className="flex-1 font-normal">
<p className="font-medium text-muted-foreground">
@@ -117,7 +117,7 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="organisationName"
@@ -114,7 +114,7 @@ export const OrganisationEmailCreateDialog = ({
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="shrink-0" variant="secondary">
<Button className="flex-shrink-0" variant="secondary">
<Trans>Add Email</Trans>
</Button>
)}
@@ -135,7 +135,7 @@ export const OrganisationEmailCreateDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={isPending}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
<FormField
control={form.control}
name="emailName"
@@ -113,7 +113,7 @@ export const OrganisationEmailDomainCreateDialog = ({ trigger, ...props }: Organ
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="shrink-0" variant="secondary">
<Button className="flex-shrink-0" variant="secondary">
<Trans>Add Email Domain</Trans>
</Button>
)}
@@ -135,7 +135,7 @@ export const OrganisationEmailDomainCreateDialog = ({ trigger, ...props }: Organ
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="domain"
@@ -106,7 +106,7 @@ export const OrganisationEmailDomainDeleteDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
@@ -59,11 +59,11 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
</DialogDescription>
</DialogHeader>
<div className="twv3-space-y-6">
<div className="twv3-space-y-4">
<div className="space-y-6">
<div className="space-y-4">
{records.map((record) => (
<div className="twv3-space-y-4 rounded-md border p-4" key={record.name}>
<div className="twv3-space-y-2">
<div className="space-y-4 rounded-md border p-4" key={record.name}>
<div className="space-y-2">
<Label>
<Trans>Record Type</Trans>
</Label>
@@ -79,7 +79,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
</div>
</div>
<div className="twv3-space-y-2">
<div className="space-y-2">
<Label>
<Trans>Record Name</Trans>
</Label>
@@ -95,7 +95,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
</div>
</div>
<div className="twv3-space-y-2">
<div className="space-y-2">
<Label>
<Trans>Record Value</Trans>
</Label>
@@ -115,7 +115,7 @@ export const OrganisationEmailUpdateDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={isPending}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
<FormField
control={form.control}
name="emailName"
@@ -108,7 +108,7 @@ export const OrganisationGroupCreateDialog = ({ trigger, ...props }: Organisatio
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="shrink-0" variant="secondary">
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create group</Trans>
</Button>
)}
@@ -127,7 +127,7 @@ export const OrganisationGroupCreateDialog = ({ trigger, ...props }: Organisatio
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -313,10 +313,10 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
<TabsContent value="INDIVIDUAL">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar twv3-space-y-4 -m-1 max-h-[60vh] overflow-y-auto p-1">
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{organisationMemberInvites.map((organisationMemberInvite, index) => (
<div className="twv3-space-x-4 flex w-full flex-row" key={organisationMemberInvite.id}>
<div className="flex w-full flex-row space-x-4" key={organisationMemberInvite.id}>
<FormField
control={form.control}
name={`invitations.${index}.email`}
@@ -409,7 +409,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
</TabsContent>
<TabsContent value="BULK">
<div className="twv3-space-y-4 mt-4">
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
@@ -147,7 +147,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="passkeyName"
@@ -214,7 +214,7 @@ export const ManagePublicTemplateDialog = ({
return (
<Dialog {...props} open={isOpen || open} onOpenChange={handleOnOpenChange}>
<fieldset disabled={isLoading} className="relative shrink-0">
<fieldset disabled={isLoading} className="relative flex-shrink-0">
<DialogTrigger asChild>{trigger}</DialogTrigger>
<AnimateGenericFadeInOut motionKey={currentStep}>
@@ -222,7 +222,7 @@ export const ManagePublicTemplateDialog = ({
.with({ currentStep: 'SELECT_TEMPLATE' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle className="w-full max-w-full whitespace-pre-line break-words">
<DialogTitle>
{team?.name ? (
<Trans>{team.name} direct signing templates</Trans>
) : (
@@ -311,7 +311,7 @@ export const ManagePublicTemplateDialog = ({
</DialogHeader>
<Form {...form}>
<form className="twv3-space-y-4 flex h-full flex-col" onSubmit={form.handleSubmit(onFormSubmit)}>
<form className="flex h-full flex-col space-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
<FormField
control={form.control}
name="publicTitle"
@@ -102,8 +102,8 @@ export const SignFieldCheckboxDialog = createCallable<SignFieldCheckboxDialogPro
call.end(data.values.map((value, i) => (value.checked ? i : null)).filter((value) => value !== null)),
)}
>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<ul className="twv3-space-y-3">
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<ul className="space-y-3">
{(formValues.values || []).map((value, index) => (
<li key={`checkbox-${index}`}>
<FormField
@@ -51,7 +51,7 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.email))}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="email"
@@ -49,7 +49,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.initials))}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="initials"
@@ -49,7 +49,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.name))}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -107,7 +107,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.number))}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="number"
@@ -51,7 +51,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="text"
@@ -154,7 +154,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="shrink-0" variant="secondary">
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create team</Trans>
</Button>
)}
@@ -195,7 +195,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
{dialogState === 'form' && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="teamName"
@@ -256,7 +256,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
control={form.control}
name="inheritMembers"
render={({ field }) => (
<FormItem className="twv3-space-x-2 flex items-center">
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox id="inherit-members" checked={field.value} onCheckedChange={field.onChange} />
@@ -142,7 +142,7 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName, redirectTo }: Team
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="teamName"
@@ -119,7 +119,7 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -104,7 +104,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -202,10 +202,10 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
{step === 'ROLES' && (
<>
<div className="custom-scrollbar twv3-space-y-4 -m-1 max-h-[60vh] overflow-y-auto p-1">
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{form.getValues('groups').map((group, index) => (
<div className="twv3-space-x-4 flex w-full flex-row" key={index}>
<div className="twv3-space-y-2 w-full">
<div className="flex w-full flex-row space-x-4" key={index}>
<div className="w-full space-y-2">
{index === 0 && (
<FormLabel>
<Trans>Group</Trans>
@@ -241,7 +241,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
control={form.control}
name="members"
render={({ field }) => (
<FormItem className="twv3-space-y-2">
<FormItem className="space-y-2">
<FormLabel>
<Trans>Members</Trans>
</FormLabel>
@@ -310,7 +310,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
</FormDescription>
{canInviteOrganisationMembers && (
<Alert variant="neutral" className="twv3-space-y-0 mt-2 flex items-center gap-2">
<Alert variant="neutral" className="mt-2 flex items-center gap-2 space-y-0">
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
@@ -358,10 +358,10 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
{step === 'MEMBERS' && (
<>
<div className="custom-scrollbar twv3-space-y-4 -m-1 max-h-[60vh] overflow-y-auto p-1">
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{form.getValues('members').map((member, index) => (
<div className="twv3-space-x-4 flex w-full flex-row" key={index}>
<div className="twv3-space-y-2 w-full">
<div className="flex w-full flex-row space-x-4" key={index}>
<div className="w-full space-y-2">
{index === 0 && (
<FormLabel>
<Trans>Member</Trans>
@@ -16,17 +16,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { match } from 'ts-pattern';
/**
* The reason a team member cannot be removed from the team. When set, the delete
* dialog explains the reason instead of offering a confirm button.
*/
export type TeamMemberDeleteDisableReason =
| 'TEAM_OWNER'
| 'HIGHER_ROLE'
| 'INHERIT_MEMBER_ENABLED'
| 'INHERITED_MEMBER';
export type TeamMemberDeleteDialogProps = {
teamId: number;
@@ -34,7 +23,7 @@ export type TeamMemberDeleteDialogProps = {
memberId: string;
memberName: string;
memberEmail: string;
disableReason?: TeamMemberDeleteDisableReason | null;
isInheritMemberEnabled: boolean | null;
trigger?: React.ReactNode;
};
@@ -45,7 +34,7 @@ export const TeamMemberDeleteDialog = ({
memberId,
memberName,
memberEmail,
disableReason,
isInheritMemberEnabled,
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
@@ -97,19 +86,10 @@ export const TeamMemberDeleteDialog = ({
</DialogDescription>
</DialogHeader>
{disableReason ? (
{isInheritMemberEnabled ? (
<Alert variant="neutral">
<AlertDescription>
{match(disableReason)
.with('TEAM_OWNER', () => <Trans>You cannot remove the organisation owner from the team.</Trans>)
.with('HIGHER_ROLE', () => <Trans>You cannot remove a member with a role higher than your own.</Trans>)
.with('INHERIT_MEMBER_ENABLED', () => (
<Trans>You cannot remove members from this team while the inherit member feature is enabled.</Trans>
))
.with('INHERITED_MEMBER', () => (
<Trans>This member is inherited from a group and cannot be removed from the team directly.</Trans>
))
.exhaustive()}
<Trans>You cannot remove members from this team if the inherit member feature is enabled.</Trans>
</AlertDescription>
</Alert>
) : (
@@ -129,10 +109,11 @@ export const TeamMemberDeleteDialog = ({
<Trans>Close</Trans>
</Button>
{!disableReason && (
{!isInheritMemberEnabled && (
<Button
type="submit"
variant="destructive"
disabled={Boolean(isInheritMemberEnabled)}
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
@@ -222,7 +222,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
control={form.control}
name="sendImmediately"
render={({ field }) => (
<FormItem className="twv3-space-x-2 flex items-center">
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox id="send-immediately" checked={field.value} onCheckedChange={field.onChange} />
@@ -211,7 +211,7 @@ export const TemplateDirectLinkDialog = ({
</DialogDescription>
</DialogHeader>
<ul className="twv3-space-y-4 mt-4 pl-12">
<ul className="mt-4 space-y-4 pl-12">
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
<li className="relative" key={index}>
<div className="absolute -left-12">
@@ -259,7 +259,7 @@ export const TemplateDirectLinkDialog = ({
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
<DialogContent className="relative">
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-sm bg-white/50 dark:bg-black/50">
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
</div>
)}
@@ -405,7 +405,7 @@ export const TemplateDirectLinkDialog = ({
className="h-8 w-8"
onClick={() => void onCopyClick(token)}
>
<ClipboardCopyIcon className="h-4 w-4 shrink-0" />
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
</Button>
</div>
</div>
@@ -0,0 +1,232 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await updateTemplate({
templateId,
data: {
folderId: data.folderId ?? null,
},
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data?.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -4,7 +4,7 @@ import {
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
@@ -35,8 +35,8 @@ import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
@@ -180,11 +180,22 @@ export function TemplateUseDialog({
await navigate(documentPath);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getTemplateUseErrorMessage(error.code);
const errorMessage = match(error.code)
.with('DOCUMENT_SEND_FAILED', () => msg`The document was created but could not be sent to recipients.`)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
() =>
msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
)
.with(AppErrorCode.NOT_FOUND, () => msg`The template or one of its recipients could not be found.`)
.with(AppErrorCode.LIMIT_EXCEEDED, () => msg`You have reached your document limit for this plan.`)
.otherwise(() => msg`An error occurred while creating document from template.`);
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
});
}
@@ -240,9 +251,9 @@ export function TemplateUseDialog({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar twv3-space-y-4 -m-1 max-h-[60vh] overflow-y-auto p-1">
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="twv3-space-x-4 flex w-full flex-row" key={recipient.id}>
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
<FormField
control={form.control}
@@ -337,7 +348,7 @@ export function TemplateUseDialog({
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="twv3-space-y-2 z-[99999] max-w-md p-4 text-muted-foreground">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
The document will be immediately sent to recipients if this is checked.
@@ -362,7 +373,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="twv3-space-y-2 z-[99999] max-w-md p-4 text-muted-foreground">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>Create the document as pending and ready to sign.</Trans>
</p>
@@ -414,7 +425,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="twv3-space-y-2 z-[99999] max-w-md p-4 text-muted-foreground">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Upload a custom document to use instead of the template's default document
@@ -429,7 +440,7 @@ export function TemplateUseDialog({
/>
{form.watch('useCustomDocument') && (
<div className="twv3-space-y-2 my-4">
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
) : (
@@ -445,7 +456,7 @@ export function TemplateUseDialog({
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="shrink-0">
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
@@ -464,7 +475,7 @@ export function TemplateUseDialog({
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex flex-shrink-0 items-center gap-2">
{field.value ? (
<div className="">
<Button
@@ -117,7 +117,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
@@ -94,7 +94,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="shrink-0">
<Button className="flex-shrink-0">
<Trans>Create Webhook</Trans>
</Button>
)}
@@ -112,7 +112,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
@@ -108,7 +108,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="webhookUrl"
@@ -88,7 +88,7 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="event"
@@ -75,7 +75,7 @@ export const ConfigureDocumentAdvancedSettings = ({
</TabsList>
<TabsContent value="general" className="mt-0">
<div className="twv3-space-y-6 flex flex-col">
<div className="flex flex-col space-y-6">
{features.allowConfigureSignatureTypes && (
<FormField
control={control}
@@ -215,7 +215,7 @@ export const ConfigureDocumentAdvancedSettings = ({
{features.allowConfigureCommunication && (
<TabsContent value="communication" className="mt-0">
<div className="twv3-space-y-6 flex flex-col">
<div className="flex flex-col space-y-6">
<FormField
control={control}
name="meta.distributionMethod"
@@ -254,7 +254,7 @@ export const ConfigureDocumentAdvancedSettings = ({
/>
<fieldset
className="twv3-space-y-6 flex flex-col disabled:cursor-not-allowed disabled:opacity-60"
className="flex flex-col space-y-6 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!isEmailDistribution}
>
<FormField
@@ -147,7 +147,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
control={control}
name="meta.signingOrder"
render={({ field }) => (
<FormItem className="twv3-space-x-2 twv3-space-y-0 mb-6 flex flex-row items-center">
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
@@ -173,7 +173,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
control={control}
name="meta.allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="twv3-space-x-2 twv3-space-y-0 mb-6 flex flex-row items-center">
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
@@ -221,7 +221,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="twv3-space-y-2">
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
{signers.map((signer, index) => (
<Draggable
key={signer.id}
@@ -254,7 +254,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.signingOrder,
})}
>
<GripVertical className="h-5 w-5 shrink-0 opacity-40" />
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
@@ -158,7 +158,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
/>
<div
className={cn('twv3-space-y-1 flex flex-col', {
className={cn('flex flex-col space-y-1', {
'text-primary': isDragActive,
'text-muted-foreground': !isDragActive,
})}
@@ -75,7 +75,7 @@ export const ConfigureDocumentView = ({
const onFormSubmit = handleSubmit(onSubmit);
return (
<div className="twv3-space-y-8 flex w-full flex-col">
<div className="flex w-full flex-col space-y-8">
<div>
<h2 className="mb-1 font-semibold text-foreground text-xl">
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
@@ -91,7 +91,7 @@ export const ConfigureDocumentView = ({
</div>
<Form {...form}>
<div className="twv3-space-y-8 flex flex-col">
<div className="flex flex-col space-y-8">
<div>
<FormField
control={control}
@@ -462,7 +462,7 @@ export const ConfigureFieldsView = ({
<hr className="my-6" />
<div className="twv3-space-y-2">
<div className="space-y-2">
<FieldSelector
selectedField={selectedField}
onSelectedFieldChange={setSelectedField}
@@ -604,7 +604,7 @@ export const ConfigureFieldsView = ({
<hr className="my-6" />
<div className="twv3-space-y-2">
<div className="space-y-2">
<FieldSelector
selectedField={selectedField}
onSelectedFieldChange={(field) => {

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