mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 17:04:12 +10:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d004c7cf | |||
| 403c1ca916 | |||
| b79895b38c |
@@ -1,430 +0,0 @@
|
|||||||
---
|
|
||||||
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
-15
@@ -103,7 +103,7 @@ NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
|
|||||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
|
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
|
||||||
|
|
||||||
# [[SMTP]]
|
# [[SMTP]]
|
||||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | resend | mailchannels
|
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||||
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||||
# OPTIONAL: Defines the host to use for sending emails.
|
# OPTIONAL: Defines the host to use for sending emails.
|
||||||
NEXT_PRIVATE_SMTP_HOST="127.0.0.1"
|
NEXT_PRIVATE_SMTP_HOST="127.0.0.1"
|
||||||
@@ -180,20 +180,6 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
|
|||||||
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
|
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
|
||||||
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
||||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
||||||
# OPTIONAL: Set to "true" to disable all signin methods (email, Google, Microsoft, OIDC).
|
|
||||||
NEXT_PUBLIC_DISABLE_SIGNIN=
|
|
||||||
# OPTIONAL: Set to "true" to disable email/password signin only. Also closes /forgot-password and /reset-password.
|
|
||||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=
|
|
||||||
# OPTIONAL: Set to "true" to hide the Google signin button.
|
|
||||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=
|
|
||||||
# OPTIONAL: Set to "true" to hide the Microsoft signin button.
|
|
||||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=
|
|
||||||
# OPTIONAL: Set to "true" to hide the OIDC signin button.
|
|
||||||
NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=
|
|
||||||
# OPTIONAL: When OIDC is the only enabled signin transport, /signin auto-redirects
|
|
||||||
# to the OIDC provider (rendering only a spinner). Set to "true" to disable this
|
|
||||||
# and keep showing the signin page.
|
|
||||||
NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=
|
|
||||||
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
||||||
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ body:
|
|||||||
label: Browser [e.g., Chrome, Firefox]
|
label: Browser [e.g., Chrome, Firefox]
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Version [e.g., 2.13.0]
|
label: Version [e.g., 2.0.1]
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Please check the boxes that apply to this issue report.
|
label: Please check the boxes that apply to this issue report.
|
||||||
@@ -44,3 +44,4 @@ body:
|
|||||||
- label: I have included relevant environment information.
|
- label: I have included relevant environment information.
|
||||||
- label: I have included any relevant screenshots.
|
- label: I have included any relevant screenshots.
|
||||||
- label: I understand that this is a voluntary contribution and that there is no guarantee of resolution.
|
- label: I understand that this is a voluntary contribution and that there is no guarantee of resolution.
|
||||||
|
- label: I want to work on creating a PR for this issue if approved
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: Security vulnerability
|
|
||||||
url: https://github.com/documenso/documenso/security/advisories/new
|
|
||||||
about: Please report security vulnerabilities privately via GitHub Security Advisories. Do not open a public issue.
|
|
||||||
- name: Questions & Discussions
|
|
||||||
url: https://github.com/documenso/documenso/discussions
|
|
||||||
about: Ask questions, share ideas, and discuss Documenso with the community.
|
|
||||||
- name: Discord
|
|
||||||
url: https://documen.so/discord
|
|
||||||
about: Chat with the community and the team.
|
|
||||||
@@ -33,3 +33,4 @@ body:
|
|||||||
- label: I have explained the use case or scenario for this feature.
|
- label: I have explained the use case or scenario for this feature.
|
||||||
- label: I have included any relevant technical details or design suggestions.
|
- label: I have included any relevant technical details or design suggestions.
|
||||||
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||||
|
- label: I want to work on creating a PR for this issue if approved
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ body:
|
|||||||
description: 'Are there any additional context or information that might be relevant to the improvement suggestion.'
|
description: 'Are there any additional context or information that might be relevant to the improvement suggestion.'
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
id: assignee
|
||||||
|
attributes:
|
||||||
|
label: 'Do you want to work on this improvement?'
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- 'No'
|
||||||
|
- 'Yes'
|
||||||
|
default: 0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: 'Please check the boxes that apply to this improvement suggestion.'
|
label: 'Please check the boxes that apply to this improvement suggestion.'
|
||||||
|
|||||||
@@ -1,14 +1,3 @@
|
|||||||
<!--
|
|
||||||
We are no longer accepting external pull requests.
|
|
||||||
|
|
||||||
Aside from a small group of trusted contributors we reach out to directly,
|
|
||||||
new external PRs will usually be closed with a request to open an issue instead.
|
|
||||||
This is a security decision. See https://documenso.com/blog/why-we-re-pausing-external-pull-requests
|
|
||||||
|
|
||||||
If you're a trusted contributor or maintainer, continue below.
|
|
||||||
Otherwise, please open a detailed issue: https://github.com/documenso/documenso/issues/new/choose
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
<!--- Describe the changes introduced by this pull request. -->
|
<!--- Describe the changes introduced by this pull request. -->
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: Test Addition
|
||||||
|
about: Submit a new test, either unit or end-to-end (E2E), for review and inclusion
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!--- Provide a clear and concise description of the new test you are adding. -->
|
||||||
|
<!--- Explain the purpose of the test and what it aims to validate. -->
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
|
||||||
|
<!--- If this test addition is related to a specific issue, reference it here using #issue_number. -->
|
||||||
|
<!--- For example, "Fixes #123" or "Addresses #456". -->
|
||||||
|
|
||||||
|
## Test Details
|
||||||
|
|
||||||
|
<!--- Describe the details of the test you're adding. -->
|
||||||
|
<!--- Include information about inputs, expected outputs, and any specific scenarios. -->
|
||||||
|
|
||||||
|
- Test Name: Name of the test
|
||||||
|
- Type: [Unit / E2E]
|
||||||
|
- Description: Brief description of what the test checks
|
||||||
|
- Inputs: What inputs the test uses (if applicable)
|
||||||
|
- Expected Output: What output or behavior the test expects
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
<!--- Please check the boxes that apply to this pull request. -->
|
||||||
|
<!--- You can add or remove items as needed. -->
|
||||||
|
|
||||||
|
- [ ] I have written the new test and ensured it works as intended.
|
||||||
|
- [ ] I have added necessary documentation to explain the purpose of the test.
|
||||||
|
- [ ] I have followed the project's testing guidelines and coding style.
|
||||||
|
- [ ] I have addressed any review feedback from previous submissions, if applicable.
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
|
||||||
|
<!--- Provide any additional context or notes for the reviewers. -->
|
||||||
|
<!--- This might include explanations about the testing approach or any potential concerns. -->
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
name: 'Welcome New Contributors'
|
name: 'Welcome New Contributors'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: ['opened']
|
||||||
issues:
|
issues:
|
||||||
types: ['opened']
|
types: ['opened']
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
issues: write
|
issues: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -17,7 +20,10 @@ jobs:
|
|||||||
- uses: actions/first-interaction@v1
|
- uses: actions/first-interaction@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
pr-message: |
|
||||||
|
Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀
|
||||||
|
<br /> Feel free to hop into our community in [Discord](https://documen.so/discord)
|
||||||
issue-message: |
|
issue-message: |
|
||||||
Thank you for opening your first issue and for being a part of the open signing revolution!
|
Thank you for opening your first issue and for being a part of the open signing revolution!
|
||||||
<br /> One of our team members will review it and get back to you as soon as possible 💚
|
<br /> One of our team members will review it and get back to you as soon as it possible 💚
|
||||||
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
|
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: 'Issue Assignee Check'
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: ['assigned']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
countIssues:
|
||||||
|
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install Octokit
|
||||||
|
run: npm install @octokit/rest@18
|
||||||
|
|
||||||
|
- name: Check Assigned User's Issue Count
|
||||||
|
id: parse-comment
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const { Octokit } = require("@octokit/rest");
|
||||||
|
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||||
|
|
||||||
|
const username = context.payload.issue.assignee.login;
|
||||||
|
console.log(`Username Extracted: ${username}`);
|
||||||
|
|
||||||
|
const { data: issues } = await octokit.issues.listForRepo({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
assignee: username,
|
||||||
|
state: 'open'
|
||||||
|
});
|
||||||
|
|
||||||
|
const issueCount = issues.length;
|
||||||
|
console.log(`Issue Count For ${username}: ${issueCount}`);
|
||||||
|
|
||||||
|
if (issueCount > 3) {
|
||||||
|
let issueCountMessage = `### 🚨 Documenso Police 🚨`;
|
||||||
|
issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`;
|
||||||
|
|
||||||
|
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: issueCountMessage,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: 'PR Review Reminder'
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: ['opened', 'ready_for_review']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checkPRs:
|
||||||
|
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install Octokit
|
||||||
|
run: npm install @octokit/rest@18
|
||||||
|
|
||||||
|
- name: Check user's PRs awaiting review
|
||||||
|
id: parse-prs
|
||||||
|
uses: actions/github-script@v5
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const { Octokit } = require("@octokit/rest");
|
||||||
|
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||||
|
|
||||||
|
const username = context.payload.pull_request.user.login;
|
||||||
|
console.log(`Username Extracted: ${username}`);
|
||||||
|
|
||||||
|
const { data: pullRequests } = await octokit.pulls.list({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
sort: 'created',
|
||||||
|
direction: 'asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review'));
|
||||||
|
const prCount = userPullRequests.length;
|
||||||
|
console.log(`PR Count for ${username}: ${prCount}`);
|
||||||
|
|
||||||
|
if (prCount > 3) {
|
||||||
|
let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`;
|
||||||
|
|
||||||
|
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.pull_request.number,
|
||||||
|
body: prReminderMessage,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,6 +16,24 @@ jobs:
|
|||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Check PR creator's previous activity
|
||||||
|
id: check_activity
|
||||||
|
run: |
|
||||||
|
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
|
||||||
|
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
|
||||||
|
if [ "$ACTIVITY" -eq 0 ]; then
|
||||||
|
echo "::set-output name=is_new::true"
|
||||||
|
else
|
||||||
|
echo "::set-output name=is_new::false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Count PRs created by user
|
||||||
|
id: count_prs
|
||||||
|
run: |
|
||||||
|
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
|
||||||
|
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
|
||||||
|
echo "::set-output name=pr_count::$PR_COUNT"
|
||||||
|
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
id: lint_pr_title
|
id: lint_pr_title
|
||||||
env:
|
env:
|
||||||
@@ -26,6 +44,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
message: |
|
message: |
|
||||||
|
Hey There! and thank you for opening this pull request! 📝👋🏼
|
||||||
|
|
||||||
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||||
|
|
||||||
Details:
|
Details:
|
||||||
@@ -33,3 +53,10 @@ jobs:
|
|||||||
```
|
```
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Thank you for following the naming conventions for pull request titles! 💚🚀
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ jobs:
|
|||||||
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
|
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
|
||||||
close-pr-message: 'This PR has been closed because of inactivity.'
|
close-pr-message: 'This PR has been closed because of inactivity.'
|
||||||
exempt-pr-labels: 'WIP,on-hold,needs review'
|
exempt-pr-labels: 'WIP,on-hold,needs review'
|
||||||
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,status: assigned,status: triage'
|
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@ vscode:
|
|||||||
extensions:
|
extensions:
|
||||||
- aaron-bond.better-comments
|
- aaron-bond.better-comments
|
||||||
- bradlc.vscode-tailwindcss
|
- bradlc.vscode-tailwindcss
|
||||||
- biomejs.biome
|
- dbaeumer.vscode-eslint
|
||||||
|
- esbenp.prettier-vscode
|
||||||
- mikestead.dotenv
|
- mikestead.dotenv
|
||||||
- unifiedjs.vscode-mdx
|
- unifiedjs.vscode-mdx
|
||||||
- GitHub.vscode-pull-request-github
|
- GitHub.vscode-pull-request-github
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
# Report security vulnerabilities privately via GitHub Security Advisories (preferred).
|
# General Issues
|
||||||
Contact: https://github.com/documenso/documenso/security/advisories/new
|
|
||||||
|
|
||||||
# Alternatively, report critical issues privately by email.
|
|
||||||
Contact: mailto:security@documenso.com
|
|
||||||
|
|
||||||
# Security policy
|
|
||||||
Policy: https://github.com/documenso/documenso/security/policy
|
|
||||||
|
|
||||||
# General (non-security) issues
|
|
||||||
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
|
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
|
||||||
|
|
||||||
|
# Report critical issues privately to let us take appropriate action before publishing.
|
||||||
|
Contact: mailto:security@documenso.com
|
||||||
Preferred-Languages: en
|
Preferred-Languages: en
|
||||||
Canonical: https://documenso.com/.well-known/security.txt
|
Canonical: https://documenso.com/.well-known/security.txt
|
||||||
@@ -65,7 +65,6 @@ Documenso is an open-source document signing platform built as a **monorepo** us
|
|||||||
| Package | Description |
|
| Package | Description |
|
||||||
| ---------------------------- | ------------------------- |
|
| ---------------------------- | ------------------------- |
|
||||||
| `@documenso/app-tests` | E2E tests (Playwright) |
|
| `@documenso/app-tests` | E2E tests (Playwright) |
|
||||||
| `@documenso/tailwind-config` | Shared Tailwind config |
|
|
||||||
| `@documenso/tsconfig` | Shared TypeScript configs |
|
| `@documenso/tsconfig` | Shared TypeScript configs |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|||||||
+6
-20
@@ -1,27 +1,13 @@
|
|||||||
# Contributing to Documenso
|
# Contributing to Documenso
|
||||||
|
|
||||||
> **We are no longer accepting external pull requests.**
|
If you plan to contribute to Documenso, please take a moment to feel awesome ✨ People like you are what open source is about ♥. Any contributions, no matter how big or small, are highly appreciated.
|
||||||
>
|
|
||||||
> Aside from a small group of trusted contributors we reach out to directly, we no longer merge external PRs. New pull requests will usually be closed with a request to open an issue instead. This is a security decision, not a judgement on your work. Read [Why We're Pausing External Pull Requests](https://documenso.com/blog/why-we-re-pausing-external-pull-requests) for the full reasoning.
|
|
||||||
>
|
|
||||||
> Documenso stays open source. You can still read, audit, run, and fork the code. The best way to contribute is through detailed issues.
|
|
||||||
|
|
||||||
## How to contribute now
|
## Before getting started
|
||||||
|
|
||||||
The most useful contribution is a detailed issue. Treat it like a spec. The more detail, the better:
|
- Before jumping into a PR be sure to search [existing PRs](https://github.com/documenso/documenso/pulls) or [issues](https://github.com/documenso/documenso/issues) for an open or closed item that relates to your submission.
|
||||||
|
- Select an issue from [here](https://github.com/documenso/documenso/issues) or create a new one
|
||||||
- The problem you're trying to solve, and who it affects
|
- Consider the results from the discussion on the issue
|
||||||
- How you expect the feature or change to behave
|
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
|
||||||
- Edge cases, constraints, and anything you've already considered
|
|
||||||
- Examples, mockups, or references where they help
|
|
||||||
|
|
||||||
Before opening an issue, search [existing issues](https://github.com/documenso/documenso/issues) and [discussions](https://github.com/documenso/documenso/discussions) for related items. If a proposal is detailed and fits where Documenso is heading, we'll pick it up and build against it.
|
|
||||||
|
|
||||||
For security vulnerabilities, do not open a public issue. Follow our [Security Policy](./SECURITY.md) instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
The sections below are for trusted contributors working with us directly, and for anyone running Documenso locally or maintaining a fork.
|
|
||||||
|
|
||||||
## English only PRs and Issues
|
## English only PRs and Issues
|
||||||
|
|
||||||
|
|||||||
@@ -51,18 +51,16 @@ Join us in creating the next generation of open trust infrastructure.
|
|||||||
|
|
||||||
## Community and Next Steps 🎯
|
## Community and Next Steps 🎯
|
||||||
|
|
||||||
- Try Documenso by self-hosting it or signing up at [documenso.com](https://documenso.com).
|
- Check out the first source code release in this repository and test it.
|
||||||
- Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions).
|
- Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions).
|
||||||
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know other community members.
|
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members.
|
||||||
- ⭐ the repository to help us raise awareness.
|
- ⭐ the repository to help us raise awareness.
|
||||||
- Open detailed [issues](https://github.com/documenso/documenso/issues) to report bugs or propose features.
|
- Spread the word on Twitter that Documenso is working towards a more open signing tool.
|
||||||
|
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
> **Note**: We no longer accept external pull requests, aside from a small group of trusted contributors we reach out to directly. The best way to contribute is through detailed issues. Read [Why We're Pausing External Pull Requests](https://documenso.com/blog/why-we-re-pausing-external-pull-requests) for the reasoning.
|
- To contribute, please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
|
||||||
|
|
||||||
- Documenso stays open source. You can read, audit, run, and fork the code.
|
|
||||||
- To report issues or propose changes, see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
|
|
||||||
|
|
||||||
## Contact us
|
## Contact us
|
||||||
|
|
||||||
@@ -83,21 +81,17 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
<a href=""><img src="" alt=""></a>
|
<a href=""><img src="" alt=""></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
- [TypeScript](https://www.typescriptlang.org/) - Language
|
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||||
- [React Router v7](https://reactrouter.com/) - Framework
|
- [ReactRouter](https://reactrouter.com/) - Framework
|
||||||
- [Hono](https://hono.dev/) - Server
|
|
||||||
- [Prisma](https://www.prisma.io/) - ORM
|
- [Prisma](https://www.prisma.io/) - ORM
|
||||||
- [Tailwind CSS](https://tailwindcss.com/) - CSS
|
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library
|
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||||
- [react-email](https://react.email/) - Email Templates
|
- [react-email](https://react.email/) - Email Templates
|
||||||
- [Lingui](https://lingui.dev/) - Internationalization
|
|
||||||
- [tRPC](https://trpc.io/) - API
|
- [tRPC](https://trpc.io/) - API
|
||||||
- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signatures
|
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
|
||||||
- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs
|
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||||
- [@cantoo/pdf-lib](https://github.com/cantoo-scribe/pdf-lib) - PDF manipulation
|
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||||
- [Stripe](https://stripe.com/) - Payments
|
- [Stripe](https://stripe.com/) - Payments
|
||||||
- [Biome](https://biomejs.dev/) - Linting & Formatting
|
|
||||||
- [Playwright](https://playwright.dev/) - E2E Testing
|
|
||||||
|
|
||||||
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
|
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
|
||||||
|
|
||||||
@@ -202,10 +196,6 @@ For full instructions, requirements, and configuration details, see the [Self Ho
|
|||||||
|
|
||||||
[](https://elest.io/open-source/documenso)
|
[](https://elest.io/open-source/documenso)
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
If you believe you have found a security vulnerability in Documenso, please report it through our [Security Policy](https://github.com/documenso/documenso/security/policy). We prioritize private reports via [GitHub Security Advisories](https://github.com/documenso/documenso/security/advisories/new). See [SECURITY.md](./SECURITY.md) for scope and details.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
For troubleshooting self-hosted deployments, see the [Troubleshooting guide](https://docs.documenso.com/docs/self-hosting/maintenance/troubleshooting) and [Tips & Common Pitfalls](https://docs.documenso.com/docs/self-hosting/getting-started/tips).
|
For troubleshooting self-hosted deployments, see the [Troubleshooting guide](https://docs.documenso.com/docs/self-hosting/maintenance/troubleshooting) and [Tips & Common Pitfalls](https://docs.documenso.com/docs/self-hosting/getting-started/tips).
|
||||||
|
|||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
We take the security of Documenso seriously. As a platform trusted with legally binding documents, the safety of the project and the people who rely on it is a priority for us. We're grateful to the security researchers who help keep it that way. If you've found an issue, we'd genuinely like to hear about it.
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
Report security vulnerabilities privately. Do not open a public issue, discussion, or pull request for security reports.
|
|
||||||
|
|
||||||
We accept reports through two channels, in order of preference:
|
|
||||||
|
|
||||||
1. **GitHub Security Advisories (preferred)**. Use the [private vulnerability reporting form](https://github.com/documenso/documenso/security/advisories/new). This is our primary channel and lets us triage and work with you on a fix.
|
|
||||||
2. **Email**. If you cannot use GitHub Security Advisories, email [security@documenso.com](mailto:security@documenso.com).
|
|
||||||
|
|
||||||
Include the affected version, a clear description, steps to reproduce, and the potential impact.
|
|
||||||
|
|
||||||
## Triage and Response
|
|
||||||
|
|
||||||
We triage reports as we have availability. We read every report we receive, and we appreciate the time and effort it takes to put one together.
|
|
||||||
|
|
||||||
We also run [Codex](https://openai.com/codex/) security analysis across the codebase. If Codex has already reported the issue you're sending us, we may close your report as a duplicate. Please don't take this as a reflection on your work; it just means our automated tooling happened to surface the same thing first.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This policy covers vulnerabilities in the Documenso application code in this repository.
|
|
||||||
|
|
||||||
The items below are out of scope and will not be accepted. They are deployment, infrastructure, and configuration concerns that belong with the operator's firewall, network, and environment setup, not the application:
|
|
||||||
|
|
||||||
- Server-Side Request Forgery (SSRF) and related network-egress concerns
|
|
||||||
- DNS rebinding and other DNS-level issues
|
|
||||||
- Rate limiting, denial of service, and volumetric attacks
|
|
||||||
- TLS and certificate configuration, HTTP security headers, and other reverse-proxy or web-server configuration
|
|
||||||
- Findings that depend on insecure self-hosted infrastructure or misconfiguration
|
|
||||||
|
|
||||||
If you're unsure whether something is in scope, report it privately anyway and we'll happily take a look.
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
Security fixes are applied to the latest release. Run the most recent version of Documenso.
|
|
||||||
+64
-6
@@ -1,9 +1,67 @@
|
|||||||
# Signing Certificate
|
# Creating your own signing certificate
|
||||||
|
|
||||||
Documenso needs a signing certificate to digitally sign documents. For full, up-to-date instructions on generating, converting, and configuring a certificate, see the official documentation:
|
For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one:
|
||||||
|
|
||||||
- [Signing Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate): Overview and all certificate options
|
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:
|
||||||
- [Local Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local): Generate a self-signed `.p12` certificate with OpenSSL
|
|
||||||
- [Google Cloud HSM](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm): Sign using Google Cloud KMS
|
|
||||||
|
|
||||||
For deploying Documenso with Docker, see the [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) and [Docker Compose](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) guides.
|
`openssl genrsa -out private.key 2048`
|
||||||
|
|
||||||
|
2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate:
|
||||||
|
|
||||||
|
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
||||||
|
|
||||||
|
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid.
|
||||||
|
|
||||||
|
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set certificate password securely (won't appear in command history)
|
||||||
|
read -s -p "Enter certificate password: " CERT_PASS
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Create the p12 certificate using the environment variable
|
||||||
|
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \
|
||||||
|
-password env:CERT_PASS \
|
||||||
|
-keypbe PBE-SHA1-3DES \
|
||||||
|
-certpbe PBE-SHA1-3DES \
|
||||||
|
-macalg sha1
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **IMPORTANT**: A certificate password is required to prevent signing failures. Make sure to use a strong password (minimum 4 characters) when prompted. Certificates without passwords will cause "Failed to get private key bags" errors during document signing.
|
||||||
|
|
||||||
|
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
> We are still working on the publishing of docker images, in the meantime you can follow the steps below to create a production ready docker image.
|
||||||
|
|
||||||
|
Want to create a production ready docker image? Follow these steps:
|
||||||
|
|
||||||
|
- cd into `docker` directory
|
||||||
|
- Make `build.sh` executable by running `chmod +x build.sh`
|
||||||
|
- Run `./build.sh` to start building the docker image.
|
||||||
|
- Publish the image to your docker registry of choice (or) If you prefer running the image from local, run the below command
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -d --restart=unless-stopped -p 3000:3000 -v documenso:/app/data --name documenso documenso:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Command Breakdown:
|
||||||
|
|
||||||
|
- `-d` - Let's you run the container in background
|
||||||
|
- `-p` - Passes down which ports to use. First half is the host port, Second half is the app port. You can change the first half anything you want and reverse proxy to that port.
|
||||||
|
- `-v` - Volume let's you persist the data
|
||||||
|
- `--name` - Name of the container
|
||||||
|
- `documenso:latest` - Image you have built
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||||
|
|
||||||
|
## Railway
|
||||||
|
|
||||||
|
[](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||||
|
|
||||||
|
## Render
|
||||||
|
|
||||||
|
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||||
|
|||||||
+38
-9
@@ -1,16 +1,45 @@
|
|||||||
# @documenso/docs
|
# docs
|
||||||
|
|
||||||
The Documenso documentation site, built with [Next.js](https://nextjs.org/) and [Fumadocs](https://fumadocs.dev/). Published at [docs.documenso.com](https://docs.documenso.com).
|
This is a Next.js application generated with
|
||||||
|
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
|
||||||
|
|
||||||
Content lives under `content/docs/` as MDX. See [WRITING_STYLE.md](../../WRITING_STYLE.md) for the documentation writing conventions.
|
Run development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the monorepo root
|
npm run dev
|
||||||
npm run dev --filter=@documenso/docs
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Structure
|
Open http://localhost:3000 with your browser to see the result.
|
||||||
|
|
||||||
- `content/docs/`: Documentation pages (MDX).
|
## Explore
|
||||||
- `lib/source.ts`: Content source adapter.
|
|
||||||
- `lib/layout.shared.tsx`: Shared layout options.
|
In the project, you can see:
|
||||||
|
|
||||||
|
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||||
|
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| ------------------------- | ------------------------------------------------------ |
|
||||||
|
| `app/(home)` | The route group for your landing page and other pages. |
|
||||||
|
| `app/docs` | The documentation layout and pages. |
|
||||||
|
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||||
|
|
||||||
|
### Fumadocs MDX
|
||||||
|
|
||||||
|
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
|
||||||
|
|
||||||
|
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js and Fumadocs, take a look at the following
|
||||||
|
resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
|
||||||
|
features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
|
||||||
|
|||||||
@@ -15,17 +15,16 @@ Pick the one that fits your needs the best.
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- [TypeScript](https://www.typescriptlang.org/) - Language
|
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||||
- [React Router v7](https://reactrouter.com/) - Framework
|
- [React Router](https://reactrouter.com/) - Framework
|
||||||
- [Hono](https://hono.dev/) - Server
|
|
||||||
- [Prisma](https://www.prisma.io/) - ORM
|
- [Prisma](https://www.prisma.io/) - ORM
|
||||||
- [Tailwind CSS](https://tailwindcss.com/) - CSS
|
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library
|
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||||
- [react-email](https://react.email/) - Email Templates
|
- [react-email](https://react.email/) - Email Templates
|
||||||
- [Lingui](https://lingui.dev/) - Internationalization
|
|
||||||
- [tRPC](https://trpc.io/) - API
|
- [tRPC](https://trpc.io/) - API
|
||||||
- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signing and Manipulation
|
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
||||||
- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs
|
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||||
|
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||||
- [Stripe](https://stripe.com/) - Payments
|
- [Stripe](https://stripe.com/) - Payments
|
||||||
|
|
||||||
<div className="mt-16 flex items-center justify-center gap-4">
|
<div className="mt-16 flex items-center justify-center gap-4">
|
||||||
|
|||||||
@@ -278,9 +278,7 @@ Test your email configuration by creating an account or resetting a password. Th
|
|||||||
|
|
||||||
### Using a Test SMTP Server
|
### Using a Test SMTP Server
|
||||||
|
|
||||||
For development or testing, use a local SMTP server like [Inbucket](https://www.inbucket.org/), [Mailpit](https://github.com/axllent/mailpit), or [Mailhog](https://github.com/mailhog/MailHog). The default development setup (`docker/development/compose.yml`) already runs Inbucket, with its web UI on port 9000 and SMTP on port 2500.
|
For development or testing, use a local SMTP server like [Mailhog](https://github.com/mailhog/MailHog) or [Mailpit](https://github.com/axllent/mailpit):
|
||||||
|
|
||||||
To run one standalone instead:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using Docker
|
# Using Docker
|
||||||
|
|||||||
@@ -86,21 +86,6 @@ Callback URL: `https://<your-domain>/api/auth/callback/microsoft`
|
|||||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | `false` | Skip email verification for OIDC accounts |
|
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | `false` | Skip email verification for OIDC accounts |
|
||||||
| `NEXT_PRIVATE_OIDC_PROMPT` | `login` | OIDC prompt parameter. Set to empty string to omit |
|
| `NEXT_PRIVATE_OIDC_PROMPT` | `login` | OIDC prompt parameter. Set to empty string to omit |
|
||||||
|
|
||||||
### Webhooks
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
| --------------------------------------- | ------- | ------------------------------------------------------------------------ |
|
|
||||||
| `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` | - | Comma-separated hostnames or IPs allowed to resolve to private addresses |
|
|
||||||
|
|
||||||
Before delivering a webhook, Documenso checks whether the target resolves to a
|
|
||||||
private or loopback address and blocks it if so. This check is best-effort and
|
|
||||||
fails open. Use `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` to allow specific
|
|
||||||
internal hosts, for example when delivering to a service on your own network:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS="hooks.internal.example,10.0.0.5"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Email Configuration
|
## Email Configuration
|
||||||
@@ -272,12 +257,6 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
|
|||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
|
||||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch. Disable all signin methods application-wide | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin. Also closes `/forgot-password` and `/reset-password` | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable the automatic `/signin` redirect when OIDC is the only enabled transport | `false` |
|
|
||||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||||
|
|
||||||
@@ -309,44 +288,6 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
|||||||
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sign-in Restrictions
|
|
||||||
|
|
||||||
You can control which methods are available for users to sign in with the following environment variables:
|
|
||||||
|
|
||||||
- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin` and rejects email/password signin server-side with a `SIGNIN_DISABLED` error.
|
|
||||||
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN`**: Set to `true` to disable email/password signin only. The email/password form is hidden, the `/forgot-password` and `/reset-password` pages redirect to `/signin`, and the corresponding server endpoints reject requests. SSO signin is unaffected.
|
|
||||||
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNIN`**: Set to `true` to hide the matching SSO button on the signin page. Useful when an SSO provider is kept configured for account linking but not advertised as a signin entry point.
|
|
||||||
|
|
||||||
These flags are opt-in: when none are set, signin behaviour is unchanged from a stock Documenso instance.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Allow only OIDC signin (e.g. enterprise SSO-only)
|
|
||||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
|
||||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
|
||||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
|
||||||
|
|
||||||
# Or disable signin entirely
|
|
||||||
NEXT_PUBLIC_DISABLE_SIGNIN="true"
|
|
||||||
```
|
|
||||||
|
|
||||||
### OIDC Auto-redirect
|
|
||||||
|
|
||||||
When OIDC is the only enabled signin transport on your instance, `/signin` automatically redirects users straight to the OIDC provider instead of showing the signin form. The page renders a spinner while the redirect happens. No extra configuration is required — disabling every other signin method is enough to trigger it.
|
|
||||||
|
|
||||||
- **`NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT`**: Set to `true` to opt out of the automatic redirect and keep rendering the signin page even when OIDC is the only enabled transport.
|
|
||||||
|
|
||||||
The redirect only triggers when OIDC is configured and email/password, Google, and Microsoft signin are all disabled. If any other transport remains enabled, the signin form is shown as normal.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# OIDC-only signin: disabling all other methods auto-redirects to the provider
|
|
||||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
|
||||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
|
||||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
|
||||||
|
|
||||||
# Opt out of the auto-redirect while still OIDC-only
|
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AI Features
|
## AI Features
|
||||||
@@ -490,16 +431,6 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
|||||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
|
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
|
||||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||||
|
|
||||||
# Sign-in restrictions (optional)
|
|
||||||
# NEXT_PUBLIC_DISABLE_SIGNIN="true"
|
|
||||||
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
|
|
||||||
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
|
|
||||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
|
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN="true"
|
|
||||||
|
|
||||||
# Opt out of the automatic OIDC redirect when OIDC is the only enabled transport (optional)
|
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -163,19 +163,6 @@ NEXT_PUBLIC_DISABLE_SIGNUP=false
|
|||||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
|
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
|
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
|
||||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
||||||
|
|
||||||
# Signin restrictions (optional)
|
|
||||||
# Master switch — disables every signin method
|
|
||||||
# NEXT_PUBLIC_DISABLE_SIGNIN=true
|
|
||||||
# Per-method switches (optional). Each disables that signin path.
|
|
||||||
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true
|
|
||||||
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=true
|
|
||||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=true
|
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=true
|
|
||||||
|
|
||||||
# When OIDC is the only enabled transport, /signin auto-redirects to the provider.
|
|
||||||
# Set this to opt out and keep showing the signin page (optional).
|
|
||||||
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
|
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
|
||||||
|
|||||||
@@ -112,12 +112,6 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
|||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
|
||||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
|
|
||||||
|
|
||||||
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ spec:
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
Pin to a specific image tag (e.g., `documenso/documenso:<version>`) in production instead of `latest`
|
Pin to a specific image tag (e.g., `documenso/documenso:1.5.0`) in production instead of `latest`
|
||||||
to ensure predictable deployments.
|
to ensure predictable deployments.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js 22 or later
|
- Node.js 20 or later
|
||||||
- npm 11 or later
|
- npm
|
||||||
- PostgreSQL 14 or later
|
- PostgreSQL 14 or later
|
||||||
- A Linux server (for systemd service setup)
|
- A Linux server (for systemd service setup)
|
||||||
|
|
||||||
|
|||||||
@@ -159,12 +159,6 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
|||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
|
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
|
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
|
||||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`| Hide the Microsoft signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
|
|
||||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
||||||
|
|
||||||
|
|||||||
@@ -124,16 +124,12 @@ docker compose -f docker/development/compose.yml exec database \
|
|||||||
|
|
||||||
The quick start setup runs the following containers:
|
The quick start setup runs the following containers:
|
||||||
|
|
||||||
| Container | Purpose | Port |
|
| Container | Purpose | Port |
|
||||||
| ----------- | ------------------------------------ | ----------------------------- |
|
| ----------- | -------------------------------- | ----- |
|
||||||
| `documenso` | Main application | 3000 |
|
| `documenso` | Main application | 3000 |
|
||||||
| `database` | PostgreSQL database | 54320 |
|
| `database` | PostgreSQL database | 54320 |
|
||||||
| `inbucket` | Local email testing server | 9000 (web UI), 2500 (SMTP) |
|
| `maildev` | Local email testing server | 2500 |
|
||||||
| `redis` | Cache and background job queue | 63790 |
|
| `minio` | S3-compatible storage (optional) | 9000 |
|
||||||
| `minio` | S3-compatible storage | 9002 (API), 9001 (console) |
|
|
||||||
| `gotenberg` | Document conversion (optional) | 3005 |
|
|
||||||
|
|
||||||
The local email server is [Inbucket](https://www.inbucket.org/). Open its web UI at [http://localhost:9000](http://localhost:9000) to view emails Documenso sends during development. For your own deployment you can use any SMTP-compatible mailserver, such as Inbucket, [Mailpit](https://github.com/axllent/mailpit), or [Mailhog](https://github.com/mailhog/MailHog).
|
|
||||||
|
|
||||||
## Useful Commands
|
## Useful Commands
|
||||||
|
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
|
|||||||
|
|
||||||
| Requirement | Version |
|
| Requirement | Version |
|
||||||
| ----------- | ------- |
|
| ----------- | ------- |
|
||||||
| Node.js | 22+ |
|
| Node.js | 18+ |
|
||||||
| npm | 11+ |
|
| npm | 8+ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ Documenso runs on:
|
|||||||
| MySQL/MariaDB | PostgreSQL-specific features required |
|
| MySQL/MariaDB | PostgreSQL-specific features required |
|
||||||
| SQLite | Not suitable for production workloads |
|
| SQLite | Not suitable for production workloads |
|
||||||
| MongoDB | Relational database required |
|
| MongoDB | Relational database required |
|
||||||
| Node.js < 22 | Modern JavaScript features required |
|
| Node.js < 18 | Modern JavaScript features required |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Use a specific version tag in production:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Good — predictable, reproducible
|
# Good — predictable, reproducible
|
||||||
docker pull documenso/documenso:<version>
|
docker pull documenso/documenso:1.8.0
|
||||||
|
|
||||||
# Risky — may pull breaking changes
|
# Risky — may pull breaking changes
|
||||||
docker pull documenso/documenso:latest
|
docker pull documenso/documenso:latest
|
||||||
|
|||||||
@@ -27,14 +27,6 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
|||||||
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
|
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<Callout type="warn">
|
|
||||||
**You are responsible for your own network security.** Documenso applies best-effort, non-exhaustive
|
|
||||||
checks to outbound requests such as webhooks, but these are not a complete SSRF mitigation and they
|
|
||||||
fail open. A self-hosted instance can reach internal addresses on your network. Restricting outbound
|
|
||||||
traffic, egress filtering, and blocking access to internal services and cloud metadata endpoints is
|
|
||||||
your responsibility through your firewall and network configuration.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment Options
|
## Deployment Options
|
||||||
|
|||||||
@@ -165,10 +165,10 @@ See [Backups](/docs/self-hosting/maintenance/backups) for automated backup strat
|
|||||||
### Pull the new image
|
### Pull the new image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull documenso/documenso:<version>
|
docker pull documenso/documenso:1.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `<version>` with your target version.
|
Replace `1.6.0` with your target version.
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
@@ -189,7 +189,7 @@ docker run -d \
|
|||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
--env-file .env \
|
--env-file .env \
|
||||||
-v /path/to/cert.p12:/opt/documenso/cert.p12:ro \
|
-v /path/to/cert.p12:/opt/documenso/cert.p12:ro \
|
||||||
documenso/documenso:<version>
|
documenso/documenso:1.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
@@ -223,14 +223,14 @@ Edit `compose.yml` or your `.env` file to specify the new version:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
documenso:
|
documenso:
|
||||||
image: documenso/documenso:<version>
|
image: documenso/documenso:1.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Or if using environment variable substitution:
|
Or if using environment variable substitution:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# In .env
|
# In .env
|
||||||
DOCUMENSO_VERSION=<version>
|
DOCUMENSO_VERSION=1.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -283,7 +283,7 @@ Edit the deployment directly:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl set image deployment/documenso \
|
kubectl set image deployment/documenso \
|
||||||
documenso=documenso/documenso:<version> \
|
documenso=documenso/documenso:1.6.0 \
|
||||||
-n documenso
|
-n documenso
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: documenso
|
- name: documenso
|
||||||
image: documenso/documenso:<version>
|
image: documenso/documenso:1.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Then apply:
|
Then apply:
|
||||||
@@ -421,12 +421,12 @@ To run migrations manually before upgrading:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull the new image
|
# Pull the new image
|
||||||
docker pull documenso/documenso:<version>
|
docker pull documenso/documenso:1.6.0
|
||||||
|
|
||||||
# Run migrations only
|
# Run migrations only
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@host:5432/documenso" \
|
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@host:5432/documenso" \
|
||||||
documenso/documenso:<version> \
|
documenso/documenso:1.6.0 \
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -516,7 +516,7 @@ docker run -d \
|
|||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
--env-file .env \
|
--env-file .env \
|
||||||
-v /path/to/cert.p12:/opt/documenso/cert.p12:ro \
|
-v /path/to/cert.p12:/opt/documenso/cert.p12:ro \
|
||||||
documenso/documenso:<previous-version>
|
documenso/documenso:1.5.0
|
||||||
```
|
```
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createMDX } from 'fumadocs-mdx/next';
|
import { createMDX } from 'fumadocs-mdx/next';
|
||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const withMDX = createMDX();
|
const withMDX = createMDX();
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
const config: NextConfig = {
|
||||||
const config = {
|
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
// biome-ignore lint/suspicious/useAwait: config file
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -13,6 +14,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
// biome-ignore lint/suspicious/useAwait: config file
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"fumadocs-ui": "16.5.0",
|
"fumadocs-ui": "16.5.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"next": "16.2.6",
|
"next": "16.2.9",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={logoSrc} alt="Documenso" height="28" />
|
<img src={logoSrc} alt="Documenso" style={{ height: 28 }} />
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: '#D4D4D8',
|
color: '#D4D4D8',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next": "16.2.6"
|
"next": "16.2.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:20-alpine AS development-dependencies-env
|
||||||
|
COPY . /app
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS production-dependencies-env
|
||||||
|
COPY ./package.json package-lock.json /app/
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build-env
|
||||||
|
COPY . /app/
|
||||||
|
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
COPY ./package.json package-lock.json /app/
|
||||||
|
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build-env /app/build /app/build
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
+93
-7
@@ -1,14 +1,100 @@
|
|||||||
# @documenso/remix
|
# Welcome to React Router!
|
||||||
|
|
||||||
The main Documenso web application. Built with [React Router v7](https://reactrouter.com/) and served by a [Hono](https://hono.dev/) server.
|
A modern, production-ready template for building full-stack React applications using React Router.
|
||||||
|
|
||||||
This package is part of the Documenso monorepo and is not meant to be run standalone. Use the root scripts instead.
|
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||||
|
|
||||||
- Local development: see the [root README](../../README.md) and the [Local Development docs](https://docs.documenso.com/docs/developers/local-development).
|
## Features
|
||||||
- Self-hosting and deployment: see the [Self-Hosting docs](https://docs.documenso.com/docs/self-hosting).
|
|
||||||
- Architecture overview: see [ARCHITECTURE.md](../../ARCHITECTURE.md).
|
- 🚀 Server-side rendering
|
||||||
|
- ⚡️ Hot Module Replacement (HMR)
|
||||||
|
- 📦 Asset bundling and optimization
|
||||||
|
- 🔄 Data loading and mutations
|
||||||
|
- 🔒 TypeScript by default
|
||||||
|
- 🎉 TailwindCSS for styling
|
||||||
|
- 📖 [React Router docs](https://reactrouter.com/)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Start the development server with HMR:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the monorepo root
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Your application will be available at `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Building for Production
|
||||||
|
|
||||||
|
Create a production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
This template includes three Dockerfiles optimized for different package managers:
|
||||||
|
|
||||||
|
- `Dockerfile` - for npm
|
||||||
|
- `Dockerfile.pnpm` - for pnpm
|
||||||
|
- `Dockerfile.bun` - for bun
|
||||||
|
|
||||||
|
To build and run using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For npm
|
||||||
|
docker build -t my-app .
|
||||||
|
|
||||||
|
# For pnpm
|
||||||
|
docker build -f Dockerfile.pnpm -t my-app .
|
||||||
|
|
||||||
|
# For bun
|
||||||
|
docker build -f Dockerfile.bun -t my-app .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -p 3000:3000 my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
The containerized application can be deployed to any platform that supports Docker, including:
|
||||||
|
|
||||||
|
- AWS ECS
|
||||||
|
- Google Cloud Run
|
||||||
|
- Azure Container Apps
|
||||||
|
- Digital Ocean App Platform
|
||||||
|
- Fly.io
|
||||||
|
- Railway
|
||||||
|
|
||||||
|
### DIY Deployment
|
||||||
|
|
||||||
|
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||||
|
|
||||||
|
Make sure to deploy the output of `npm run build`
|
||||||
|
|
||||||
|
```
|
||||||
|
├── package.json
|
||||||
|
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||||
|
├── build/
|
||||||
|
│ ├── client/ # Static assets
|
||||||
|
│ └── server/ # Server-side code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ using React Router.
|
||||||
|
|||||||
+14
-3
@@ -1,5 +1,15 @@
|
|||||||
@import "@documenso/ui/styles/theme.css";
|
@import "@documenso/ui/styles/theme.css";
|
||||||
|
|
||||||
|
/* Content sources: this app plus the shared `ui` and `email` packages it renders. */
|
||||||
|
@source "./**/*.{ts,tsx}";
|
||||||
|
@source "../../../packages/ui/primitives/**/*.{ts,tsx}";
|
||||||
|
@source "../../../packages/ui/components/**/*.{ts,tsx}";
|
||||||
|
@source "../../../packages/ui/icons/**/*.{ts,tsx}";
|
||||||
|
@source "../../../packages/ui/lib/**/*.{ts,tsx}";
|
||||||
|
@source "../../../packages/email/templates/**/*.{ts,tsx}";
|
||||||
|
@source "../../../packages/email/template-components/**/*.{ts,tsx}";
|
||||||
|
@source "../../../packages/email/providers/**/*.{ts,tsx}";
|
||||||
|
|
||||||
/* Inter Variable Fonts */
|
/* Inter Variable Fonts */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Inter";
|
font-family: "Inter";
|
||||||
@@ -64,8 +74,9 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--font-sans: "Inter";
|
/* Consumed by the `--font-*` theme tokens in @documenso/ui/styles/theme.css. */
|
||||||
--font-signature: "Caveat";
|
--font-family-sans: "Inter";
|
||||||
--font-noto: "Noto Sans", "Noto Sans Korean", "Noto Sans Japanese", "Noto Sans Chinese";
|
--font-family-signature: "Caveat";
|
||||||
|
--font-family-noto: "Noto Sans", "Noto Sans Korean", "Noto Sans Japanese", "Noto Sans Chinese";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<Dialog onOpenChange={() => setEnteredEmail('')}>
|
<Dialog onOpenChange={() => setEnteredEmail('')}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
@@ -82,7 +82,7 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="twv3-space-y-4">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Delete Account</Trans>
|
<Trans>Delete Account</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
@@ -80,7 +80,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="twv3-space-y-4">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Delete Document</Trans>
|
<Trans>Delete Document</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const AdminOrganisationCreateDialog = ({ trigger, ownerUserId, ...props }
|
|||||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="shrink-0" variant="secondary">
|
||||||
<Trans>Create organisation</Trans>
|
<Trans>Create organisation</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -109,7 +109,7 @@ export const AdminOrganisationCreateDialog = ({ trigger, ownerUserId, ...props }
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const AdminOrganisationDeleteDialog = ({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="organisationName"
|
name="organisationName"
|
||||||
@@ -151,7 +151,7 @@ export const AdminOrganisationDeleteDialog = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="sendEmailToOwner"
|
name="sendEmailToOwner"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
<FormItem className="twv3-space-x-3 twv3-space-y-0 flex flex-row items-start">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="admin-delete-organisation-send-email"
|
id="admin-delete-organisation-send-email"
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const AdminOrganisationMemberDeleteDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="twv3-space-y-4">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Remove Organisation Member</Trans>
|
<Trans>Remove Organisation Member</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -110,12 +110,12 @@ export const AdminOrganisationSyncSubscriptionDialog = ({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="syncClaims"
|
name="syncClaims"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
|
<FormItem className="twv3-space-x-3 twv3-space-y-0 flex flex-row items-center">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="admin-sync-subscription-sync-claims"
|
id="admin-sync-subscription-sync-claims"
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const AdminSwapSubscriptionDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex flex-col" disabled={isSubmitting}>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="font-medium text-sm">
|
<label className="font-medium text-sm">
|
||||||
<Trans>Target Organisation</Trans>
|
<Trans>Target Organisation</Trans>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const AdminTeamMemberDeleteDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="twv3-space-y-4">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Remove Team Member</Trans>
|
<Trans>Remove Team Member</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDial
|
|||||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="shrink-0" variant="secondary">
|
||||||
<Trans>Create User</Trans>
|
<Trans>Create User</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -101,7 +101,7 @@ export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDial
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
@@ -86,7 +86,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="twv3-space-y-4">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Delete Account</Trans>
|
<Trans>Delete Account</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const AdminUserDisableDialog = ({ className, userToDisable }: AdminUserDi
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
@@ -83,7 +83,7 @@ export const AdminUserDisableDialog = ({ className, userToDisable }: AdminUserDi
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="twv3-space-y-4">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Disable Account</Trans>
|
<Trans>Disable Account</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -83,7 +83,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="twv3-space-y-4">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Enable Account</Trans>
|
<Trans>Enable Account</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
@@ -99,7 +99,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="twv3-space-y-4">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Reset Two Factor Authentication</Trans>
|
<Trans>Reset Two Factor Authentication</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const AiFeaturesEnableDialog = ({ open, onOpenChange, onEnabled }: AiFeat
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="twv3-space-y-4">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Turn on AI detection to automatically find recipients and fields in your documents. AI providers do not
|
Turn on AI detection to automatically find recipients and fields in your documents. AI providers do not
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const AiFieldDetectionDialog = ({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="twv3-space-y-4">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
We'll scan your document to find form fields like signature lines, text inputs, checkboxes, and more.
|
We'll scan your document to find form fields like signature lines, text inputs, checkboxes, and more.
|
||||||
@@ -166,14 +166,14 @@ export const AiFieldDetectionDialog = ({
|
|||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
|
<Alert className="twv3-space-y-0 flex items-center gap-2" variant="neutral">
|
||||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||||
<AlertDescription className="mt-0">
|
<AlertDescription className="mt-0">
|
||||||
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
|
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="twv3-space-y-1.5">
|
||||||
<Label htmlFor="context">
|
<Label htmlFor="context">
|
||||||
<Trans>Context</Trans>
|
<Trans>Context</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export const AiRecipientDetectionDialog = ({
|
|||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
|
<Alert className="twv3-space-y-0 mt-4 flex items-center gap-2" variant="neutral">
|
||||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||||
<AlertDescription className="mt-0">
|
<AlertDescription className="mt-0">
|
||||||
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
|
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="shrink-0" variant="secondary">
|
||||||
<Trans>Create claim</Trans>
|
<Trans>Create claim</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
|
|||||||
licenseFlags={licenseFlags}
|
licenseFlags={licenseFlags}
|
||||||
formSubmitTrigger={
|
formSubmitTrigger={
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="twv3-space-x-2 flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="backport-email-transport"
|
id="backport-email-transport"
|
||||||
checked={backportEmailTransport}
|
checked={backportEmailTransport}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDial
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0">
|
<Button className="shrink-0">
|
||||||
<Trans>Add transport</Trans>
|
<Trans>Add transport</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTran
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="to"
|
name="to"
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
<fieldset disabled={isSubmitting}>
|
<fieldset disabled={isSubmitting}>
|
||||||
{hasOverlappingEnvelopeFields && (
|
{hasOverlappingEnvelopeFields && (
|
||||||
<Alert variant="warning" className="mb-4 flex flex-row items-start gap-3">
|
<Alert variant="warning" className="mb-4 flex flex-row items-start gap-3">
|
||||||
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
<AlertTriangleIcon className="mt-0.5 h-5 w-5 shrink-0" />
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
|
|||||||
@@ -163,14 +163,14 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
{isLoadingEnvelopeItems
|
{isLoadingEnvelopeItems
|
||||||
? Array.from({ length: 1 }).map((_, index) => (
|
? Array.from({ length: 1 }).map((_, index) => (
|
||||||
<div key={index} className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
|
<div key={index} className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
|
||||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
<Skeleton className="h-10 w-10 shrink-0 rounded-lg" />
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className="flex w-full flex-col gap-2">
|
||||||
<Skeleton className="h-4 w-28 rounded-lg" />
|
<Skeleton className="h-4 w-28 rounded-lg" />
|
||||||
<Skeleton className="h-4 w-20 rounded-lg" />
|
<Skeleton className="h-4 w-20 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
<Skeleton className="h-10 w-20 shrink-0 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: envelopeItems.map((item) => (
|
: envelopeItems.map((item) => (
|
||||||
@@ -178,7 +178,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
|
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<FileTextIcon className="h-5 w-5 text-primary" />
|
<FileTextIcon className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +194,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -118,12 +118,12 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="twv3-space-y-4">
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeRecipients"
|
name="includeRecipients"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="twv3-space-x-2 flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="envelopeDuplicateIncludeRecipients"
|
id="envelopeDuplicateIncludeRecipients"
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
@@ -146,7 +146,7 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeFields"
|
name="includeFields"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="twv3-space-x-2 flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="envelopeDuplicateIncludeFields"
|
id="envelopeDuplicateIncludeFields"
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export const EnvelopeItemEditDialog = ({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="title"
|
name="title"
|
||||||
@@ -253,13 +253,13 @@ export const EnvelopeItemEditDialog = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
{replacementFile ? (
|
{replacementFile ? (
|
||||||
<div className="mt-1.5 space-y-2">
|
<div className="twv3-space-y-2 mt-1.5">
|
||||||
<div
|
<div
|
||||||
data-testid="envelope-item-edit-selected-file"
|
data-testid="envelope-item-edit-selected-file"
|
||||||
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
|
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center space-x-2">
|
<div className="twv3-space-x-2 flex min-w-0 items-center">
|
||||||
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium text-sm">{replacementFile.file.name}</p>
|
<p className="truncate font-medium text-sm">{replacementFile.file.name}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
@@ -309,7 +309,7 @@ export const EnvelopeItemEditDialog = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
<div className="flex items-center space-x-2 text-muted-foreground text-sm">
|
<div className="twv3-space-x-2 flex items-center text-muted-foreground text-sm">
|
||||||
<UploadIcon className="h-4 w-4" />
|
<UploadIcon className="h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Drop PDF here or click to select</Trans>
|
<Trans>Drop PDF here or click to select</Trans>
|
||||||
|
|||||||
@@ -116,12 +116,12 @@ export const EnvelopeSaveAsTemplateDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="twv3-space-y-4">
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeRecipients"
|
name="includeRecipients"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="twv3-space-x-2 flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="envelopeIncludeRecipients"
|
id="envelopeIncludeRecipients"
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
@@ -144,7 +144,7 @@ export const EnvelopeSaveAsTemplateDialog = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeFields"
|
name="includeFields"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="twv3-space-x-2 flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="envelopeIncludeFields"
|
id="envelopeIncludeFields"
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const EnvelopesBulkMoveDialog = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
<div className="twv3-space-y-2 max-h-96 overflow-y-auto">
|
||||||
{isFoldersLoading ? (
|
{isFoldersLoading ? (
|
||||||
<div className="flex h-10 items-center justify-center">
|
<div className="flex h-10 items-center justify-center">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="confirmText"
|
name="confirmText"
|
||||||
|
|||||||
@@ -126,14 +126,14 @@ export const FolderMoveDialog = ({ foldersData, folder, isOpen, onOpenChange }:
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="targetFolderId"
|
name="targetFolderId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
<div className="twv3-space-y-2 max-h-96 overflow-y-auto">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={!field.value ? 'default' : 'outline'}
|
variant={!field.value ? 'default' : 'outline'}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="twv3-space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="shrink-0" variant="secondary">
|
||||||
<Trans>Create organisation</Trans>
|
<Trans>Create organisation</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -199,7 +199,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -305,7 +305,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="twv3-space-y-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
className="flex w-full items-center justify-center"
|
className="flex w-full items-center justify-center"
|
||||||
defaultValue="monthlyPrice"
|
defaultValue="monthlyPrice"
|
||||||
@@ -327,7 +327,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
|||||||
<button
|
<button
|
||||||
onClick={() => onChange('')}
|
onClick={() => onChange('')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:border-primary hover:shadow-sm',
|
'twv3-space-x-2 flex cursor-pointer items-center rounded-md border p-4 transition-all hover:border-primary hover:shadow-xs',
|
||||||
{
|
{
|
||||||
'border-primary ring-2 ring-primary/10 ring-offset-1': '' === value,
|
'border-primary ring-2 ring-primary/10 ring-offset-1': '' === value,
|
||||||
},
|
},
|
||||||
@@ -360,7 +360,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
|||||||
key={plan[billingPeriod]?.id}
|
key={plan[billingPeriod]?.id}
|
||||||
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
|
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:border-primary hover:shadow-sm',
|
'twv3-space-x-2 flex cursor-pointer items-center rounded-md border p-4 transition-all hover:border-primary hover:shadow-xs',
|
||||||
{
|
{
|
||||||
'border-primary ring-2 ring-primary/10 ring-offset-1': plan[billingPeriod]?.id === value,
|
'border-primary ring-2 ring-primary/10 ring-offset-1': plan[billingPeriod]?.id === value,
|
||||||
},
|
},
|
||||||
@@ -382,7 +382,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
|||||||
<Link
|
<Link
|
||||||
to="https://documen.so/enterprise-cta"
|
to="https://documen.so/enterprise-cta"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
|
className="twv3-space-x-2 flex items-center rounded-md border bg-muted/30 p-4"
|
||||||
>
|
>
|
||||||
<div className="flex-1 font-normal">
|
<div className="flex-1 font-normal">
|
||||||
<p className="font-medium text-muted-foreground">
|
<p className="font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="organisationName"
|
name="organisationName"
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const OrganisationEmailCreateDialog = ({
|
|||||||
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="shrink-0" variant="secondary">
|
||||||
<Trans>Add Email</Trans>
|
<Trans>Add Email</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +135,7 @@ export const OrganisationEmailCreateDialog = ({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={isPending}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="emailName"
|
name="emailName"
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const OrganisationEmailDomainCreateDialog = ({ trigger, ...props }: Organ
|
|||||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="shrink-0" variant="secondary">
|
||||||
<Trans>Add Email Domain</Trans>
|
<Trans>Add Email Domain</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +135,7 @@ export const OrganisationEmailDomainCreateDialog = ({ trigger, ...props }: Organ
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="domain"
|
name="domain"
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const OrganisationEmailDomainDeleteDialog = ({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
<fieldset disabled={form.formState.isSubmitting} className="twv3-space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="confirmText"
|
name="confirmText"
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="twv3-space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="twv3-space-y-4">
|
||||||
{records.map((record) => (
|
{records.map((record) => (
|
||||||
<div className="space-y-4 rounded-md border p-4" key={record.name}>
|
<div className="twv3-space-y-4 rounded-md border p-4" key={record.name}>
|
||||||
<div className="space-y-2">
|
<div className="twv3-space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
<Trans>Record Type</Trans>
|
<Trans>Record Type</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -79,7 +79,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="twv3-space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
<Trans>Record Name</Trans>
|
<Trans>Record Name</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -95,7 +95,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="twv3-space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
<Trans>Record Value</Trans>
|
<Trans>Record Value</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const OrganisationEmailUpdateDialog = ({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={isPending}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="emailName"
|
name="emailName"
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const OrganisationGroupCreateDialog = ({ trigger, ...props }: Organisatio
|
|||||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="shrink-0" variant="secondary">
|
||||||
<Trans>Create group</Trans>
|
<Trans>Create group</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -127,7 +127,7 @@ export const OrganisationGroupCreateDialog = ({ trigger, ...props }: Organisatio
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -313,10 +313,10 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
|
|||||||
<TabsContent value="INDIVIDUAL">
|
<TabsContent value="INDIVIDUAL">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
<div className="custom-scrollbar twv3-space-y-4 -m-1 max-h-[60vh] overflow-y-auto p-1">
|
||||||
{organisationMemberInvites.map((organisationMemberInvite, index) => (
|
{organisationMemberInvites.map((organisationMemberInvite, index) => (
|
||||||
<div className="flex w-full flex-row space-x-4" key={organisationMemberInvite.id}>
|
<div className="twv3-space-x-4 flex w-full flex-row" key={organisationMemberInvite.id}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`invitations.${index}.email`}
|
name={`invitations.${index}.email`}
|
||||||
@@ -409,7 +409,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="BULK">
|
<TabsContent value="BULK">
|
||||||
<div className="mt-4 space-y-4">
|
<div className="twv3-space-y-4 mt-4">
|
||||||
<Card gradient className="h-32">
|
<Card gradient className="h-32">
|
||||||
<CardContent
|
<CardContent
|
||||||
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
|
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="passkeyName"
|
name="passkeyName"
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export const ManagePublicTemplateDialog = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={isOpen || open} onOpenChange={handleOnOpenChange}>
|
<Dialog {...props} open={isOpen || open} onOpenChange={handleOnOpenChange}>
|
||||||
<fieldset disabled={isLoading} className="relative flex-shrink-0">
|
<fieldset disabled={isLoading} className="relative shrink-0">
|
||||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||||
@@ -311,7 +311,7 @@ export const ManagePublicTemplateDialog = ({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form className="flex h-full flex-col space-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form className="twv3-space-y-4 flex h-full flex-col" onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="publicTitle"
|
name="publicTitle"
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export const SignFieldCheckboxDialog = createCallable<SignFieldCheckboxDialogPro
|
|||||||
call.end(data.values.map((value, i) => (value.checked ? i : null)).filter((value) => value !== null)),
|
call.end(data.values.map((value, i) => (value.checked ? i : null)).filter((value) => value !== null)),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<ul className="space-y-3">
|
<ul className="twv3-space-y-3">
|
||||||
{(formValues.values || []).map((value, index) => (
|
{(formValues.values || []).map((value, index) => (
|
||||||
<li key={`checkbox-${index}`}>
|
<li key={`checkbox-${index}`}>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit((data) => call.end(data.email))}>
|
<form onSubmit={form.handleSubmit((data) => call.end(data.email))}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit((data) => call.end(data.initials))}>
|
<form onSubmit={form.handleSubmit((data) => call.end(data.initials))}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="initials"
|
name="initials"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit((data) => call.end(data.name))}>
|
<form onSubmit={form.handleSubmit((data) => call.end(data.name))}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit((data) => call.end(data.number))}>
|
<form onSubmit={form.handleSubmit((data) => call.end(data.number))}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="number"
|
name="number"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
|
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="text"
|
name="text"
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="shrink-0" variant="secondary">
|
||||||
<Trans>Create team</Trans>
|
<Trans>Create team</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -195,7 +195,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
{dialogState === 'form' && (
|
{dialogState === 'form' && (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="teamName"
|
name="teamName"
|
||||||
@@ -256,7 +256,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="inheritMembers"
|
name="inheritMembers"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center space-x-2">
|
<FormItem className="twv3-space-x-2 flex items-center">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Checkbox id="inherit-members" checked={field.value} onCheckedChange={field.onChange} />
|
<Checkbox id="inherit-members" checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName, redirectTo }: Team
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="teamName"
|
name="teamName"
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -202,10 +202,10 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
|||||||
|
|
||||||
{step === 'ROLES' && (
|
{step === 'ROLES' && (
|
||||||
<>
|
<>
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
<div className="custom-scrollbar twv3-space-y-4 -m-1 max-h-[60vh] overflow-y-auto p-1">
|
||||||
{form.getValues('groups').map((group, index) => (
|
{form.getValues('groups').map((group, index) => (
|
||||||
<div className="flex w-full flex-row space-x-4" key={index}>
|
<div className="twv3-space-x-4 flex w-full flex-row" key={index}>
|
||||||
<div className="w-full space-y-2">
|
<div className="twv3-space-y-2 w-full">
|
||||||
{index === 0 && (
|
{index === 0 && (
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Group</Trans>
|
<Trans>Group</Trans>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="members"
|
name="members"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="space-y-2">
|
<FormItem className="twv3-space-y-2">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Members</Trans>
|
<Trans>Members</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@@ -310,7 +310,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|
||||||
{canInviteOrganisationMembers && (
|
{canInviteOrganisationMembers && (
|
||||||
<Alert variant="neutral" className="mt-2 flex items-center gap-2 space-y-0">
|
<Alert variant="neutral" className="twv3-space-y-0 mt-2 flex items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
|
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
@@ -358,10 +358,10 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
|||||||
|
|
||||||
{step === 'MEMBERS' && (
|
{step === 'MEMBERS' && (
|
||||||
<>
|
<>
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
<div className="custom-scrollbar twv3-space-y-4 -m-1 max-h-[60vh] overflow-y-auto p-1">
|
||||||
{form.getValues('members').map((member, index) => (
|
{form.getValues('members').map((member, index) => (
|
||||||
<div className="flex w-full flex-row space-x-4" key={index}>
|
<div className="twv3-space-x-4 flex w-full flex-row" key={index}>
|
||||||
<div className="w-full space-y-2">
|
<div className="twv3-space-y-2 w-full">
|
||||||
{index === 0 && (
|
{index === 0 && (
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Member</Trans>
|
<Trans>Member</Trans>
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="sendImmediately"
|
name="sendImmediately"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center space-x-2">
|
<FormItem className="twv3-space-x-2 flex items-center">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Checkbox id="send-immediately" checked={field.value} onCheckedChange={field.onChange} />
|
<Checkbox id="send-immediately" checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ul className="mt-4 space-y-4 pl-12">
|
<ul className="twv3-space-y-4 mt-4 pl-12">
|
||||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||||
<li className="relative" key={index}>
|
<li className="relative" key={index}>
|
||||||
<div className="absolute -left-12">
|
<div className="absolute -left-12">
|
||||||
@@ -259,7 +259,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||||
<DialogContent className="relative">
|
<DialogContent className="relative">
|
||||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-sm bg-white/50 dark:bg-black/50">
|
||||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -405,7 +405,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => void onCopyClick(token)}
|
onClick={() => void onCopyClick(token)}
|
||||||
>
|
>
|
||||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
<ClipboardCopyIcon className="h-4 w-4 shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,9 +240,9 @@ export function TemplateUseDialog({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
<div className="custom-scrollbar twv3-space-y-4 -m-1 max-h-[60vh] overflow-y-auto p-1">
|
||||||
{formRecipients.map((recipient, index) => (
|
{formRecipients.map((recipient, index) => (
|
||||||
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
<div className="twv3-space-x-4 flex w-full flex-row" key={recipient.id}>
|
||||||
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
|
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -337,7 +337,7 @@ export function TemplateUseDialog({
|
|||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
<TooltipContent className="twv3-space-y-2 z-[99999] max-w-md p-4 text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
The document will be immediately sent to recipients if this is checked.
|
The document will be immediately sent to recipients if this is checked.
|
||||||
@@ -362,7 +362,7 @@ export function TemplateUseDialog({
|
|||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
<TooltipContent className="twv3-space-y-2 z-[99999] max-w-md p-4 text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
<Trans>Create the document as pending and ready to sign.</Trans>
|
<Trans>Create the document as pending and ready to sign.</Trans>
|
||||||
</p>
|
</p>
|
||||||
@@ -414,7 +414,7 @@ export function TemplateUseDialog({
|
|||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
<TooltipContent className="twv3-space-y-2 z-[99999] max-w-md p-4 text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
Upload a custom document to use instead of the template's default document
|
Upload a custom document to use instead of the template's default document
|
||||||
@@ -429,7 +429,7 @@ export function TemplateUseDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch('useCustomDocument') && (
|
{form.watch('useCustomDocument') && (
|
||||||
<div className="my-4 space-y-2">
|
<div className="twv3-space-y-2 my-4">
|
||||||
{isLoadingEnvelopeItems ? (
|
{isLoadingEnvelopeItems ? (
|
||||||
<SpinnerBox className="py-16" />
|
<SpinnerBox className="py-16" />
|
||||||
) : (
|
) : (
|
||||||
@@ -445,7 +445,7 @@ export function TemplateUseDialog({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<FileTextIcon className="h-5 w-5 text-primary" />
|
<FileTextIcon className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
@@ -464,7 +464,7 @@ export function TemplateUseDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{field.value ? (
|
{field.value ? (
|
||||||
<div className="">
|
<div className="">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="tokenName"
|
name="tokenName"
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0">
|
<Button className="shrink-0">
|
||||||
<Trans>Create Webhook</Trans>
|
<Trans>Create Webhook</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -112,7 +112,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="twv3-space-y-4 flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="webhookUrl"
|
name="webhookUrl"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user