Compare commits

..
Author SHA1 Message Date
Ephraim Duncan 45117cef14 Merge branch 'main' into feat/acroform-field-import 2026-08-19 09:55:00 +00:00
ephraimduncan 2662329455 Merge remote-tracking branch 'origin/main' into pr-2853
# Conflicts:
#	apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
2026-07-02 06:48:23 +00:00
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
437 changed files with 8696 additions and 16592 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 1–6: 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,61 +0,0 @@
name: 'One-Click Deploy Provider Request'
description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README
title: 'One-Click Deploy Provider Request: [Provider Name]'
labels: ['deploy-provider-request']
body:
- type: markdown
attributes:
value: |
Thanks for your interest in adding a one-click deploy option for Documenso!
Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest.
**How this works:**
- 👍 this issue if you'd like to see Documenso deployable on this provider.
- If community interest is high enough, we'll consider adding it to the README.
- Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed.
- type: input
attributes:
label: Provider Name
placeholder: e.g. Railway
validations:
required: true
- type: input
attributes:
label: Provider Website
placeholder: e.g. https://railway.com
validations:
required: true
- type: input
attributes:
label: Deploy/Template URL
description: A link to an existing deployment template or deploy button URL, if one exists.
- type: dropdown
attributes:
label: Who creates and maintains the deployment template?
options:
- The provider
- Me / the community
- Nobody yet
validations:
required: true
- type: textarea
attributes:
label: Testing & Maintenance
description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)?
validations:
required: true
- type: textarea
attributes:
label: Why this provider?
description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc.
validations:
required: true
- type: checkboxes
attributes:
label: Please check the boxes that apply to this request.
options:
- label: I have searched existing issues to make sure this provider has not already been requested.
- label: I understand that inclusion depends on community interest and is not guaranteed.
- label: I understand that PRs adding deploy badges without a prior issue will be closed.
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Setup node'
inputs:
node_version:
required: false
default: v24.x
default: v22.x
runs:
using: 'composite'
-20
View File
@@ -30,26 +30,6 @@ jobs:
- name: Build app
run: npm run build
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: ./.github/actions/node-install
- name: Copy env
run: cp .env.example .env
# Includes the 2FA enforcement drift guard (packages/trpc), which is the
# only check that catches a session route without enforcement middleware.
- name: Run unit tests
run: npm run test -w @documenso/lib -w @documenso/trpc
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
+15 -31
View File
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js (v24 or above)
- Node.js (v22 or above)
- Postgres SQL Database
- Docker (optional)
@@ -186,37 +186,21 @@ For full instructions, requirements, and configuration details, see the [Self Ho
### One-Click Deploys
> [!NOTE]
> Want to see another provider listed here? Please [open a provider request](https://github.com/documenso/documenso/issues/new?template=deploy-provider-request.yml) instead of a PR so the community can signal interest. PRs adding deploy badges without a prior issue will be closed.
#### Railway
<table>
<tr>
<td align="center" width="200">
<a href="https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40" />
</a>
</td>
<td align="center" width="200">
<a href="https://render.com/deploy?repo=https://github.com/documenso/documenso">
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40" />
</a>
</td>
<td align="center" width="200">
<a href="https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile">
<img src="https://www.koyeb.com/static/images/deploy/button.svg" alt="Deploy to Koyeb" height="40" />
</a>
</td>
</tr>
<tr>
<td align="center" width="200">
<a href="https://elest.io/open-source/documenso">
<img src="https://elest.io/images/logos/deploy-to-elestio-btn.png" alt="Deploy on Elestio" height="40" />
</a>
</td>
<td align="center" width="200"></td>
<td align="center" width="200"></td>
</tr>
</table>
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
#### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
#### Elestio
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso)
## Security
@@ -95,7 +95,7 @@ Documents created with a team token belong to that team:
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "DOCUMENT",
@@ -157,11 +157,11 @@ Retrieve all documents belonging to the team:
```bash
# List all team documents
curl -X GET "https://app.documenso.com/api/v2/envelope" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -191,7 +191,7 @@ Templates created with a team token are shared across the team.
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/template/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"title": "NDA Template",
@@ -269,7 +269,7 @@ console.log('Created team template:', template.id);
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -51,7 +51,6 @@ async function createAndSendDocument(
pdfBuffer: Buffer,
filename: string,
title: string,
externalId: string,
recipients: Recipient[],
): Promise<CreateAndSendResult> {
const recipientPayload = recipients.map((recipient, index) => ({
@@ -90,7 +89,6 @@ async function createAndSendDocument(
JSON.stringify({
type: 'DOCUMENT',
title,
externalId,
recipients: recipientPayload,
meta: {
subject: `Please sign: ${title}`,
@@ -147,7 +145,6 @@ const result = await createAndSendDocument(
pdfBuffer,
'contract.pdf',
'Service Agreement',
'nda-contract-ndac214',
[
{ email: 'client@example.com', name: 'John Smith', role: 'SIGNER' },
{ email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' },
@@ -175,7 +172,6 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \
-F 'payload={
"type": "DOCUMENT",
"title": "Service Agreement",
"externalId": "nda-contract-ndac214",
"recipients": [
{
"email": "client@example.com",
@@ -245,8 +241,6 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}'
</Tab>
</Tabs>
`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents).
---
## Workflow 2: Create Document from Template with Custom Data
@@ -9,7 +9,6 @@
"background-jobs",
"signing-certificate",
"telemetry",
"two-factor-enforcement",
"organisation-limits",
"advanced"
]
@@ -1,54 +0,0 @@
---
title: Two-Factor Enforcement
description: Require two-factor authentication instance-wide or per organisation, with grace periods and important limitations.
---
import { Callout } from 'fumadocs-ui/components/callout';
## Overview
Documenso can require users to enable two-factor authentication (2FA) at two levels:
- **Instance-wide enforcement** — configured by an instance administrator under **Admin → Site Settings**. Requires a Documenso license that includes the feature. Once a user's grace period expires, they are redirected to a forced enrolment page before they can continue using the app.
- **Per-organisation enforcement** — available to everyone, configured by organisation admins in the organisation settings. Once a member's grace period expires, only access to that organisation (and its teams) is blocked; the rest of the app stays usable.
A user satisfies enforcement when they have 2FA enabled **and** their current session has passed a second factor (a TOTP/backup-code challenge, a user-verified passkey sign-in, or enabling 2FA during the session). Sessions created before the user enabled 2FA must sign out and back in to verify.
## Grace Periods
Both levels support a grace period of 0–365 days:
- **Instance**: the window starts at the later of the user's grace start (typically account creation, restarted by an admin 2FA reset) and the moment enforcement was enabled.
- **Organisation**: the window starts at the latest of joining the organisation, the moment the organisation enabled enforcement, and the user's grace start.
A grace period of **0 days** enforces immediately: for the instance policy, users are forced to enrol right after signing up or signing in; for the organisation policy, members are blocked from the organisation until they enrol.
**Joining is never blocked; access is.** Invitations and SSO sign-ins always succeed — the grace window starts at join. Members who never comply still occupy a seat and count towards member limits; organisation admins can see per-member 2FA compliance in the members list.
Reducing an active grace period requires an explicit acknowledgement in the settings UI, since it can immediately block users who have not yet enrolled.
Enabling enforcement requires the acting administrator to already satisfy the policy themselves (2FA enabled and verified on their current session). This prevents administrators from locking themselves out with a 0-day grace period.
## Known Limitation: API Tokens Are Exempt
<Callout type="warn">
Enforcement applies to interactive (session-based) access only. **API tokens minted before a
user's deadline keep working after it.** A blocked user cannot mint new tokens, but existing
tokens are not revoked by enforcement. If you need to cut off a non-compliant user's API access,
revoke their tokens explicitly.
</Callout>
## Licensing
Instance-wide enforcement is license-gated:
- Without the license, the instance-wide section in Admin → Site Settings is visible but disabled.
- If enforcement was configured while licensed and the license later lapses, the stored configuration becomes **inactive** (nothing is enforced) and the only permitted change is disabling it.
Per-organisation enforcement does not require a license. When instance-wide enforcement is active, it takes precedence over organisation policies.
---
## See Also
- [License](/docs/self-hosting/configuration/license) - Configuring your Documenso license
@@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| Variable | Description | Default |
| ------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
| `PORT` | Port the application listens on | `3000` |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — set to the volume-mount path (e.g. `/opt/documenso/cert.p12`). Only Docker Compose defaults this; plain `docker run` must set it explicitly | - |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
@@ -136,7 +136,6 @@ docker run -d \
-e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \
-e NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH="/opt/documenso/cert.p12" \
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \
-e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \
-e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \
@@ -155,12 +154,6 @@ A signing certificate is required for document signing. You have two options for
- **Volume mount** — mount a `.p12` file from the host into the container at `/opt/documenso/cert.p12` (shown above). This is the simplest approach for small to moderate deployments.
- **Base64-encoded contents** — set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` with the base64-encoded certificate string. Use this when file mounting is not available (e.g., Railway, Vercel).
<Callout type="warn">
Plain `docker run` deployments must set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` explicitly. This
prevents production deployments from accidentally using the insecure example certificate.
Docker Compose sets the file path for you.
</Callout>
For production deployments that require Adobe Approved Trust List recognition, consider using a [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) or another external HSM.
<Callout type="warn">
@@ -185,7 +178,6 @@ NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
@@ -211,12 +203,6 @@ docker run -d \
Documenso provides health check endpoints for monitoring:
<Callout type="info">
If a certificate is mounted but signing fails, ensure `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH`
explicitly points to its path inside the container. Production does not use the development
example certificate as a fallback.
</Callout>
| Endpoint | Purpose |
| ------------------------- | -------------------------------------------------------------- |
| `/api/health` | Checks database connectivity and certificate status |
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
## Prerequisites
- Node.js 24 or later
- npm 11.17 or later
- Node.js 22 or later
- npm 11 or later
- PostgreSQL 14 or later
- A Linux server (for systemd service setup)
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
| Requirement | Version |
| ----------- | ------- |
| Node.js | 24+ |
| npm | 11.17+ |
| Node.js | 22+ |
| npm | 11+ |
---
@@ -169,7 +169,7 @@ Documenso runs on:
| MySQL/MariaDB | PostgreSQL-specific features required |
| SQLite | Not suitable for production workloads |
| MongoDB | Relational database required |
| Node.js < 24 | Modern JavaScript features required |
| Node.js < 22 | Modern JavaScript features required |
---
@@ -34,7 +34,7 @@ To access the preferences, navigate to either the organisation or teams settings
| **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. |
| **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). |
| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). |
| **Delegate Document Ownership** | By default, documents created with a team API token are owned by the user who created the token. Enable this setting to let supported API requests assign ownership to another team member. |
| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. |
| **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. |
Document visibility, language, and signature settings can be overridden per document.
+1 -1
View File
@@ -15,7 +15,7 @@
"fumadocs-ui": "16.14.3",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "^16.3.3",
"next": "16.3.0",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "^16.3.3"
"next": "16.3.0"
},
"devDependencies": {
"@types/node": "^20",
+25
View File
@@ -0,0 +1,25 @@
FROM oven/bun:1 AS dependencies-env
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production
FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]
@@ -56,7 +56,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentication for this user.`,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
@@ -85,8 +85,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will disable two factor
authentication for the user. Their two-factor enforcement grace period will restart from the moment of the
reset.
authentication for the user.
</Trans>
</AlertDescription>
</div>
@@ -109,8 +108,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before proceeding. Any
two-factor enforcement grace period for this user will restart from the moment of the reset.
This action is irreversible. Please ensure you have informed the user before proceeding.
</Trans>
</AlertDescription>
</Alert>
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
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';
@@ -72,7 +71,6 @@ export const EnvelopeDistributeDialog = ({
const { toast } = useToast();
const { t, i18n } = useLingui();
const navigate = useNavigate();
const analytics = useAnalytics();
const [isOpen, setIsOpen] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
@@ -202,12 +200,6 @@ export const EnvelopeDistributeDialog = ({
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'distribute_document',
envelopeId: envelope.id,
});
const errorMessage = getDistributeErrorMessage(error.code);
toast({
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
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';
@@ -52,7 +51,6 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
const { toast } = useToast();
const { t, i18n } = useLingui();
const analytics = useAnalytics();
const [isOpen, setIsOpen] = useState(false);
@@ -97,13 +95,6 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'redistribute_document',
envelopeId: envelope.id,
});
const errorMessage = getDistributeErrorMessage(error.code);
toast({
@@ -290,11 +290,10 @@ export const EnvelopesBulkDownloadDialog = ({
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<Plural
value={MAX_BULK_DOWNLOAD_ENVELOPES}
one="You can download up to # document at a time. Deselect some documents to continue."
other="You can download up to # documents at a time. Deselect some documents to continue."
/>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
</AlertDescription>
</Alert>
)}
@@ -1,7 +1,4 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@@ -18,11 +15,9 @@ 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 { Plural, Trans } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -34,8 +29,6 @@ const ZBulkSendFormSchema = z.object({
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
@@ -49,9 +42,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
@@ -61,20 +51,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}
setOpen(value);
if (!value) {
setValidationError(null);
form.reset();
}
};
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
@@ -95,44 +71,36 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};
const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);
try {
const csv = await values.file.text();
const result = await uploadBulkSend({
await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
if (!result.success) {
setValidationError(result.error);
return;
}
toast({
title: _(msg`Success`),
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
});
setOpen(false);
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
const error = AppError.parseError(err);
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -206,10 +174,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -230,11 +195,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -257,72 +218,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
)}
/>
{validationError !== null && (
<Alert variant="destructive">
<AlertDescription className="max-h-32 overflow-y-auto">
{match(validationError)
.with({ type: 'PARSE_ERROR' }, () => (
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
))
.with({ type: 'EMPTY' }, () => (
<Trans>
The CSV does not contain any rows. Please add at least one row of recipient details.
</Trans>
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
<Plural value={rowCount} one="The CSV contains # row." other="The CSV contains # rows." />{' '}
<Plural
value={maxRows}
one="A maximum of # row is allowed per upload."
other="A maximum of # rows is allowed per upload."
/>
</Trans>
))
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
<>
<Trans>
The CSV is missing the following required columns. Please download the template CSV for the
correct format.
</Trans>
<ul className="mt-1 list-inside list-disc">
{missingColumns.map((column) => (
<li key={column} className="font-mono">
{column}
</li>
))}
</ul>
</>
))
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
<>
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
<ul className="mt-1 list-inside list-disc">
{rowErrors.map((rowError, index) => (
<li key={index}>
<Trans>
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
email or empty
</Trans>
</li>
))}
</ul>
</>
))
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
code === AppErrorCode.LIMIT_EXCEEDED ? (
<Trans>The CSV exceeds the maximum file size.</Trans>
) : (
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
),
)
.exhaustive()}
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="sendImmediately"
@@ -345,12 +240,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Trans>Cancel</Trans>
</Button>
@@ -18,8 +18,6 @@ import { useCallback, useRef } from 'react';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { useCspNonce } from '~/utils/nonce';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -34,7 +32,6 @@ export interface ConfigureDocumentRecipientsProps {
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const cspNonce = useCspNonce();
const $sensorApi = useRef<SensorAPI | null>(null);
@@ -215,7 +212,6 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
/>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
@@ -78,7 +77,6 @@ export const EmbedDirectTemplateClientPage = ({
}: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [searchParams] = useSearchParams();
@@ -266,13 +264,6 @@ export const EmbedDirectTemplateClientPage = ({
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
analytics.captureException(err, {
source: 'embed',
location: 'direct_template',
recipientId: recipient.id,
envelopeId,
});
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
@@ -317,14 +308,6 @@ export const EmbedDirectTemplateClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
envelopeId,
});
setHasFinishedInit(true);
}
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
@@ -76,7 +75,6 @@ export const EmbedSignDocumentV1ClientPage = ({
}: EmbedSignDocumentV1ClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
@@ -156,14 +154,6 @@ export const EmbedSignDocumentV1ClientPage = ({
setHasCompletedDocument(true);
} catch (err) {
analytics.captureException(err, {
source: 'embed',
location: 'complete_document',
recipientId: recipient.id,
documentId,
envelopeId,
});
if (window.parent) {
window.parent.postMessage(
{
@@ -246,15 +236,6 @@ export const EmbedSignDocumentV1ClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
documentId,
envelopeId,
});
setHasFinishedInit(true);
}
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
@@ -26,7 +25,6 @@ export const EmbedSignDocumentV2ClientPage = ({
allowWhitelabelling = false,
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const analytics = useAnalytics();
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
useRequiredEnvelopeSigningContext();
@@ -172,14 +170,6 @@ export const EmbedSignDocumentV2ClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
envelopeId: envelope.id,
});
setHasFinishedInit(true);
}
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -58,7 +57,6 @@ export const MultiSignDocumentSigningView = ({
}: MultiSignDocumentSigningViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
@@ -103,13 +101,6 @@ export const MultiSignDocumentSigningView = ({
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'sign_field',
recipientId,
documentId: document?.id,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
@@ -129,13 +120,6 @@ export const MultiSignDocumentSigningView = ({
}
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'remove_field',
recipientId,
documentId: document?.id,
});
}
};
@@ -156,13 +140,6 @@ export const MultiSignDocumentSigningView = ({
recipientId,
});
} catch (err) {
analytics.captureException(err, {
source: 'embed',
location: 'complete_document',
recipientId,
documentId: document?.id,
});
onDocumentError?.();
const error = AppError.parseError(err);
@@ -1,6 +1,5 @@
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -69,23 +68,15 @@ export const DisableAuthenticatorAppDialog = () => {
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
// Todo: (2FA enforcement, step 5) Once org enforcement state is available
// client-side, warn BEFORE disabling that org/team access will block at the
// org 2FA deadline. Until then the warning is shown after the fact based on
// the server response.
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
try {
const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode });
await authClient.twoFactor.disable({ totpCode, backupCode });
toast({
title: _(msg`Two-factor authentication disabled`),
description: orgEnforcementApplies
? _(
msg`Two-factor authentication has been disabled for your account. One of your organisations requires two-factor authentication: access to it will be blocked at its deadline until you re-enable 2FA.`,
)
: _(
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
),
description: _(
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
),
});
flushSync(() => {
@@ -93,19 +84,7 @@ export const DisableAuthenticatorAppDialog = () => {
});
await refreshSession();
} catch (err) {
const error = AppError.parseError(err);
if (error.code === 'TWO_FACTOR_DISABLE_FORBIDDEN') {
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(msg`Two-factor authentication is required by this instance and cannot be disabled.`),
variant: 'destructive',
});
return;
}
} catch (_err) {
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(
@@ -1,7 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -70,18 +69,11 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
setSetup2FAData(data);
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Unable to setup two-factor authentication`),
description:
error.code === 'TWO_FACTOR_ALREADY_ENABLED'
? _(
msg`Two-factor authentication is already enabled for your account. Disable it before setting it up again.`,
)
: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
}
@@ -1,158 +0,0 @@
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { Trans } from '@lingui/react/macro';
import type React from 'react';
import { useState } from 'react';
import { type FieldValues, type Path, useFormContext } from 'react-hook-form';
import { z } from 'zod';
/**
* Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`.
*/
export const ZTwoFactorCodeFieldSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TTwoFactorCodeFieldSchema = z.infer<typeof ZTwoFactorCodeFieldSchema>;
export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode;
type TwoFactorMethod = 'totp' | 'backup';
export type TwoFactorCodeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isSubmitting?: boolean;
submitLabel: React.ReactNode;
/**
* Called when the user submits the code. Typically the parent form's submit handler.
*/
onSubmit: () => void;
};
/**
* Collects a TOTP or backup code on top of an existing form, mirroring the
* sign in and disable 2FA dialogs.
*
* Must be rendered inside a `<Form>` whose values include `totpCode` and `backupCode`.
*/
export const TwoFactorCodeDialog = <T extends FieldValues & TTwoFactorCodeFieldSchema>({
open,
onOpenChange,
isSubmitting,
submitLabel,
onSubmit,
}: TwoFactorCodeDialogProps) => {
const form = useFormContext<T>();
const [method, setMethod] = useState<TwoFactorMethod>('totp');
const totpCodeName = 'totpCode' as Path<T>;
const backupCodeName = 'backupCode' as Path<T>;
const onToggleMethod = () => {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod((current) => (current === 'totp' ? 'backup' : 'totp'));
};
const handleOpenChange = (value: boolean) => {
if (isSubmitting) {
return;
}
if (!value) {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod('totp');
}
onOpenChange(value);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Two-Factor Authentication</Trans>
</DialogTitle>
<DialogDescription>
{method === 'totp' ? (
<Trans>Enter the code from your authenticator app to continue.</Trans>
) : (
<Trans>Enter one of your backup codes to continue.</Trans>
)}
</DialogDescription>
</DialogHeader>
<fieldset disabled={isSubmitting}>
{method === 'totp' && (
<FormField
control={form.control}
name={totpCodeName}
render={({ field }) => (
<FormItem>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6} autoFocus>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'backup' && (
<FormField
control={form.control}
name={backupCodeName}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" autoComplete="off" autoFocus {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={onToggleMethod}>
{method === 'totp' ? <Trans>Use Backup Code</Trans> : <Trans>Use Authenticator</Trans>}
</Button>
<Button type="button" loading={isSubmitting} onClick={onSubmit}>
{submitLabel}
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -1,282 +0,0 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZTwoFactorEnforcementFormSchema = z.object({
twoFactorRequired: z.boolean(),
twoFactorGracePeriodDays: z.coerce.number().int().min(0).max(365),
acknowledgeGracePeriodReduction: z.boolean(),
});
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
/**
* Organisation 2FA enforcement settings (require toggle + grace period).
*
* Rendered only for MANAGE_ORGANISATION_SECURITY holders (ADMIN). When
* instance-wide enforcement is active the fields are shown disabled — not
* hidden — with a banner explaining that the instance policy takes
* precedence, so a configured organisation policy stays visible instead of
* resurfacing already-expired later.
*/
export const OrganisationTwoFactorEnforcementForm = () => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { twoFactorEnforcement: instanceTwoFactorEnforcement } = useSession();
const isInstanceEnforcementActive = instanceTwoFactorEnforcement.required;
const { data: organisationWithSettings, isLoading } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const utils = trpc.useUtils();
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const settings = organisationWithSettings?.organisationGlobalSettings;
const form = useForm<TTwoFactorEnforcementFormSchema>({
values: {
twoFactorRequired: settings?.twoFactorRequired ?? false,
twoFactorGracePeriodDays: settings?.twoFactorGracePeriodDays ?? 7,
acknowledgeGracePeriodReduction: false,
},
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
});
const watchedValues = form.watch();
// Client-side mirror of the server's grace-reduction detection so we can
// surface the acknowledgement checkbox before submitting.
const isGraceReduction =
settings !== undefined &&
isTwoFactorGracePeriodReduction({
previous: settings.twoFactorRequired
? {
anchors: [settings.twoFactorEnforcedFrom],
gracePeriodDays: settings.twoFactorGracePeriodDays,
}
: null,
next: watchedValues.twoFactorRequired
? {
anchors: [settings.twoFactorRequired ? settings.twoFactorEnforcedFrom : new Date()],
gracePeriodDays: watchedValues.twoFactorGracePeriodDays,
}
: null,
now: new Date(),
});
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
try {
await updateOrganisationSettings({
organisationId: organisation.id,
acknowledgeGracePeriodReduction: data.acknowledgeGracePeriodReduction,
data: {
twoFactorRequired: data.twoFactorRequired,
twoFactorGracePeriodDays: data.twoFactorGracePeriodDays,
},
});
await utils.organisation.get.invalidate();
toast({
title: _(msg`Two-factor enforcement settings updated`),
});
form.setValue('acknowledgeGracePeriodReduction', false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
toast({
title: _(msg`Two-factor authentication required`),
description: _(
msg`You must have two-factor authentication enabled and verified on this session before requiring it for the organisation.`,
),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
variant: 'destructive',
});
}
};
if (isLoading || !settings) {
return (
<div className="flex justify-center rounded-lg border py-16">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isInstanceEnforcementActive}
className="flex flex-col gap-y-4"
>
{isInstanceEnforcementActive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Instance policy takes precedence</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor authentication is enforced instance-wide by your administrator, so the organisation policy
below is not editable while the instance policy is active.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="twoFactorRequired"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Members must enable two-factor authentication to access this organisation. Joining is never
blocked — the grace period starts when a member joins.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="twoFactorGracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a member has to enable two-factor authentication after joining. 0 blocks organisation
access immediately until they enrol.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{settings.twoFactorRequired && settings.twoFactorEnforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since {i18n.date(settings.twoFactorEnforcedFrom, { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<ul className="list-disc space-y-1 pl-4">
<li>
<Trans>
API tokens are exempt: tokens minted before a member's deadline keep working after it. Blocked
members cannot mint new tokens.
</Trans>
</li>
<li>
<Trans>
Members who have not yet complied still occupy a seat and count towards your member limit.
</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Members who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their organisation access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for members</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -1,53 +0,0 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CheckIcon } from 'lucide-react';
import { match } from 'ts-pattern';
/**
* Compact "send me a setup link" button that reports via toast, for settings
* cards where the surrounding layout provides the explanation.
*/
export const PasswordSetupRequestButton = () => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({
onSuccess: () => {
toast({
title: _(msg`Check your email`),
description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`),
duration: 5000,
});
},
onError: (errorCode) => {
toast({
title: _(msg`An error occurred`),
description: match(errorCode)
.with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`))
.otherwise(() => _(msg`We were unable to send the email. Please try again later.`)),
variant: 'destructive',
});
},
});
if (isSuccess) {
return (
<Button variant="outline" className="flex-shrink-0 bg-background" disabled>
<CheckIcon className="mr-2 h-4 w-4" />
<Trans>Link sent</Trans>
</Button>
);
}
return (
<Button variant="outline" className="flex-shrink-0 bg-background" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
);
};
@@ -1,61 +0,0 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
export type PasswordSetupRequestProps = {
className?: string;
};
/**
* Inline "send me a setup link" control with its own sent/error states, for
* contexts like dialogs where a toast would be missed.
*/
export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => {
const { _ } = useLingui();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest();
if (isSuccess) {
return (
<Alert className={className} variant="neutral">
<AlertTitle>
<Trans>Check your email</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue.
</Trans>
</AlertDescription>
</Alert>
);
}
return (
<div className={className}>
{errorCode && (
<Alert className="mb-4" variant="destructive">
<AlertTitle>
<Trans>An error occurred</Trans>
</AlertTitle>
<AlertDescription>
{match(errorCode)
.with('SIGNIN_DISABLED', () =>
_(msg`Password sign in is disabled for this instance. Please contact support.`),
)
.otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))}
</AlertDescription>
</Alert>
)}
<Button type="button" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
</div>
);
};
+18 -59
View File
@@ -1,6 +1,6 @@
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -11,21 +11,20 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { z } from 'zod';
import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog';
export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
}).refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
export const ZPasswordFormSchema = z
.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
@@ -34,51 +33,29 @@ export type PasswordFormProps = {
user: SessionUser;
};
export const PasswordForm = ({ className, user }: PasswordFormProps) => {
export const PasswordForm = ({ className }: PasswordFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false);
const form = useForm<TPasswordFormSchema>({
values: {
currentPassword: '',
password: '',
repeatedPassword: '',
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async (values: TPasswordFormSchema) => {
const { currentPassword, password, totpCode, backupCode } = values;
// Collect the 2FA code in a dialog once the password fields are valid.
if (user.twoFactorEnabled && !hasTwoFactorCode(values)) {
if (isTwoFactorDialogOpen) {
const message = _(msg`A code is required`);
form.setError('totpCode', { message });
form.setError('backupCode', { message });
}
setIsTwoFactorDialogOpen(true);
return;
}
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
try {
await authClient.emailPassword.updatePassword({
currentPassword,
password,
totpCode: totpCode || undefined,
backupCode: backupCode || undefined,
});
form.reset();
setIsTwoFactorDialogOpen(false);
toast({
title: _(msg`Password updated`),
@@ -89,14 +66,9 @@ export const PasswordForm = ({ className, user }: PasswordFormProps) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`)
.with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`)
.with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`)
.with(
AppErrorCode.INCORRECT_TWO_FACTOR_CODE,
AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS,
() => msg`The two factor code you provided is invalid. Please try again.`,
)
.with('NO_PASSWORD', () => msg`User has no password.`)
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
.with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`)
.otherwise(
() => msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
);
@@ -111,12 +83,7 @@ export const PasswordForm = ({ className, user }: PasswordFormProps) => {
return (
<Form {...form}>
{/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */}
<form
method="post"
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
@@ -173,14 +140,6 @@ export const PasswordForm = ({ className, user }: PasswordFormProps) => {
</Button>
</div>
</form>
<TwoFactorCodeDialog<TPasswordFormSchema>
open={isTwoFactorDialogOpen}
onOpenChange={setIsTwoFactorDialogOpen}
isSubmitting={isSubmitting}
submitLabel={<Trans>Update password</Trans>}
onSubmit={form.handleSubmit(onFormSubmit)}
/>
</Form>
);
};
@@ -1,332 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { LoaderIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZTwoFactorEnforcementFormSchema = z.object({
enabled: z.boolean(),
gracePeriodDays: z.coerce.number().int().min(0).max(365),
acknowledgeGracePeriodReduction: z.boolean(),
});
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
/**
* Instance-wide 2FA enforcement settings for the admin site-settings page.
*
* License-gated states:
*
* - Licensed: full form.
* - Unlicensed + unconfigured: section visible but disabled with a "requires
* license" note.
* - Unlicensed + configured ("configured but inactive", e.g. license lapsed):
* stored values shown read-only with a disable-only affordance — the only
* permitted unlicensed update is turning the stored policy off.
*/
export const AdminTwoFactorEnforcementSection = () => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const { data: enforcementConfig, isLoading } = trpc.admin.getTwoFactorEnforcement.useQuery();
const utils = trpc.useUtils();
const { mutateAsync: updateTwoFactorEnforcement, isPending: isUpdatePending } =
trpc.admin.updateTwoFactorEnforcement.useMutation();
const form = useForm<TTwoFactorEnforcementFormSchema>({
values: {
enabled: enforcementConfig?.enabled ?? false,
gracePeriodDays: enforcementConfig?.gracePeriodDays ?? 7,
acknowledgeGracePeriodReduction: false,
},
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
});
const watchedValues = form.watch();
const isLicensed = enforcementConfig?.isLicensed ?? false;
const isConfiguredButInactive = !isLicensed && (enforcementConfig?.enabled ?? false);
// Client-side mirror of the server's grace-reduction detection so we can
// surface the acknowledgement checkbox before submitting. The server resets
// `enforcedFrom` to now on an off→on transition, hence the `new Date()`
// anchor when the stored policy is currently disabled.
const isGraceReduction =
enforcementConfig !== undefined &&
isTwoFactorGracePeriodReduction({
previous: enforcementConfig.enabled
? {
anchors: [enforcementConfig.enforcedFrom ? new Date(enforcementConfig.enforcedFrom) : null],
gracePeriodDays: enforcementConfig.gracePeriodDays,
}
: null,
next: watchedValues.enabled
? {
anchors: [
enforcementConfig.enabled && enforcementConfig.enforcedFrom
? new Date(enforcementConfig.enforcedFrom)
: new Date(),
],
gracePeriodDays: watchedValues.gracePeriodDays,
}
: null,
now: new Date(),
});
const onUpdate = async (data: {
enabled: boolean;
gracePeriodDays: number;
acknowledgeGracePeriodReduction?: boolean;
}) => {
try {
await updateTwoFactorEnforcement(data);
await utils.admin.getTwoFactorEnforcement.invalidate();
toast({
title: _(msg`Two-factor enforcement settings updated`),
});
form.setValue('acknowledgeGracePeriodReduction', false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
toast({
title: _(msg`Two-factor authentication required`),
description: _(
msg`Enable two-factor authentication on your own account and verify it on this session before requiring it for the instance.`,
),
variant: 'destructive',
});
return;
}
if (error.code === AppErrorCode.FORBIDDEN) {
toast({
title: _(msg`License required`),
description: _(
msg`Your license does not include instance-wide two-factor enforcement. Only disabling the stored configuration is permitted.`,
),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
variant: 'destructive',
});
}
};
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
await onUpdate(data);
};
// Disable-only affordance for the "configured but inactive" state: submits
// an enabled→disabled transition with the stored values unchanged, which is
// the only unlicensed update the server accepts.
const onDisableOnly = async () => {
if (!enforcementConfig) {
return;
}
await onUpdate({
enabled: false,
gracePeriodDays: enforcementConfig.gracePeriodDays,
});
};
return (
<div>
<h2 className="font-semibold">
<Trans>Instance Two-Factor Enforcement</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Require every user on this instance, including administrators, to enable two-factor authentication within a
grace period.
</Trans>
</p>
{isLoading || !enforcementConfig ? (
<div className="mt-4 flex justify-center rounded-lg border py-16">
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting || !isLicensed} className="mt-4 flex flex-col gap-y-4">
{!isLicensed && !isConfiguredButInactive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Requires a license</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Instance-wide two-factor enforcement requires a Documenso license that includes this feature.
</Trans>
</AlertDescription>
</Alert>
)}
{isConfiguredButInactive && (
<Alert variant="warning">
<AlertTitle>
<Trans>Configured but inactive</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor enforcement is configured but your current license does not include this feature, so it
is not being enforced. You can disable the stored configuration below; changing it requires a
license.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Users who have not enabled two-factor authentication by their deadline are redirected to a
forced enrolment page before they can continue.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="gracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a user has to enable two-factor authentication. 0 forces enrolment immediately
after signing up or signing in.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{enforcementConfig.isActive && enforcementConfig.enforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since{' '}
{i18n.date(new Date(enforcementConfig.enforcedFrom), { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<Trans>
API tokens are exempt: tokens minted before a user's deadline keep working after it. Blocked users
cannot mint new tokens.
</Trans>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Users who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for users</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
{isConfiguredButInactive && (
<div className="mt-4 flex justify-end">
<Button type="button" variant="destructive" loading={isUpdatePending} onClick={onDisableOnly}>
<Trans>Disable enforcement</Trans>
</Button>
</div>
)}
</form>
</Form>
)}
</div>
);
};
@@ -17,7 +17,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { defaultFilter as commandScore } from 'cmdk';
import {
@@ -596,21 +596,9 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
<span className="ml-auto text-muted-foreground text-xs">
{hasValidSearch ? (
isVisibleCountCapped ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural
value={totalVisibleCount}
one="# result"
other="# results"
/>
)
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural
value={totalVisibleCount}
one="# item"
other="# items"
/>
<Trans>{totalVisibleCount} items</Trans>
)}
</span>
</div>
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TTemplate } from '@documenso/lib/types/template';
@@ -42,7 +41,6 @@ export const DirectTemplatePageView = ({
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
@@ -126,13 +124,6 @@ export const DirectTemplatePageView = ({
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
analytics.captureException(err, {
source: 'signing',
location: 'direct_template',
recipientId: directTemplateRecipient.id,
envelopeId: template.envelopeId,
});
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
@@ -1,7 +1,5 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -9,13 +7,11 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password';
export type DocumentSigningAuthPasswordProps = {
open: boolean;
@@ -39,12 +35,8 @@ export const DocumentSigningAuthPassword = ({
}: DocumentSigningAuthPasswordProps) => {
const { t } = useLingui();
const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Fetched on demand since this is only needed once the user opts for password auth.
const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, {
enabled: !!user,
});
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
@@ -55,10 +47,6 @@ export const DocumentSigningAuthPassword = ({
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
// If the query fails we fall through to the regular password form rather than blocking.
const isPasswordSetupRequired =
!!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD);
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
@@ -76,6 +64,8 @@ export const DocumentSigningAuthPassword = ({
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
@@ -89,22 +79,9 @@ export const DocumentSigningAuthPassword = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (user && isAuthMethodsPending) {
return (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (isPasswordSetupRequired) {
return <DocumentSigningAuthSetPassword onOpenChange={onOpenChange} />;
}
return (
<Form {...form}>
{/* method="post" so a pre-hydration native submit can't leak the password into the URL. */}
<form method="post" onSubmit={form.handleSubmit(onFormSubmit)}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{formErrorCode && (
@@ -1,63 +0,0 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { PasswordSetupRequest } from '~/components/forms/password-setup-request';
export type DocumentSigningAuthSetPasswordProps = {
onOpenChange: (value: boolean) => void;
};
/**
* Shown in place of the password reauth form when the signed in user has no
* password (e.g. they signed up via OAuth or a passkey).
*
* Password based action auth is meant to prove more than possession of a session,
* so rather than letting the session set a password inline we send the user the
* verified reset link and ask them to come back.
*/
export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => {
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
return (
<div className="space-y-4">
{isEmailPasswordSigninEnabled ? (
<>
<Alert variant="neutral">
<AlertTitle>
<Trans>No password set</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Signing this field requires a password, but your account does not have one. We can email you a link to
set one. Once done, sign in again and return to this document to continue.
</Trans>
</AlertDescription>
</Alert>
<PasswordSetupRequest />
</>
) : (
<Alert variant="warning">
<AlertTitle>
<Trans>Password authentication unavailable</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Your account does not have a password and password sign in is disabled for this instance. Please contact
the document sender to use a different authentication method.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
};
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -40,7 +39,6 @@ export const DocumentSigningCheckboxField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -128,13 +126,6 @@ export const DocumentSigningCheckboxField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -166,13 +157,6 @@ export const DocumentSigningCheckboxField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -232,13 +216,6 @@ export const DocumentSigningCheckboxField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating the signature.`),
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -89,7 +88,6 @@ export const DocumentSigningCompleteDialog = ({
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const analytics = useAnalytics();
const { t, i18n } = useLingui();
const { toast } = useToast();
@@ -181,11 +179,6 @@ export const DocumentSigningCompleteDialog = ({
return;
}
analytics.captureException(error, {
source: 'signing',
location: 'complete_document',
});
// This dialog owns the completion error toast for every signing surface
// so the user gets a specific, actionable message. Callers should run
// their own side effects (e.g. embeds posting document-error) and
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -40,7 +39,6 @@ export const DocumentSigningDateField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -87,13 +85,6 @@ export const DocumentSigningDateField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -122,13 +113,6 @@ export const DocumentSigningDateField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -36,7 +35,6 @@ export const DocumentSigningDropdownField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -88,13 +86,6 @@ export const DocumentSigningDropdownField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -129,13 +120,6 @@ export const DocumentSigningDropdownField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -34,7 +33,6 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { email: providedEmail } = useRequiredDocumentSigningContext();
@@ -80,13 +78,6 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -115,13 +106,6 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -39,7 +38,6 @@ export const DocumentSigningInitialsField = ({
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { fullName } = useRequiredDocumentSigningContext();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -86,13 +84,6 @@ export const DocumentSigningInitialsField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -121,13 +112,6 @@ export const DocumentSigningInitialsField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -40,7 +39,6 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
@@ -118,13 +116,6 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -153,13 +144,6 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,5 +1,4 @@
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -48,7 +47,6 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -144,13 +142,6 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -202,13 +193,6 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
@@ -82,6 +83,8 @@ export const DocumentSigningPageViewV1 = ({
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false);
@@ -115,6 +118,12 @@ export const DocumentSigningPageViewV1 = ({
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -32,7 +31,6 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -93,13 +91,6 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -129,13 +120,6 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the selection.`),
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -42,7 +41,6 @@ export function DocumentSigningRejectDialog({
}: DocumentSigningRejectDialogProps) {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@@ -78,12 +76,6 @@ export function DocumentSigningRejectDialog({
window.location.href = `/sign/${token}/rejected`;
}
} catch (err) {
analytics.captureException(err, {
source: 'signing',
location: 'reject_document',
documentId,
});
toast({
title: t`Error`,
description: t`An error occurred while rejecting the document. Please try again.`,
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -48,7 +47,6 @@ export const DocumentSigningSignatureField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient } = useDocumentSigningRecipientContext();
@@ -159,13 +157,6 @@ export const DocumentSigningSignatureField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
@@ -192,13 +183,6 @@ export const DocumentSigningSignatureField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
@@ -1,5 +1,4 @@
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -51,7 +50,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -172,13 +170,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -209,13 +200,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -28,7 +28,6 @@ import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type DocumentEditFormProps = {
@@ -43,7 +42,6 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const cspNonce = useCspNonce();
const navigate = useNavigate();
@@ -475,7 +473,6 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddFieldsFormPartial
@@ -1,4 +1,5 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -37,6 +38,7 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
const team = useCurrentTeam();
const navigate = useNavigate();
const analytics = useAnalytics();
const organisation = useCurrentOrganisation();
const userTimezone =
@@ -101,6 +103,12 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
// Handle legacy template creation.
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@@ -38,12 +37,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
/** How far past a resize handle you can still grab it, in screen pixels. */
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -281,13 +276,6 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
@@ -362,9 +350,6 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
shouldOverdrawWholeArea: true,
ignoreStroke: true,
flipEnabled: false,
anchorStyleFunc: (anchor) => {
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / scale);
},
boundBoxFunc: (oldBox, newBox) => {
// Enforce minimum size
if (newBox.width < 30 || newBox.height < 20) {
@@ -20,18 +20,20 @@ import {
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangleIcon, FileTextIcon, FormInputIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -79,7 +81,8 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { envelope, editorFields, navigateToStep, editorConfig, flushAutosave, syncEnvelope } =
useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -87,7 +90,32 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const [acroFormHasFieldsByItemRevision, setAcroFormHasFieldsByItemRevision] = useState<Record<string, boolean>>({});
const { revalidate } = useRevalidator();
const { toast } = useToast();
const { mutateAsync: importFieldsFromPdf, isPending: isImportingFieldsFromPdf } =
trpc.envelope.field.importFromPdf.useMutation();
const currentEnvelopeItemRevision = currentEnvelopeItem
? `${currentEnvelopeItem.id}:${currentEnvelopeItem.documentDataId}`
: null;
const currentItemHasAcroForm =
currentEnvelopeItemRevision !== null && acroFormHasFieldsByItemRevision[currentEnvelopeItemRevision] === true;
const onAcroFormDetected = useCallback(
(hasFields: boolean) => {
if (!currentEnvelopeItemRevision) {
return;
}
setAcroFormHasFieldsByItemRevision((prev) =>
prev[currentEnvelopeItemRevision] === hasFields ? prev : { ...prev, [currentEnvelopeItemRevision]: hasFields },
);
},
[currentEnvelopeItemRevision],
);
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
@@ -202,6 +230,40 @@ export const EnvelopeEditorFieldsPage = () => {
});
};
const onImportFromPdfClick = async () => {
try {
await flushAutosave();
const result = await importFieldsFromPdf({ envelopeId: envelope.id });
if (result.fieldsCreated === 0) {
toast({
title: _(msg`No form fields found`),
description: _(msg`This PDF does not contain any importable form fields.`),
duration: 5000,
});
return;
}
await syncEnvelope();
toast({
title: _(msg`Fields imported`),
description: _(
msg`Imported ${result.fieldsCreated} field${result.fieldsCreated === 1 ? '' : 's'} from the PDF form. Review and reassign in the editor.`,
),
duration: 5000,
});
} catch {
toast({
title: _(msg`Could not import fields`),
description: _(msg`Something went wrong while importing fields from the PDF.`),
variant: 'destructive',
duration: 5000,
});
}
};
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
@@ -291,6 +353,7 @@ export const EnvelopeEditorFieldsPage = () => {
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
onAcroFormDetected={onAcroFormDetected}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -383,6 +446,20 @@ export const EnvelopeEditorFieldsPage = () => {
/>
</>
)}
{currentItemHasAcroForm && envelope.status === DocumentStatus.DRAFT && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={() => void onImportFromPdfClick()}
disabled={isImportingFieldsFromPdf}
>
<FormInputIcon className="mr-2 -ml-1 h-4 w-4" />
{isImportingFieldsFromPdf ? <Trans>Importing...</Trans> : <Trans>Import from PDF form</Trans>}
</Button>
)}
</section>
{/* Field details section. */}
@@ -45,7 +45,6 @@ import { isDeepEqual } from 'remeda';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
@@ -53,7 +52,6 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const cspNonce = useCspNonce();
const { t } = useLingui();
const { toast } = useToast();
@@ -797,7 +795,6 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -1,5 +1,4 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
@@ -26,7 +25,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { useCspNonce } from '~/utils/nonce';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
@@ -43,12 +41,10 @@ type LocalFile = {
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const cspNonce = useCspNonce();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const analytics = useAnalytics();
const {
envelope,
@@ -217,12 +213,6 @@ export const EnvelopeEditorUploadPage = () => {
const { data } = await createPromise.catch((error) => {
console.error(error);
analytics.captureException(error, {
source: isEmbedded ? 'embed' : 'editor',
location: 'create_envelope_items',
envelopeId: envelope.id,
});
// Set error state on files in batch upload.
setLocalFiles((prev) =>
prev.map((uploadingFile) =>
@@ -300,12 +290,6 @@ export const EnvelopeEditorUploadPage = () => {
} catch (error) {
console.error(error);
analytics.captureException(error, {
source: isEmbedded ? 'embed' : 'editor',
location: 'replace_pdf',
envelopeId: envelope.id,
});
toast({
title: t`Replace failed`,
description: t`Something went wrong while replacing the PDF`,
@@ -496,7 +480,7 @@ export const EnvelopeEditorUploadPage = () => {
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext nonce={cspNonce} onDragEnd={onDragEnd}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
@@ -19,7 +18,6 @@ type GenericLocalField = TEnvelope['fields'][number] & {
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const {
envelopeStatus,
@@ -116,13 +114,6 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: currentEnvelopeItem?.envelopeId,
});
setRenderError(true);
}
};
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
@@ -54,7 +53,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const analytics = useAnalytics();
const {
envelopeData,
@@ -428,14 +426,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'page_render',
recipientId: recipient.id,
envelopeId: envelope.id,
});
setRenderError(true);
}
};
@@ -517,14 +507,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: payload.type,
recipientId: recipient.id,
envelopeId: envelope.id,
});
toast({
title: t`Error`,
description: t`An error occurred while signing the field.`,
@@ -118,6 +118,12 @@ export const EnvelopeSignerCompleteDialog = () => {
title: t`Document already signed`,
description: t`This document was already signed and no further action was taken.`,
});
} else {
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
}
if (onDocumentCompleted) {
@@ -142,13 +148,6 @@ export const EnvelopeSignerCompleteDialog = () => {
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
analytics.captureException(err, {
source: 'signing',
location: 'complete_document',
recipientId: recipient.id,
envelopeId: envelope.id,
});
onDocumentError?.();
}
@@ -222,13 +221,6 @@ export const EnvelopeSignerCompleteDialog = () => {
} catch (err) {
console.log('err', err);
analytics.captureException(err, {
source: 'signing',
location: 'complete_document_next_signer',
recipientId: recipient.id,
envelopeId: envelope.id,
});
onDocumentError?.();
// Rethrow so DocumentSigningCompleteDialog can toast a specific
@@ -93,6 +93,14 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
duration: 5000,
});
if (type === EnvelopeType.DOCUMENT) {
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
@@ -101,11 +109,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'upload_document',
});
const errorMessage = getUploadErrorMessage(error.code);
toast({
@@ -1,5 +1,4 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -35,7 +34,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const analytics = useAnalytics();
const team = useCurrentTeam();
@@ -114,11 +112,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'upload_document',
});
const errorMessage = getUploadErrorMessage(error.code);
toast({
@@ -46,7 +46,7 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
return (
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}-${currentEnvelopeItem.documentDataId}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
@@ -51,6 +50,7 @@ export type PDFViewerProps = {
scrollParentRef: ScrollTarget;
onDocumentLoad?: () => void;
onAcroFormDetected?: (hasFields: boolean) => void;
/**
* Additional component to render next to the image, such as a Konva canvas
@@ -64,12 +64,12 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
onAcroFormDetected,
customPageRenderer,
...props
}: PDFViewerProps) {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const $el = useRef<HTMLDivElement>(null);
@@ -126,6 +126,20 @@ export default function PDFViewer({
// eslint-disable-next-line require-atomic-updates
pdfRef.current = loadedPdf;
if (onAcroFormDetected) {
try {
const fieldObjects = await loadedPdf.getFieldObjects();
if (!isCancelled) {
onAcroFormDetected(fieldObjects !== null && Object.keys(fieldObjects).length > 0);
}
} catch {
if (!isCancelled) {
onAcroFormDetected(false);
}
}
}
// Fetch the pages
const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => {
const page = await loadedPdf.getPage(pageIndex + 1);
@@ -152,11 +166,6 @@ export default function PDFViewer({
console.error(err);
setLoadingState('error');
analytics.captureException(err, {
source: 'pdf_viewer',
location: 'pdf_load',
});
toast({
title: t`Error`,
description: t`An error occurred while loading the document.`,
@@ -175,7 +184,7 @@ export default function PDFViewer({
pdfRef.current = null;
}
};
}, [data]);
}, [data, onAcroFormDetected]);
// Notify when document is loaded
useEffect(() => {
@@ -373,8 +382,6 @@ const PdfViewerPage = ({
* Manages rendering a page from a pdf.
*/
const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => {
const analytics = useAnalytics();
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
const [imageUrl, setImageUrl] = useState('');
@@ -466,12 +473,6 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
if (!isCancelled) {
console.error(err);
analytics.captureException(err, {
source: 'pdf_viewer',
location: 'pdf_page_render',
});
setImageLoadingState('error');
}
} finally {
@@ -25,7 +25,6 @@ import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
export type TemplateEditFormProps = {
className?: string;
@@ -39,7 +38,6 @@ const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const TemplateEditForm = ({ initialTemplate, className, templateRootPath }: TemplateEditFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const cspNonce = useCspNonce();
const navigate = useNavigate();
const team = useCurrentTeam();
@@ -341,7 +339,6 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddTemplateFieldsFormPartial
@@ -1,144 +0,0 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, XIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router';
type GraceBannerCandidate = {
/**
* Dismissal scope: `instance` or `org:<organisationId>`.
*/
scope: string;
deadline: Date;
isSessionUnverified: boolean;
};
const buildDismissalKey = (userId: number, candidate: GraceBannerCandidate) =>
`2fa-grace-banner:${userId}:${candidate.scope}:${candidate.deadline.getTime()}`;
const toCandidate = (
status: TTwoFactorEnforcementStatus,
scope: string,
isSessionUnverified: boolean,
): GraceBannerCandidate | null => {
// Banner territory is the grace window only: required, not yet satisfied,
// not yet expired. Expiry is handled by the org 403 screen (and, for
// instance enforcement, the onboarding redirect).
if (!status.required || status.isSatisfied || status.isDeadlineExpired) {
return null;
}
return {
scope,
deadline: status.deadline,
isSessionUnverified,
};
};
/**
* Shared grace-period banner for 2FA enforcement.
*
* Shows the NEAREST applicable deadline between instance enforcement and the
* current organisation's enforcement. Dismissal is stored in `sessionStorage`
* keyed by userId + scope + deadline, so a changed deadline re-shows the
* banner.
*/
export const TwoFactorGraceBanner = () => {
const { i18n } = useLingui();
const { sessionData } = useOptionalSession();
const currentOrganisation = useOptionalCurrentOrganisation();
const location = useLocation();
const [dismissedKeys, setDismissedKeys] = useState<string[]>([]);
const candidate = useMemo(() => {
if (!sessionData) {
return null;
}
const isSessionUnverified = sessionData.user.twoFactorEnabled && !sessionData.session.twoFactorVerified;
const candidates = [
toCandidate(sessionData.twoFactorEnforcement, 'instance', isSessionUnverified),
currentOrganisation
? toCandidate(currentOrganisation.twoFactorEnforcement, `org:${currentOrganisation.id}`, isSessionUnverified)
: null,
].filter((value): value is GraceBannerCandidate => value !== null);
if (candidates.length === 0) {
return null;
}
return candidates.reduce((nearest, current) =>
current.deadline.getTime() < nearest.deadline.getTime() ? current : nearest,
);
}, [sessionData, currentOrganisation]);
if (!sessionData || !candidate) {
return null;
}
const dismissalKey = buildDismissalKey(sessionData.user.id, candidate);
const isDismissed =
dismissedKeys.includes(dismissalKey) ||
(typeof window !== 'undefined' && window.sessionStorage.getItem(dismissalKey) === 'true');
if (isDismissed) {
return null;
}
const onDismiss = () => {
try {
window.sessionStorage.setItem(dismissalKey, 'true');
} catch {
// Storage may be unavailable (private browsing); fall back to state.
}
setDismissedKeys((keys) => [...keys, dismissalKey]);
};
const returnTo = encodeURIComponent(`${location.pathname}${location.search}`);
return (
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-between gap-x-4 px-4 py-2 font-medium text-sm text-yellow-900">
<div className="flex items-center gap-x-2">
<AlertTriangleIcon className="h-4 w-4 flex-shrink-0" />
<span>
{candidate.isSessionUnverified ? (
<Trans>
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.
Two-factor authentication is enabled for your account, but this session has not been verified with a
second factor — sign out and log back in to verify this session.
</Trans>
) : (
<Trans>
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.{' '}
<Link to={`/onboarding/2fa?returnTo=${returnTo}`} className="underline">
Enable it now
</Link>{' '}
to keep access.
</Trans>
)}
</span>
</div>
<button
type="button"
className="rounded p-1 hover:bg-yellow-300 dark:hover:bg-yellow-500"
aria-label="Dismiss"
onClick={onDismiss}
>
<XIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
@@ -39,14 +39,13 @@ const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
/**
* Admin list pages which support prefilling their search from the URL, used
* for the "View all results" links on capped groups. Teams and
* for the "View all results" links on capped groups. Teams, recipients and
* subscriptions have no admin list pages.
*/
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
};
export type UseAdminSearchCategoriesOptions = {
@@ -6,7 +6,6 @@ import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/orga
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -101,23 +100,6 @@ export const OrganisationMembersDataTable = () => {
header: _(msg`Groups`),
cell: ({ row }) => row.original.groups.filter((group) => group.type === OrganisationGroupType.CUSTOM).length,
},
{
// Per-member 2FA compliance indicator: enrolment is the durable half
// of the satisfaction rule, so org admins can see who has and hasn't
// enrolled (non-compliant members still consume seats).
header: _(msg`2FA`),
accessorKey: 'twoFactorEnabled',
cell: ({ row }) =>
row.original.twoFactorEnabled ? (
<Badge variant="default">
<Trans>Enrolled</Trans>
</Badge>
) : (
<Badge variant="neutral">
<Trans>Not enrolled</Trans>
</Badge>
),
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
-44
View File
@@ -18,23 +18,6 @@ import './utils/polyfills/promise-with-resolvers';
* the page early, leaving dead event handlers (broken dropdowns, native form
* submits).
*/
/**
* Signing and direct template URLs contain recipient tokens which must never
* be sent to PostHog. Recipient context is attached explicitly via
* `recipientId` where needed instead.
*/
const redactTokensFromUrl = (value: string) => {
return value.replace(/(\/(?:sign|d|direct)\/)([^/?#]+)/g, '$1:token');
};
const URL_EVENT_PROPERTIES = [
'$current_url',
'$pathname',
'$referrer',
'$initial_referrer',
'$prev_pageview_pathname',
] as const;
function initPosthog() {
const postHogConfig = extractPostHogConfig();
@@ -42,34 +25,7 @@ function initPosthog() {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
// Only create person profiles for identified (authenticated) users,
// anonymous recipients on signing pages stay anonymous.
person_profiles: 'identified_only',
// Explicit events only, autocapture on signing pages blows up usage
// without providing actionable data.
autocapture: false,
capture_pageview: true,
capture_pageleave: false,
capture_exceptions: true,
before_send: (event) => {
if (!event) {
return null;
}
for (const property of URL_EVENT_PROPERTIES) {
const value = event.properties?.[property];
if (typeof value === 'string') {
event.properties[property] = redactTokensFromUrl(value);
}
}
if (event.$set_once && typeof event.$set_once['$initial_current_url'] === 'string') {
event.$set_once['$initial_current_url'] = redactTokensFromUrl(event.$set_once['$initial_current_url']);
}
return event;
},
});
});
}
+3 -4
View File
@@ -7,11 +7,10 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { EntryContext, RouterContextProvider } from 'react-router';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import { langCookie } from './storage/lang-cookie.server';
import { nonceContext } from './utils/nonce';
export const streamTimeout = 5_000;
@@ -20,7 +19,7 @@ export default async function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: RouterContextProvider,
loadContext: AppLoadContext,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -34,7 +33,7 @@ export default async function handleRequest(
// scripts it injects (route manifest, hydration data, module preloads).
// The same nonce is also exposed to the React tree via the root loader so
// our own inline scripts/styles can carry it.
const nonce = loadContext.get(nonceContext) || undefined;
const nonce = loadContext.nonce || undefined;
return new Promise((resolve, reject) => {
let shellRendered = false;
-13
View File
@@ -1,13 +0,0 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { type MiddlewareFunction, redirect } from 'react-router';
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
const { user } = await getOptionalSession(request);
if (!user || !isAdmin(user)) {
throw redirect('/');
}
return next();
};
-8
View File
@@ -1,8 +0,0 @@
import type { MiddlewareFunction } from 'react-router';
import { getRequestNonce } from '../../server/load-context';
import { nonceContext } from '../utils/nonce';
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
context.set(nonceContext, getRequestNonce());
};
+3 -29
View File
@@ -1,18 +1,14 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { getBasePath } from '@documenso/lib/constants/app';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { createPublicEnv } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
import { TrpcProvider } from '@documenso/trpc/react';
import { getOrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { useEffect } from 'react';
import {
data,
isRouteErrorResponse,
@@ -25,16 +21,13 @@ import {
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import { nonceMiddleware } from '~/middleware/nonce';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta';
import { nonce, nonceContext } from './utils/nonce';
export const middleware = [nonceMiddleware];
import { nonce } from './utils/nonce';
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
@@ -65,19 +58,9 @@ export async function loader({ context, request }: Route.LoaderArgs) {
const disableAnimations = cookieHeader.includes('__disable_animations=true');
let organisations = null;
let twoFactorEnforcement: TTwoFactorEnforcementStatus = { required: false };
if (session.isAuthenticated) {
[organisations, twoFactorEnforcement] = await Promise.all([
getOrganisationSession({
userId: session.user.id,
user: session.user,
session: session.session,
}),
// Instance 2FA enforcement status is part of the session payload so
// layouts can derive banners/redirects client-side without new queries.
getTwoFactorEnforcementStatus({ user: session.user, session: session.session }),
]);
organisations = await getOrganisationSession({ userId: session.user.id });
}
return data(
@@ -89,13 +72,12 @@ export async function loader({ context, request }: Route.LoaderArgs) {
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
nonce: context.get(nonceContext),
nonce: context.nonce,
session: session.isAuthenticated
? {
user: session.user,
session: session.session,
organisations: organisations || [],
twoFactorEnforcement,
}
: null,
publicEnv: createPublicEnv(),
@@ -218,19 +200,11 @@ export default function App() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error);
}
useEffect(() => {
if (errorCode !== 404) {
analytics.captureException(error, { source: 'app', location: 'root_boundary' });
}
}, [error]);
return <GenericErrorLayout errorCode={errorCode} />;
}
@@ -1,44 +1,31 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { calculateTwoFactorDeadlineTimerDelay } from '@documenso/lib/utils/two-factor';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useEffect } from 'react';
import { Link, Outlet, redirect, useLocation, useNavigate } from 'react-router';
import { Link, Outlet, redirect } from 'react-router';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { OrganisationBillingBanner } from '~/components/general/organisations/organisation-billing-banner';
import { OrganisationQuotaBanner } from '~/components/general/organisations/organisation-quota-banner';
import { TwoFactorGraceBanner } from '~/components/general/two-factor-grace-banner';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
/**
* Builds the enrolment redirect for an instance-blocked user, carrying the
* current path so they land back where they were after enrolling.
* Don't revalidate (run the loader on sequential navigations)
*
* Update values via providers.
*/
const buildTwoFactorOnboardingPath = (currentPath: string) => {
const returnTo = (isValidReturnTo(currentPath) && normalizeReturnTo(currentPath)) || '/';
return `/onboarding/2fa?returnTo=${encodeURIComponent(returnTo)}`;
};
// Note: no `shouldRevalidate` suppression on this layout (the root layout
// keeps its own) — the loader must rerun on navigations so the instance
// enforcement redirect below is re-evaluated server-side.
export const shouldRevalidate = () => false;
export async function loader({ request }: Route.LoaderArgs) {
const [session, banner] = await Promise.all([
@@ -50,22 +37,6 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/signin');
}
// Instance-wide 2FA enforcement (UX chokepoint — the security boundary is
// the tRPC/Hono asserts): a blocked user is redirected into forced
// enrolment. `/onboarding/2fa` lives outside this layout, so the redirect
// cannot loop. Never blocks login itself — signin/onboarding are outside
// this layout too.
const twoFactorEnforcement = await getTwoFactorEnforcementStatus({
user: session.user,
session: session.session,
});
if (twoFactorEnforcement.required && twoFactorEnforcement.isBlocked) {
const url = new URL(request.url);
throw redirect(buildTwoFactorOnboardingPath(`${url.pathname}${url.search}`));
}
return {
banner,
};
@@ -74,51 +45,7 @@ export async function loader({ request }: Route.LoaderArgs) {
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
const { banner } = loaderData;
const { user, session, organisations, twoFactorEnforcement } = useSession();
const location = useLocation();
const navigate = useNavigate();
// Client-side counterpart of the loader's instance enforcement redirect:
// parent-layout loaders don't rerun on every child navigation, and a grace
// deadline can pass while the app is open. The session provider refreshes
// the enforcement status on navigation/focus; the timer covers a deadline
// crossing while the tab sits idle.
const isInstanceTwoFactorBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked;
useEffect(() => {
if (!twoFactorEnforcement.required || twoFactorEnforcement.isSatisfied) {
return;
}
const redirectToOnboarding = () => {
void navigate(buildTwoFactorOnboardingPath(`${location.pathname}${location.search}`));
};
if (twoFactorEnforcement.isBlocked) {
redirectToOnboarding();
return;
}
// Within grace: fire at the deadline instant. A `null` delay means the
// deadline is beyond `setTimeout` range (~24.8 days) — no timer needed,
// the status is re-evaluated long before then.
const timerDelay = calculateTwoFactorDeadlineTimerDelay({
deadline: twoFactorEnforcement.deadline,
now: new Date(),
});
if (timerDelay === null) {
return;
}
const timeout = window.setTimeout(redirectToOnboarding, timerDelay);
return () => {
window.clearTimeout(timeout);
};
}, [twoFactorEnforcement, location.pathname, location.search, navigate]);
const { user, organisations } = useSession();
const { layoutMode } = useChildRouteFlags();
@@ -153,65 +80,6 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
match?.id === 'routes/_authenticated+/t.$teamUrl+/templates.$id.edit',
);
// Per-organisation 2FA enforcement: when the current org/team context's
// organisation blocks the user, render a 403 screen (NOT a redirect — the
// rest of the app stays usable) linking to the enrolment page. Derived
// client-side from the session provider's bootstrap payload, which stays
// readable while blocked. This is UX only — the security boundary is the
// tRPC/Hono asserts.
const isCurrentOrganisationTwoFactorBlocked =
Boolean(orgUrl || teamUrl) &&
Boolean(currentOrganisation?.twoFactorEnforcement.required && currentOrganisation.twoFactorEnforcement.isBlocked);
// State (b): enrolled, but this session never passed a second factor —
// enrolment would rightly refuse, so the remediation is a fresh sign-in.
const requiresRelogin = user.twoFactorEnabled && !session.twoFactorVerified;
// Instance enforcement takes precedence over the org 403 below: render
// nothing while the effect above navigates to forced enrolment.
if (isInstanceTwoFactorBlocked) {
return null;
}
if (isCurrentOrganisationTwoFactorBlocked) {
const returnTo = encodeURIComponent(`${location.pathname}${location.search}`);
return (
<GenericErrorLayout
errorCode={403}
errorCodeMap={{
403: {
heading: msg`Two-factor authentication required`,
subHeading: msg`403 Forbidden`,
message: requiresRelogin
? msg`This organisation requires two-factor authentication. Two-factor authentication is enabled for your account, but this session has not been verified with a second factor. Sign out and log back in to verify this session.`
: msg`This organisation requires two-factor authentication. Enable it for your account to regain access. The rest of your account remains available.`,
},
}}
primaryButton={
requiresRelogin ? (
<Button onClick={() => void authClient.signOut()}>
<Trans>Sign out</Trans>
</Button>
) : (
<Button asChild>
<Link to={`/onboarding/2fa?returnTo=${returnTo}`}>
<Trans>Set up two-factor authentication</Trans>
</Link>
</Button>
)
}
secondaryButton={
<Button variant="ghost" asChild>
<Link to="/">
<Trans>Go home</Trans>
</Link>
</Button>
}
/>
);
}
if (orgNotFound || teamNotFound) {
return (
<GenericErrorLayout
@@ -244,8 +112,6 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={currentTeam || null}>
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
<TwoFactorGraceBanner />
<OrganisationBillingBanner />
<OrganisationQuotaBanner />
@@ -1,4 +1,4 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,18 +20,16 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import { adminMiddleware } from '~/middleware/admin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Admin`);
}
export const middleware = [adminMiddleware];
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { user } = await getSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
@@ -1,7 +1,9 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -22,10 +24,32 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -35,8 +59,8 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
<div className="mt-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by claim ID or name`}
className="mb-4"
/>
@@ -12,7 +12,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -30,12 +30,6 @@ export default function AdminDocumentsPage() {
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const urlTerm = searchParams?.get?.('term') ?? '';
useEffect(() => {
setTerm(urlTerm);
}, [urlTerm]);
const { data: findDocumentsData, isPending: isFindDocumentsLoading } = trpc.admin.document.find.useQuery(
{
query: debouncedTerm,
@@ -137,7 +131,7 @@ export default function AdminDocumentsPage() {
<div>
<Input
type="search"
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
placeholder={_(msg`Search by document title, team:123 or user:123`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
@@ -1,6 +1,8 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -9,10 +11,32 @@ import { AdminEmailTransportsTable } from '~/components/tables/admin-email-trans
export default function AdminEmailTransportsPage() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -22,8 +46,8 @@ export default function AdminEmailTransportsPage() {
<div className="mt-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by name or from address`}
className="mb-4"
/>
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -5,9 +6,8 @@ import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro';
import { debounce, parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import {
@@ -52,17 +52,14 @@ export default function OrganisationStats() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [{ query: searchQuery }, setSearchFilters] = useQueryStates(
{
query: parseAsString.withDefault(''),
page: parseAsInteger,
},
{ shallow: false, limitUrlUpdates: debounce(500) },
);
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const [displayMode, setDisplayMode] = useState<OrganisationStatsDisplayMode>('usage');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
@@ -74,12 +71,29 @@ export default function OrganisationStats() {
const claimOptions = claimsData?.data ?? [];
const onSearchQueryChange = (value: string) => {
void setSearchFilters({
query: value || null,
page: null,
});
};
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
params.delete('page');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPeriodChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
@@ -114,8 +128,8 @@ export default function OrganisationStats() {
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
<Input
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation name, URL or ID`}
className="flex-1"
/>
@@ -1,6 +1,8 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -8,10 +10,32 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
export default function Organisations() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -19,8 +43,8 @@ export default function Organisations() {
<div className="mt-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
className="mb-4"
/>
@@ -6,7 +6,6 @@ import { useLingui } from '@lingui/react';
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
import { AdminTwoFactorEnforcementSection } from '~/components/general/admin-two-factor-enforcement-section';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/site-settings';
@@ -32,8 +31,6 @@ export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentPro
<AdminSiteBannerSection banner={banner} />
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
<AdminTwoFactorEnforcementSection />
</div>
</div>
);
@@ -1,4 +1,3 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
@@ -76,11 +75,6 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { user: currentUser } = useSession();
// Self-reset is forbidden server-side; hide the affordance entirely.
const canResetTwoFactor = user.twoFactorEnabled && user.id !== currentUser.id;
const roles = user.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
@@ -234,7 +228,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
</Accordion>
<div className="mt-16 flex flex-col gap-4">
{canResetTwoFactor && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
@@ -7,7 +7,6 @@ import { Trans } from '@lingui/react/macro';
import { OrganisationDeleteDialog } from '~/components/dialogs/organisation-delete-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { OrganisationTwoFactorEnforcementForm } from '~/components/forms/organisation-two-factor-enforcement-form';
import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
@@ -30,19 +29,6 @@ export default function OrganisationSettingsGeneral() {
<OrganisationUpdateForm />
</div>
{canExecuteOrganisationAction('MANAGE_ORGANISATION_SECURITY', organisation.currentOrganisationRole) && (
<>
<hr className="my-6" />
<SettingsHeader
title={_(msg`Two-factor authentication`)}
subtitle={_(msg`Require members of this organisation to use two-factor authentication.`)}
/>
<OrganisationTwoFactorEnforcementForm />
</>
)}
{canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -1,9 +1,10 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
@@ -14,16 +15,35 @@ import { OrganisationMembersDataTable } from '~/components/tables/organisation-m
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
@@ -37,8 +57,8 @@ export default function TeamsSettingsMembersPage() {
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={_(msg`Search`)}
/>
@@ -1,6 +1,8 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -9,10 +11,27 @@ import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-t
export default function OrganisationSettingsTeamsPage() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -21,8 +40,8 @@ export default function OrganisationSettingsTeamsPage() {
</SettingsHeader>
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,8 +1,6 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { getUserAuthMethods } from '@documenso/lib/server-only/user/get-user-auth-methods';
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
import { prisma } from '@documenso/prisma';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -14,7 +12,6 @@ import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-au
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { PasswordForm } from '~/components/forms/password';
import { PasswordSetupRequestButton } from '~/components/forms/password-setup-request-button';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
@@ -27,10 +24,33 @@ export function meta() {
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const authMethods = await getUserAuthMethods({ userId: user.id });
// Todo: Use providers instead after RR7 migration.
// const accounts = await prisma.account.findMany({
// where: {
// userId: user.id,
// },
// select: {
// provider: true,
// },
// });
// const providers = accounts.map((account) => account.provider);
// let hasEmailPasswordAccount = providers.includes('DOCUMENSO');
const hasEmailPasswordAccount: boolean = await prisma.user
.count({
where: {
id: user.id,
password: {
not: null,
},
},
})
.then((value) => value > 0);
return {
hasEmailPasswordAccount: authMethods.includes(UserAuthMethod.PASSWORD),
// providers,
hasEmailPasswordAccount,
};
}
@@ -40,36 +60,14 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
const { _ } = useLingui();
const { user } = useSession();
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
return (
<div>
<SettingsHeader
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{hasEmailPasswordAccount && <PasswordForm user={user} />}
{!hasEmailPasswordAccount && isEmailPasswordSigninEnabled && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Set a password</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
<Trans>
Your account has no password. Add one to sign in with your email and to sign documents that require it.
We'll email you a link.
</Trans>
</AlertDescription>
</div>
<PasswordSetupRequestButton />
</Alert>
)}
<Alert className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
@@ -1,5 +1,4 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -8,7 +7,6 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { ShouldRevalidateFunctionArgs } from 'react-router';
import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router';
@@ -68,18 +66,8 @@ export default function DocumentsLayout() {
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'editor',
location: 'editor_layout_boundary',
envelopeId: params.id,
});
}, [error]);
const errorCodeMap = {
404: {
subHeading: msg`404 Document not found`,
@@ -1,9 +1,11 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -14,12 +16,34 @@ import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsGroupsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const everyoneGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
@@ -37,8 +61,8 @@ export default function TeamsSettingsGroupsPage() {
</SettingsHeader>
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,6 +1,8 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -9,10 +11,32 @@ import { TeamMembersTable } from '~/components/tables/team-members-table';
export default function TeamsSettingsMembersPage() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -21,8 +45,8 @@ export default function TeamsSettingsMembersPage() {
</SettingsHeader>
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -68,20 +68,6 @@ export default function ApiTokensPage() {
return i18n.date(row.original.expires);
},
},
{
header: t`Last Used`,
cell: ({ row }) => {
if (!row.original.lastUsedAt) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
return <span className="text-foreground">{i18n.date(row.original.lastUsedAt)}</span>;
},
},
{
header: t`Actions`,
cell: ({ row }) => (
@@ -160,9 +146,6 @@ export default function ApiTokensPage() {
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
@@ -19,8 +20,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { CheckCircle2Icon, ChevronRightIcon, PencilIcon, TerminalIcon, XCircleIcon } from 'lucide-react';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
@@ -51,14 +51,13 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
const { toast } = useToast();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const parsedSearchParams = WebhookSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
@@ -82,6 +81,26 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
query: parsedSearchParams.query,
});
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
@@ -233,8 +252,8 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
<div className="mt-4">
<div className="mb-4 flex flex-row items-center justify-between gap-x-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by ID`}
/>
@@ -1,5 +1,4 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -9,7 +8,6 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useEffect } from 'react';
import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -86,18 +84,8 @@ export default function TemplatesLayout() {
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'editor',
location: 'editor_layout_boundary',
envelopeId: params.id,
});
}, [error]);
const errorCodeMap = {
404: {
subHeading: msg`404 Template not found`,
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -6,7 +5,6 @@ import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { useEffect } from 'react';
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -50,14 +48,8 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, { source: 'signing', location: 'recipient_layout_boundary' });
}, [error]);
return (
<GenericErrorLayout
errorCode={errorCode}
@@ -1,244 +0,0 @@
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-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 { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigate } from 'react-router';
import { z } from 'zod';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/2fa-challenge';
export function meta() {
return appMetaTags(msg`Two-Factor Authentication`);
}
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
// A signed-in user has no pending challenge to complete (any successful
// sign-in clears it server-side).
if (isAuthenticated) {
throw redirect('/');
}
return null;
}
const ZTwoFactorChallengeFormSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
type TTwoFactorChallengeFormSchema = z.infer<typeof ZTwoFactorChallengeFormSchema>;
export default function TwoFactorChallenge() {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [isValidatingChallenge, setIsValidatingChallenge] = useState(true);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<'totp' | 'backup'>('totp');
const form = useForm<TTwoFactorChallengeFormSchema>({
values: {
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZTwoFactorChallengeFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onRedirectToSignIn = async () => {
toast({
title: _(msg`Sign in required`),
description: _(msg`Your sign-in attempt has expired. Please sign in again.`),
variant: 'destructive',
});
await navigate('/signin');
};
useEffect(() => {
void authClient.twoFactor
.getChallenge()
.then(async ({ valid }) => {
if (!valid) {
await onRedirectToSignIn();
return;
}
setIsValidatingChallenge(false);
})
.catch(async () => onRedirectToSignIn());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onToggleTwoFactorAuthenticationMethodClick = () => {
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
if (method === 'totp') {
form.setValue('backupCode', '');
}
if (method === 'backup') {
form.setValue('totpCode', '');
}
setTwoFactorAuthenticationMethod(method);
};
const onFormSubmit = async ({ totpCode, backupCode }: TTwoFactorChallengeFormSchema) => {
try {
// On success this navigates to the server-provided redirect path.
await authClient.twoFactor.verifyChallenge(
twoFactorAuthenticationMethod === 'totp' ? { totpCode } : { backupCode },
);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AuthenticationErrorCode.TwoFactorChallengeExpired) {
await onRedirectToSignIn();
return;
}
if (error.code === AuthenticationErrorCode.InvalidTwoFactorCode) {
toast({
title: _(msg`Unable to sign in`),
description: _(msg`The two-factor authentication code provided is incorrect.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your code. Please try again.`),
variant: 'destructive',
});
}
};
if (isValidatingChallenge) {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Checking your sign-in...</Trans>
</p>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<h1 className="font-semibold text-2xl">
<Trans>Two-Factor Authentication</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
{twoFactorAuthenticationMethod === 'totp' ? (
<Trans>Enter the code from your authenticator app to finish signing in.</Trans>
) : (
<Trans>Enter one of your backup codes to finish signing in.</Trans>
)}
</p>
<hr className="-mx-6 my-4" />
<Form {...form}>
<form className="flex w-full flex-col gap-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
{twoFactorAuthenticationMethod === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{twoFactorAuthenticationMethod === 'backup' && (
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
<Button type="button" variant="secondary" onClick={onToggleTwoFactorAuthenticationMethodClick}>
{twoFactorAuthenticationMethod === 'totp' ? (
<Trans>Use Backup Code</Trans>
) : (
<Trans>Use Authenticator</Trans>
)}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</div>
</fieldset>
</form>
</Form>
<p className="mt-6 text-center text-muted-foreground text-sm">
<Trans>
Not you?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Back to sign in
</Link>
</Trans>
</p>
</div>
</div>
);
}
@@ -32,12 +32,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
organisation: {
select: {
name: true,
organisationGlobalSettings: {
select: {
twoFactorRequired: true,
twoFactorGracePeriodDays: true,
},
},
},
},
},
@@ -77,10 +71,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
},
});
// Non-blocking notice data: joining always succeeds, but the member's 2FA
// grace window starts at join when the organisation requires 2FA.
const twoFactorSettings = organisationMemberInvite.organisation.organisationGlobalSettings;
return {
state: 'Pending',
token: organisationMemberInvite.token,
@@ -88,8 +78,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
organisationName,
userExists: user !== null,
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
organisationTwoFactorRequired: twoFactorSettings.twoFactorRequired,
organisationTwoFactorGracePeriodDays: twoFactorSettings.twoFactorGracePeriodDays,
} as const;
}
@@ -153,8 +141,6 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
organisationName={data.organisationName}
userExists={data.userExists}
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
organisationTwoFactorRequired={data.organisationTwoFactorRequired}
organisationTwoFactorGracePeriodDays={data.organisationTwoFactorGracePeriodDays}
/>
);
}
@@ -165,8 +151,6 @@ type PendingInvitationProps = {
organisationName: string;
userExists: boolean;
isSessionUserTheInvitedUser: boolean;
organisationTwoFactorRequired: boolean;
organisationTwoFactorGracePeriodDays: number;
};
type InvitationResult = 'idle' | 'accepted' | 'declined';
@@ -179,8 +163,6 @@ const PendingInvitation = ({
organisationName,
userExists,
isSessionUserTheInvitedUser,
organisationTwoFactorRequired,
organisationTwoFactorGracePeriodDays,
}: PendingInvitationProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -307,24 +289,6 @@ const PendingInvitation = ({
</Trans>
</p>
{/* Non-blocking notice: accepting always succeeds; access to the
organisation blocks only after the grace period expires. */}
{organisationTwoFactorRequired && !actionIsDecline && (
<p className="mt-2 mb-4 text-muted-foreground text-sm">
{organisationTwoFactorGracePeriodDays > 0 ? (
<Trans>
This organisation requires two-factor authentication. You will need to enable it within{' '}
{organisationTwoFactorGracePeriodDays} days of joining to keep access to the organisation.
</Trans>
) : (
<Trans>
This organisation requires two-factor authentication. You will need to enable it immediately after
joining to access the organisation.
</Trans>
)}
</p>
)}
{acceptFailureReason && (
<p className="mt-2 mb-4 text-destructive text-sm">
{match(acceptFailureReason)
@@ -86,12 +86,6 @@ export async function loader({ params }: Route.LoaderArgs) {
name: true,
url: true,
avatarImageId: true,
organisationGlobalSettings: {
select: {
twoFactorRequired: true,
twoFactorGracePeriodDays: true,
},
},
},
});
@@ -113,10 +107,6 @@ export async function loader({ params }: Route.LoaderArgs) {
name: organisation.name,
url: organisation.url,
avatar: organisation.avatarImageId,
// Non-blocking notice data: SSO membership creation always succeeds;
// the 2FA grace window starts at join.
twoFactorRequired: organisation.organisationGlobalSettings.twoFactorRequired,
twoFactorGracePeriodDays: organisation.organisationGlobalSettings.twoFactorGracePeriodDays,
},
} as const;
}
@@ -276,26 +266,6 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
</div>
</div>
{/* Non-blocking notice: confirming always succeeds; organisation
access blocks only after the grace period expires. */}
{organisation.twoFactorRequired && (
<Alert variant="neutral">
<AlertDescription>
{organisation.twoFactorGracePeriodDays > 0 ? (
<Trans>
This organisation requires two-factor authentication. You will need to enable it within{' '}
{organisation.twoFactorGracePeriodDays} days of joining to keep access to the organisation.
</Trans>
) : (
<Trans>
This organisation requires two-factor authentication. You will need to enable it immediately after
joining to access the organisation.
</Trans>
)}
</AlertDescription>
</Alert>
)}
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`accept-conditions`}
@@ -1,8 +1,8 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
export const loader = async () => {
export const loader = () => {
try {
const certStatus = await getCertificateStatus();
const certStatus = getCertificateStatus();
return Response.json({
isAvailable: certStatus.isAvailable,
+1 -1
View File
@@ -22,7 +22,7 @@ export const loader = async () => {
}
try {
const certStatus = await getCertificateStatus();
const certStatus = getCertificateStatus();
if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' };
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
@@ -6,7 +5,6 @@ import {
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { Trans } from '@lingui/react/macro';
import { useEffect } from 'react';
import { isRouteErrorResponse, Outlet, useRouteError } from 'react-router';
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
@@ -49,30 +47,10 @@ export default function Layout() {
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
const analytics = useAnalytics();
const error = useRouteError();
console.log({ routeError: error });
useEffect(() => {
const isExpectedEmbedResponse =
isRouteErrorResponse(error) &&
[
'embed-authentication-required',
'embed-paywall',
'embed-waiting-for-turn',
'embed-recipient-expired',
'embed-document-rejected',
'embed-document-completed',
].includes(error.data?.type);
if (isExpectedEmbedResponse) {
return;
}
analytics.captureException(error, { source: 'embed', location: 'embed_layout_boundary' });
}, [error]);
if (isRouteErrorResponse(error)) {
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
return (

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