Compare commits

..

10 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Every imported field carries fieldMeta.source = 'acroform' (new optional on
ZBaseFieldMeta) for future provenance queries. DOCUMENT envelopes emit a
per-field FIELD_CREATED audit entry matching create-envelope-fields.ts.
Recipient assignment picks the first Recipient with role SIGNER or APPROVER
sorted by (signingOrder asc nulls last, id asc); when no signable recipient
exists, a placeholder Recipient 1 SIGNER is created mirroring the
placeholder-pipeline behaviour.
2026-05-21 04:05:12 +00:00
161 changed files with 6772 additions and 10662 deletions
@@ -0,0 +1,430 @@
---
date: 2026-05-21
title: Acroform Field Detection And Reuse
---
## Problem
Users routinely prepare PDFs in Adobe Acrobat (or other PDF editors) with AcroForm fields — signatures, text inputs, dates, checkboxes — and upload them to Documenso. Today those widgets are either stripped (`DOCUMENT` upload flattens via `form.flatten()` in `normalize-pdf.ts`) or preserved as static interactive controls (`TEMPLATE` upload), but never reused as Documenso fields. Users have to re-place every field in the editor.
Issue: https://github.com/documenso/documenso/issues/2697 (labels: `type: enhancement`, `apps: web`).
## Goal
On upload, detect AcroForm fields, map supported types to Documenso fields, persist their page geometry, then flatten the PDF so no duplicate interactive controls remain. Imported fields should be ordinary `Field` rows — visible in the editor, assignable to recipients, signable like any other field.
## Background
The placeholder pipeline (`{{signature, r1}}` style) already does almost everything we need:
- `packages/lib/server-only/pdf/auto-place-fields.ts` extracts `PlaceholderInfo[]` (with `fieldAndMeta: TFieldAndMeta`, top-left percentages, page index), then `convertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)` returns `tx.field.createMany` payloads.
- `packages/trpc/server/envelope-router/create-envelope.ts` per file: `convertToPdf` → optional `insertFormValuesInPdf``normalizePdf({ flattenForm: type !== 'TEMPLATE' })``extractPdfPlaceholders(normalized)``putPdfFileServerSide(cleanedPdf)` → forwards `{ title, documentDataId, placeholders }`.
- `packages/lib/server-only/envelope-item/create-envelope-items.ts` runs the same per-file pipeline when files are appended to an existing envelope.
- `packages/lib/server-only/envelope/create-envelope.ts` consumes `envelopeItems[].placeholders`, creates placeholder `SIGNER` recipients (`recipient.${i}@documenso.com`) when `data.recipients` is empty, then calls `convertPlaceholdersToFieldInputs` + `tx.field.createMany` inside its existing transaction.
`@libpdf/core` exposes the AcroForm primitives we need (verified in `node_modules/@libpdf/core/dist/index.d.mts`):
- `PDF.isEncrypted: boolean`, `PDF.getForm(): PDFForm | null`, `PDF.getPages()[i].ref` + `.getRotation()`.
- `PDFForm.getFields(): FormField[]`.
- `FormField`: `name`, `partialName`, `alternateName` (/TU), `isRequired()`, `isReadOnly()`, `acroField(): PdfDict` (raw dict access), `type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'listbox' | 'signature' | 'button' | 'unknown' | 'non-terminal'`.
- `WidgetAnnotation`: `rect`, `width`, `height`, `pageRef: PdfRef | null`, `isHidden()`, `isPrintable()`, `getOnValue()`.
- Typed subclasses: `CheckboxField.isChecked()` / `getOnValues()` / `getOnValue()`, `DropdownField.getOptions()`, `RadioField.getOptions()`, `SignatureField`, `TextField.getText()`.
`ZBaseFieldMeta` (in `packages/lib/types/field-meta.ts`) already carries `label`, `required`, `readOnly`. We extend it once with `source?: 'acroform'` so imported fields are introspectable without UI changes.
## Scope
In scope: server-side extraction at upload time for v2 envelopes (both new envelopes and items appended to existing envelopes), per-field `FIELD_CREATED` audit log entries for imported fields, and the one-line schema extension to record provenance. Out of scope: editor UI changes (badges, banners, review modals), signing surface changes, v1 path, listbox, button/unknown/non-terminal field types, true radio-group consolidation, recipient inference beyond first-signer selection.
## Field Mapping
### Type resolution
| AcroForm type | Documenso field | Rule |
| --- | --- | --- |
| `signature` (unsigned) | `SIGNATURE` | Import. |
| `signature` (signed — `SignatureField.isSigned()` returns true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). |
| `text` | resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
| `checkbox` | `CHECKBOX` | One Documenso field per widget. |
| `radio` | `RADIO` | One Documenso field per widget. Store group's `getOptions()` in each `fieldMeta.values`. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
| `dropdown` | `DROPDOWN` | Preserve `getOptions()` in `fieldMeta.values`, current selection in `fieldMeta.defaultValue`. |
| `listbox`, `button`, `unknown`, `non-terminal` | — | Skip, return as `AcroFormUnsupportedFieldInfo`. Never block upload. |
### Text-field heuristic (resolved in order — first match wins)
AcroForm format actions take precedence over every name token. If `/AA → /F → /JS` references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
1. **DATE** if `acroField()` carries an additional-actions date format (`/AA``/F``JS` containing `AFDate_FormatEx` or `AFDate_Format`).
2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action.
3. **DATE** if name/alternateName matches `/date|dob|birth/i`.
4. **NUMBER** if name/alternateName matches `/amount|qty|count|number/i` AND `/MaxLen <= 10`.
5. **EMAIL** if name/alternateName matches `/email|e[-_]?mail/i`.
6. **NAME** if name/alternateName matches `/name/i`.
7. **INITIALS** if name/alternateName matches `/initial/i`.
8. Else **TEXT**.
All regexes are case-insensitive and run against `partialName` then `alternateName`.
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. `CustomerName`, `BirthDate`) that strict word-boundary patterns miss. Expected false positives — `username` → NAME, `birth_name` → DATE, `initialize` → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
### Metadata mapping
For every imported field:
- `fieldMeta.required = field.isRequired()` (boolean; omit when false).
- `fieldMeta.readOnly = field.isReadOnly()` (boolean; omit when false). Read-only fields **are** imported (rendered for reference in the editor); the renderer already honours `readOnly`.
- `fieldMeta.label`: `alternateName ?? partialName` for label-supporting types (TEXT, NUMBER, DATE, INITIALS, NAME, EMAIL, DROPDOWN, RADIO, CHECKBOX). SIGNATURE has no label slot — drop it.
- `fieldMeta.source = 'acroform'` on every imported field (see Schema Extension).
### CHECKBOX required semantics
AcroForm "required" on a checkbox means "must be checked". Documenso CHECKBOX has both `required` (field must be present) and `validationRule`/`validationLength` (e.g. "at least N of M"). Mapping:
```ts
if (field.isRequired()) {
fieldMeta.required = true;
fieldMeta.validationRule = 'at-least';
fieldMeta.validationLength = 1;
}
```
This approximates "must be checked to submit" for a single-widget checkbox field.
### Default values (only when `formValues` was NOT provided on this upload)
`formValues` (via `insertFormValuesInPdf`) is authoritative when present — it bakes values into the flattened background, so the imported fields stay empty for the signer to fill. When `formValues` is absent, we copy PDF defaults into `fieldMeta` so the editor preview matches the source PDF:
| Source | Target |
| --- | --- |
| `TextField.getText()` (non-empty) | `fieldMeta.text` (TEXT/DATE/INITIALS/NAME/EMAIL) or `fieldMeta.value` (NUMBER) |
| `DropdownField` current selection | `fieldMeta.defaultValue` |
| `CheckboxField.isChecked()` | `fieldMeta.values[0].checked = true` |
| `RadioField` current selection | `fieldMeta.values[i].checked = true` on the matching option |
Always emit `inserted: false` and `customText: ''` — the signer still confirms each field, defaults are editor-only hints.
All metadata flows through `ZEnvelopeFieldAndMetaSchema.parse(...)` so imported fields match what the editor already expects.
## Pre-extraction Guards
Run in this order before any AcroForm work:
1. **Encrypted PDFs**: if `pdfDoc.isEncrypted` → log `{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }`, return `{ fields: [], unsupported: [] }`. Upload proceeds with zero imported fields.
2. **XFA hybrid**: detect via the catalog's `AcroForm` dict carrying an `XFA` key (best-effort via raw dict access; if @libpdf/core's public surface can't read it, fall through — mirrored AcroForm fields in XFA hybrids are fine to import). When detected, log `{ event: 'acroform-import.skip', reason: 'xfa-hybrid' }` and return empty results.
3. **`getForm()` is null** → return empty results silently.
4. **Top-level try/catch** around steps 16: any throw → `logger.error({ event: 'acroform-import.error', envelopeItemTitle, err })`, return `{ fields: [], unsupported: [] }`. Upload proceeds untouched. Never bubble.
## Coordinate Handling
Reuse the placeholder convention (top-left percentages, see `auto-place-fields.ts:121-138`):
1. Build a page lookup once: `pages = pdfDoc.getPages(); pageByRef = new Map(pages.map((p, i) => [p.ref, i]))`.
2. For each widget:
1. Read `widget.rect = [x1, y1, x2, y2]` (bottom-left, points).
2. Normalize: `left = min(x1, x2)`, `right = max`, `bottom = min(y1, y2)`, `top = max`.
3. Resolve `pageIndex` via `pageByRef.get(widget.pageRef)`. Skip if no match.
4. Read `page.width`, `page.height`, `rot = page.getRotation()` (degrees, normalized to `0|90|180|270`).
5. Apply inverse rotation transform so the field lands at the rendered top-left percentage:
- `rot === 0`: `x = left`, `y = pageH - top`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
- `rot === 90`: `x = bottom`, `y = left`, `w = top - bottom`, `h = right - left`. Page dims swap: `(pageH, pageW)`.
- `rot === 180`: `x = pageW - right`, `y = bottom`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
- `rot === 270`: `x = pageH - top`, `y = pageW - right`, `w = top - bottom`, `h = right - left`. Page dims swap.
6. Out-of-bounds policy: if the entire rect is outside the rotated page bounds, skip + emit `AcroFormUnsupportedFieldInfo` with `reason: 'off-page'`. Otherwise clamp to `[0, renderedW] × [0, renderedH]`.
7. Convert to percentages against the rendered page dimensions from step 5.
8. Apply the existing `MIN_HEIGHT_THRESHOLD` / `DEFAULT_FIELD_HEIGHT_PERCENT` fallback used by placeholders.
3. Skip widgets that are `isHidden()` or have zero/negative `width`/`height` after normalization.
## Ordering
Sort imported fields before `createMany` by `(pageIndex asc, top-to-bottom, left-to-right)`. Concretely: ascending `pageIndex`, then ascending `y` (top-of-page first), then ascending `x` within `±2%` y-buckets so a row of fields stays a row. This matches how a signer visually scans the page; it does not rely on AcroForm `/Tabs` metadata (often wrong).
## Audit Logging
Every imported field emits one `FIELD_CREATED` entry matching `create-envelope-fields.ts:264`'s shape:
```ts
await tx.documentAuditLog.createMany({
data: createdFields.map((f) => createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: { fieldId: f.secondaryId, fieldRecipientEmail, fieldRecipientId, fieldType: f.type },
})),
});
```
The placeholder branch in `create-envelope.ts` is silent today and stays silent — AcroForm import does not retroactively change that. Distinguishing imported vs. placeholder vs. user-placed fields is done via `fieldMeta.source`, not a new audit type.
## Schema Extension
One field added to `ZBaseFieldMeta` in `packages/lib/types/field-meta.ts`:
```ts
export const ZBaseFieldMeta = z.object({
// existing...
source: z.enum(['acroform']).optional(),
});
```
No DB migration (fieldMeta is JSON). No editor change. No API contract change beyond the optional field. Forwards-compatible: future sources (`'placeholder'`, `'figma'`, etc.) extend the enum.
## Plan
### 1. Add the AcroForm extractor
New file `packages/lib/server-only/pdf/acroform-fields.ts`:
```ts
export type AcroFormFieldImportInfo = {
source: 'acroform';
fieldName: string;
widgetIndex: number;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type AcroFormUnsupportedFieldInfo = {
fieldName: string;
acroFormType: string;
reason: 'unsupported-type' | 'hidden' | 'off-page' | 'zero-size' | 'no-page-match' | 'signed-signature';
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/** True when a signed signature widget was found — caller MUST set flattenForm: false for that item. */
hasSignedSignature: boolean;
/** True when extraction returned empty for a reason that should be surfaced in logs but not propagated. */
skipReason?: 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
};
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
): Promise<AcroFormExtractionResult>;
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[];
```
`extractAcroFormFieldsFromPDF`:
- Wraps everything in try/catch (top-level guard).
- Loads via `PDF.load(new Uint8Array(pdf))`.
- Runs pre-extraction guards (encrypted, XFA, null form) — returns early with `skipReason` set.
- Builds the page-ref → index + rotation lookup once.
- Iterates `form.getFields()`, applies the type-resolution heuristic, geometry pipeline, default-value mapping.
- Records signed-signature widgets in `unsupported` with `reason: 'signed-signature'` AND sets `hasSignedSignature = true`.
- Logger is module-scoped (no apiRequestMetadata in this file — pure function).
`convertAcroFormFieldsToFieldInputs` mirrors `convertPlaceholdersToFieldInputs` — pure point→percentage transform, no DB access. After mapping, sort by `(page, y, x)` as in Ordering.
Kept separate from `auto-place-fields.ts`: placeholders are text-driven and emit white rectangles via `whiteoutRegions`; AcroForm import is widget-driven and relies on the post-extraction `form.flatten()` to clean the PDF. Sharing types prematurely would couple both paths.
### 2. Extract AcroForm fields before flattening
In both upload entry points the order becomes:
```
convertToPdf (router only)
→ insertFormValuesInPdf if formValues
→ extractAcroFormFieldsFromPDF(pdf) // new — must run BEFORE normalizePdf
→ const shouldFlatten = type !== 'TEMPLATE' && !extraction.hasSignedSignature
→ normalizePdf({ flattenForm: shouldFlatten })
→ extractPdfPlaceholders(normalized)
→ putPdfFileServerSide(cleanedPdf)
→ forward { placeholders, acroFormFields, formValuesProvided }
```
Why before `normalizePdf`: for `DOCUMENT` uploads `normalizePdf` calls `form.flatten()` and destroys widget geometry. Extraction must read the unflattened buffer. `formValues` filling stays first so user-prefilled values still bake into the flattened background.
When `extraction.hasSignedSignature` is true, also `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`.
`formValuesProvided` (boolean) is forwarded to the converter so the default-value mapping can skip prefill when the user-supplied values pipeline already filled the PDF.
Template flattening policy is unchanged in this plan: templates continue to preserve AcroForm widgets (no `form.flatten()`), so imported fields will visually duplicate the still-interactive PDF widgets in the template preview. Flipping templates to flatten is a follow-up — it's a breaking change for API users relying on template `formValues`.
### 3. Thread `acroFormFields` through `createEnvelope`
Extend `CreateEnvelopeOptions.data.envelopeItems[number]` (`packages/lib/server-only/envelope/create-envelope.ts:70-75`):
```ts
envelopeItems: {
title?: string;
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
acroFormFields?: AcroFormFieldImportInfo[]; // new
formValuesProvided?: boolean; // new — already-applied prefill
}[];
```
Inside the existing transaction (alongside the `itemsWithPlaceholders` branch at `:431-538`), add an `itemsWithAcroFormFields` branch:
- Run AFTER the placeholder branch so `availableRecipients` reflects any placeholder signers it created.
- Recipient resolution:
- **First-signer rule**: pick `availableRecipients.filter(r => r.role === SIGNER || r.role === APPROVER).sort((a, b) => (a.signingOrder ?? Infinity) - (b.signingOrder ?? Infinity) || a.id - b.id)[0]`.
- If none: the placeholder branch may have created `Recipient 1` already — reuse. If still none (no recipients, no placeholders), create one placeholder `SIGNER` via the same `recipient.1@documenso.com` shape used by the placeholder branch.
- All imported fields → that one recipient. User reassigns in editor.
- Call `convertAcroFormFieldsToFieldInputs(item.acroFormFields, resolver, envelopeItem.id)` then `tx.field.createMany(...)` with the same `{ envelopeId, envelopeItemId, recipientId, type, page, positionX, positionY, width, height, customText: '', inserted: false, fieldMeta }` shape used by the placeholder branch.
- Immediately after, emit per-field `FIELD_CREATED` audit log entries (see Audit Logging).
### 4. Mirror in `UNSAFE_createEnvelopeItems`
`packages/lib/server-only/envelope-item/create-envelope-items.ts:47-77` — carry `acroFormFields` and `formValuesProvided` alongside `placeholders` in `envelopeItemsToCreate`. Inside the existing `if (envelope.recipients.length > 0)` block (`:111-160`), after the placeholder loop, run the AcroForm loop using the same first-signer rule (SIGNER|APPROVER, signingOrder asc, id asc). Emit per-field `FIELD_CREATED` entries with `apiRequestMetadata`. If `envelope.recipients.length === 0`, skip — appending widgets to a recipient-less envelope is the user's setup phase and is handled when they add recipients (matches current placeholder behavior on append).
### 5. Log unsupported fields, never block upload
In both entry points, after extraction:
```ts
if (extraction.unsupported.length > 0) {
logger.info({
event: 'acroform-import.unsupported',
envelopeItemTitle,
count: extraction.unsupported.length,
byReason: groupBy(extraction.unsupported, u => u.reason),
});
}
if (extraction.skipReason) {
logger.info({ event: 'acroform-import.skip', envelopeItemTitle, reason: extraction.skipReason });
}
```
No new error type, no upload rejection, no response-shape change. A UI surface for warnings comes later once the upload response has a stable warning shape.
### 6. Schema extension
`packages/lib/types/field-meta.ts`: add `source: z.enum(['acroform']).optional()` to `ZBaseFieldMeta`. Single-line change, no callers need updating because the field is optional.
## Files
| File | Change |
| --- | --- |
| `packages/lib/types/field-meta.ts` | Add `source?: 'acroform'` to `ZBaseFieldMeta`. |
| `packages/lib/server-only/pdf/acroform-fields.ts` | **new** — extractor, converter, types, pre-extraction guards, heuristics, geometry pipeline, ordering. |
| `packages/lib/server-only/envelope/create-envelope.ts` | Extend `CreateEnvelopeOptions.envelopeItems[]` with `acroFormFields` + `formValuesProvided`. Add AcroForm branch beside placeholder branch (~`:431-538`). Emit per-field `FIELD_CREATED` audit entries. |
| `packages/trpc/server/envelope-router/create-envelope.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` in the per-file loop (`:110-141`). Downgrade `flattenForm` when `hasSignedSignature`. Forward `acroFormFields` + `formValuesProvided` into the `envelopeItems` payload (`:135-140`). Log unsupported + skipReason. |
| `packages/lib/server-only/envelope-item/create-envelope-items.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` (`:48-77`). Same flatten downgrade. Carry `acroFormFields` + `formValuesProvided` in `envelopeItemsToCreate`. Add AcroForm loop inside `envelope.recipients.length > 0` (`:111-160`). Per-field audit entries. Log unsupported + skipReason. |
| `packages/lib/server-only/pdf/acroform-fields.test.ts` | **new** — unit suite (see Tests). |
| `packages/app-tests/e2e/scenarios/acroform-import.spec.ts` | **new** — e2e suite (see Tests). |
| `scripts/generate-acroform-test-pdf.mjs` | **new** — one-off generator (committed) producing `assets/acroform-import-test.pdf` + rotated variants. |
| `assets/acroform-import-test.pdf` | **new** — base fixture: one of each supported type. |
| `assets/acroform-import-rotated-90.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-rotated-180.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-rotated-270.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-signed.pdf` | **new** — fixture with one signed signature widget + supported widgets. |
No DB schema change. No new tRPC route. No public API surface change beyond the optional `fieldMeta.source`.
## Tests
### Unit (`packages/lib/server-only/pdf/acroform-fields.test.ts`)
Drive from the committed fixture set; synthesize edge-case PDFs inline via `@libpdf/core`'s form builder where a static file is overkill.
Type resolution:
- text / signature / checkbox / radio / dropdown widgets each produce the expected Documenso field type.
- Heuristic positives: `signed_date` / `dob` → DATE; `initial` / `initials` → INITIALS; `customer_email` → EMAIL; `full_name` / `fname` → NAME; field with `AFNumber_Format` action → NUMBER; field with `MaxLen: 5` + name `qty` → NUMBER; plain `customer_id` → TEXT.
- AcroForm format actions take precedence over name tokens (a field named `customer_name` with an `AFDate_FormatEx` action → DATE).
Metadata:
- `isRequired` / `isReadOnly` round-trip into `fieldMeta`.
- `alternateName` (or `partialName` fallback) → `fieldMeta.label` on label-supporting types; SIGNATURE has no label.
- Required CHECKBOX → `required: true` + `validationRule: 'at-least'` + `validationLength: 1`.
- Every imported field has `fieldMeta.source = 'acroform'`.
Default values:
- TextField with non-empty value AND `formValuesProvided = false``fieldMeta.text` set.
- TextField with non-empty value AND `formValuesProvided = true``fieldMeta.text` NOT set.
- DropdownField selection → `fieldMeta.defaultValue`.
- CheckboxField checked → `values[0].checked = true`.
- RadioField selected → matching `values[i].checked = true`.
Geometry:
- Bottom-left widget rect `[100, 600, 200, 620]` on a 612×792 page → top-left percentages within ±0.01% of expected.
- 90° rotated page: same widget rect → rotated coordinates as defined in Coordinate Handling step 5.
- 180° and 270° rotated pages: same.
- Hidden widgets (annotation flags hidden bit) → skipped.
- Widgets with zero/negative dimensions → skipped.
- Widgets with `pageRef` not in `pdfDoc.getPages()``unsupported` with `reason: 'no-page-match'`.
- Widget rect entirely off-page → `unsupported` with `reason: 'off-page'`.
- Widget rect partially off-page → clamped, imported.
Ordering:
- Two pages × four widgets in scrambled creation order → output sorted by `(page, y, x)`.
Skips and unsupported:
- listbox / button / unknown / non-terminal → `unsupported`, never thrown.
- Encrypted PDF → `skipReason: 'encrypted'`, `fields: []`, no throw.
- XFA hybrid PDF (best-effort detect) → `skipReason: 'xfa-hybrid'` when detectable; otherwise extraction proceeds normally.
- Signed signature widget (`SignatureField.isSigned()` returns true) → `unsupported` with `reason: 'signed-signature'` AND `hasSignedSignature: true`.
- Buffer corruption → top-level try/catch, returns empty + `skipReason: 'error'`, no throw.
### E2E (`packages/app-tests/e2e/scenarios/acroform-import.spec.ts`)
- Upload `assets/acroform-import-test.pdf` as a `DOCUMENT` via the v2 envelope router with one provided SIGNER recipient → assert envelope has one `Field` per supported widget, types match, `positionX/Y/width/height` within ±1% of expected, every field's recipient is that one SIGNER, stored PDF (`documentData`) loaded via `PDF.load` reports `getForm() === null` or `getFields().length === 0`. Audit log contains N `FIELD_CREATED` entries.
- Upload with `formValues` populated → `formValues` persists, imported fields exist but have no default values set in fieldMeta, the flattened PDF reflects the prefilled values.
- Upload with two recipients: one CC + one SIGNER → all imported fields assigned to the SIGNER (CC skipped).
- Upload with two recipients: one SIGNER (signingOrder=1) + one APPROVER (signingOrder=2) → all imported fields assigned to the SIGNER.
- Upload with zero recipients → placeholder `Recipient 1` created (shared with the placeholder branch's behavior; if both placeholders and AcroForm fields exist in the same file, only one `Recipient 1` exists).
- Upload `assets/acroform-import-signed.pdf` → signed signature widget skipped, other widgets imported, stored PDF is NOT flattened (`getForm() !== null`, widgets still present).
- Append `assets/acroform-import-test.pdf` to an existing envelope with one SIGNER via `UNSAFE_createEnvelopeItems` → new `envelopeItem.id` carries the imported fields, all assigned to that SIGNER.
- Append to a recipient-less envelope → AcroForm extraction runs, fields are NOT created (skipped, matching placeholder behavior).
- Upload `TEMPLATE` → template still preserves AcroForm widgets in the stored PDF (current behavior unchanged), imported fields ALSO exist (visual duplication acknowledged in Risks).
- Upload PDF with one `listbox` + one supported `text` field → upload succeeds, only the text field becomes a Documenso field, log line emitted for the listbox.
- Upload rotated PDFs (90/180/270 fixtures) → field geometry lands within ±1% of the expected rendered position on each page.
### Regression
```bash
npx tsc --noEmit -p apps/remix/tsconfig.json
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/form-flattening.spec.ts
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/acroform-import.spec.ts
```
## Behavior Matrix
| Upload | Has AcroForm | Signed sig? | `formValues`? | `recipients`? | Result |
| --- | --- | --- | --- | --- | --- |
| `DOCUMENT` | yes | no | none | 1 SIGNER/APPROVER | All imported fields → that recipient. Stored PDF flat. Per-field `FIELD_CREATED` audit. |
| `DOCUMENT` | yes | no | none | N≥2 mixed roles | All imported fields → first SIGNER|APPROVER by (signingOrder asc, id asc). CC/VIEWER skipped. User reassigns in editor. |
| `DOCUMENT` | yes | no | none | only CC/VIEWER | Treated as "no signable recipients" — placeholder `Recipient 1` SIGNER created. |
| `DOCUMENT` | yes | no | none | none | One placeholder `Recipient 1` SIGNER created (reused if placeholder branch already made one). |
| `DOCUMENT` | yes | no | provided | any | `formValues` filled → flattened values visible → empty supported fields imported with `source: 'acroform'`, no `fieldMeta.text`/`defaultValue` prefill. |
| `DOCUMENT` | yes | yes | any | any | Signed signature(s) skipped + logged. `flattenForm` downgraded to false → stored PDF retains widgets. Other supported widgets imported normally. |
| `DOCUMENT` | no | n/a | any | any | Unchanged. |
| `TEMPLATE` | yes | n/a | any | any | Imported fields created **and** PDF still contains interactive widgets (known artifact, follow-up). |
| Encrypted PDF | n/a | n/a | any | any | Extraction skipped + logged. Upload proceeds with zero AcroForm imports. |
| XFA hybrid (detected) | n/a | n/a | any | any | Extraction skipped + logged. Same as encrypted. |
| Append to existing envelope w/ recipients | yes | no | n/a | n/a | Imported fields → first SIGNER|APPROVER of the envelope. Per-field audit. |
| Append to existing envelope w/o recipients | yes | n/a | n/a | n/a | Skipped (matches current placeholder behavior on append). |
## Out of Scope / Follow-ups
- Flipping `TEMPLATE` uploads to flatten after import — breaking for API users relying on template AcroForm `formValues`.
- Editor UI surface: "Imported from PDF" badge (using `fieldMeta.source`), warning toast for skipped widgets, encrypted/XFA banner. Data is captured now; UI ships separately.
- A signed-AcroForm-signature → completed Documenso signature mapping.
- True radio-group consolidation (one Documenso field per AcroForm radio group instead of per widget) — needs `fieldMeta` schema extension for multi-position groups.
- Same-name multi-widget non-radio fields (one AcroForm text field rendered on N pages) — currently emit N independent Documenso fields; future work could sync values at signing time via a shared `groupId` in fieldMeta.
- Listbox support.
- Recipient inference from PDF authoring metadata (Adobe's role/recipient hints, tab order grouping).
- AcroForm `/Tabs` ordering as a signal — current spatial sort suffices.
## Risks
- **Rotated pages**: covered by inverse-rotation transform with 90/180/270 fixtures gating the unit suite. Skewed rotations (non-cardinal) are not supported; should be rejected as `off-page` if their normalized rect doesn't land within page bounds.
- **Radio groups**: emitting one Documenso field per widget will look right visually but signing semantics differ from a single PDF radio group (each option becomes independently checkable). Gating fixture in e2e covers visual placement; signing semantics divergence is documented and ships as a known limitation.
- **Template behavior**: leaving `TEMPLATE` uploads unflattened means imported fields and live widgets coexist. Acceptable for v1, but the template preview will show duplicated controls.
- **Signed signature + flattenForm downgrade**: a `DOCUMENT` upload containing a signed signature now stores an un-flattened PDF. Existing code paths that assume `DOCUMENT` PDFs are always flat (signing renderer, downstream conversion) MUST be re-verified — add an integration check in the e2e suite that signing still works on the signed-fixture envelope.
- **Recipient ambiguity**: AcroForm widgets don't encode Documenso recipients. Deterministic "all to first signer" + editor review is the safest first cut; smarter assignment is a follow-up.
- **XFA detection**: best-effort; if @libpdf/core's public surface doesn't expose the catalog AcroForm dict, we fall through and import any mirrored AcroForm fields. Acceptable — XFA-only PDFs with no mirror produce empty AcroForm extraction and the upload proceeds. Worst case is a noisy log line on a misclassified hybrid.
- **Heuristic false positives**: expanded heuristic (NAME/EMAIL/NUMBER/DATE/INITIALS) increases the chance of mis-typing a field. Mitigation: every imported field is editable in the editor before sending. False negatives fall through to TEXT (always safe).
+1 -1
View File
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
# min-release-age = 7
min-release-age = 7
@@ -65,4 +65,3 @@ When you exceed a resource limit:
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
- [API Versioning](/docs/developers/api/versioning) - API version management
- [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Admins: set per-organisation resource quotas and rate limits (the HTTP rate limit above is separate and not admin-settable)
@@ -76,8 +76,6 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
</Accordion>
</Accordions>
@@ -199,7 +197,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement
2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
4. Configure Enterprise features with support assistance
</Step>
<Step>
@@ -240,7 +238,6 @@ See [Support](/docs/policies/support) for complete support options.
## Related
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
@@ -510,5 +510,4 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Set per-organisation document, email, and API limits from the admin panel
- [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues
@@ -29,11 +29,6 @@ description: Configure your self-hosted Documenso instance with environment vari
description="Digital signature certificate setup."
href="/docs/self-hosting/configuration/signing-certificate"
/>
<Card
title="Organisation Limits"
description="Set per-organisation document, email, and API limits via the admin panel."
href="/docs/self-hosting/configuration/organisation-limits"
/>
</Cards>
## Required Configuration
@@ -1,107 +0,0 @@
---
title: Apply Your License Key
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
<Callout type="info">
The license key applies to your **whole instance**, not an individual user account. There's one
key per deployment.
</Callout>
## Prerequisites
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
- A running self-hosted Documenso instance that you're able to restart
## Step 1: Set the environment variable
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
<Tabs items={['Docker Compose', 'docker run', '.env']}>
<Tab value="Docker Compose">
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
Then apply it:
```bash
docker compose up -d
```
</Tab>
<Tab value="docker run">
```bash
docker run -d \
--name documenso \
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
documenso/documenso:latest
```
</Tab>
<Tab value=".env">
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
</Tab>
</Tabs>
## Step 2: Restart the instance
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
```bash
# Docker Compose
docker compose restart documenso
# Docker
docker restart documenso
```
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
## What the license enables
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
## Troubleshooting
<Accordions type="multiple">
<Accordion title="Enterprise features are still unavailable after applying the key">
- Confirm the key is present in the environment the running process actually reads — `docker
exec` into the container and check `env | grep LICENSE` if unsure.
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
- Re-copy the key to rule out truncation or accidental whitespace.
</Accordion>
<Accordion title="A specific feature still isn't working">
Instance-wide features (like CSC signing) also need their own configuration — an active license
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
Per-organisation features additionally need to be provisioned for the organisation that's using
them.
</Accordion>
</Accordions>
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
@@ -2,14 +2,12 @@
"title": "Configuration",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
"background-jobs",
"signing-certificate",
"telemetry",
"organisation-limits",
"advanced"
]
}
@@ -1,111 +0,0 @@
---
title: Organisation Limits
description: View and set per-organisation document, email, and API limits on a self-hosted Documenso instance using the admin panel's subscription claims.
---
import { Callout } from 'fumadocs-ui/components/callout';
Per-organisation limits — document, email, and API usage, plus feature toggles and team/member caps — are controlled by **subscription claims**. You configure them in the admin panel, not through environment variables.
There are three distinct kinds of limit:
| Limit | Caps | Admin-settable |
| ---------------------- | ------------------------------------------------- | ----------------------- |
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) |
## Prerequisites
- A running self-hosted Documenso instance.
- An account with the **`ADMIN`** role — an account-level role, separate from organisation and team roles. New accounts are created with the `USER` role only. Grant the first admin by adding `ADMIN` to that user's `roles` directly in the database; after that, an existing admin can grant the role to others under **Admin Panel > Users > _(user)_ > Roles > Update user**.
Open the admin panel at `/admin`. The sidebar sections used below are **Claims**, **Organisations**, and **Organisation Stats**.
## Viewing usage
**One organisation:** open **Admin Panel > Organisations** and select it. The **Organisation usage** section shows the current period's document, email, and API usage against its quotas.
**All organisations:** open **Admin Panel > Organisation Stats** to sort and filter monthly usage. Filter by **claim** and by **period** (a UTC calendar month, shown as `YYYY-MM`), and switch between **Show usage**, **Show usage with quotas**, and **Show daily averages**.
<Callout type="warn">
Usage counts **attempts**, not only successful actions. A request that exceeds a quota is still counted before it is rejected, so displayed usage can read higher than the number of actions that succeeded.
</Callout>
## Subscription claims
A subscription claim is a named bundle of limits and feature flags (for example `Free`, `Individual`, `Teams`, `Platform`, or `Enterprise`). Claims are **templates**: when an organisation is created it receives a private copy of its claim and reads from that copy afterwards. Editing a claim template therefore affects organisations created later, not existing ones — to change an existing organisation, [edit it directly](#change-limits-for-one-organisation).
### Claim fields
Under **Admin Panel > Claims** (`/admin/claims`), each claim has:
| Field | Controls |
| ----------------------- | --------------------------------------------------------------------------------- |
| **Name** | The claim's display name. |
| **Team Count** | Teams allowed. `0` = unlimited. |
| **Member Count** | Members allowed. `0` = unlimited. |
| **Envelope Item Count** | Uploaded files allowed per envelope. Minimum `1`. |
| **Recipient Count** | Recipients allowed per document. `0` = unlimited. |
| **Feature Flags** | Feature toggles (see [Feature flags](#feature-flags)). |
| **Limits** | Monthly quota and rate-limit windows for Documents, Emails, and API. |
| **Email transport** | Transport the claim uses. *Default (system mailer)* uses the instance default. |
### Quotas and rate limits
The **Limits** section has a column for **Documents**, **Emails**, and **API**, each with two controls:
- **Monthly quota** — how many of that resource are allowed per calendar month. An **empty** field is unlimited; **`0`** blocks the resource entirely.
- **Rate limit windows** — optional short-window caps, each a duration and a maximum. A window is a number and a unit (`s`, `m`, `h`, `d`), such as `5m`, `1h`, or `24h`, and must be unique within the resource.
<Callout type="warn">
Quotas and counts use opposite conventions for "unlimited": an **empty** quota is unlimited (and `0` blocks the resource), whereas `0` in the **Team**, **Member**, and **Recipient Count** fields means unlimited.
</Callout>
### Feature flags
The **Feature Flags** section toggles capabilities such as Unlimited documents, Branding, Hide Documenso branding, Email domains, Embed authoring, Embed signing, White label for embed authoring/signing, 21 CFR, HIPAA, Authentication portal, Allow Legacy Envelopes, Signing reminders, QES signing, and Disable emails.
Some flags are Enterprise features. If your license does not include one, it is marked and cannot be enabled (you can still turn it off). See [Enterprise Edition](/docs/policies/enterprise-edition).
### Create or edit a claim template
1. Go to **Admin Panel > Claims**.
2. Select **New claim**, or select an existing claim to edit it.
3. Set the counts, feature flags, and the **Limits** section.
4. Save. Changes apply to organisations created afterwards, not existing ones.
### Change limits for one organisation
To change limits for an existing organisation, edit it directly rather than its claim template.
1. Go to **Admin Panel > Organisations** and open the organisation.
2. Adjust its quota, rate-limit, feature-flag, or email-transport fields.
3. Save. Changes take effect immediately.
The organisation also shows the **Inherited subscription claim** it was created from.
## Usage reset
Monthly quota usage is keyed to the **UTC calendar month**. There is no scheduled reset job — when the month rolls over, the new period's counter starts at `0`.
## Limitations
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
## Troubleshooting
| Symptom | Cause and fix |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| An organisation hit its limit unexpectedly | Usage counts rejected over-quota attempts. Compare usage against the quota under **Organisation Stats > Show usage with quotas**. |
| A resource is blocked entirely, not just capped | The **Monthly quota** is `0`, which blocks the resource. Leave it empty for unlimited. |
| Emails are not sending for an organisation | Check whether the **Disable emails** flag is enabled on the organisation's claim — it blocks all emails regardless of quota. |
| A claim template edit had no effect | Template edits are not retroactive. Edit the organisation directly under **Admin Panel > Organisations**. |
---
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - All configuration options
- [Rate Limits](/docs/developers/api/rate-limits) - The global HTTP API rate limit (separate from claims)
- [Enterprise Edition](/docs/policies/enterprise-edition) - Features unlocked by license flags
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
---
+2 -2
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build",
"build": "next build",
"dev": "next dev",
"start": "next start",
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
@@ -29,7 +29,7 @@
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.19",
"postcss": "^8.5.14",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
+1 -1
View File
@@ -83,7 +83,7 @@
--accent: hsl(0 0% 27.8431%);
--accent-foreground: hsl(95.0847 71.0843% 67.451%);
--destructive: hsl(0 86.5979% 61.9608%);
--destructive-foreground: hsl(0 0% 98.0392%);
--destructive-foreground: hsl(0 87.6289% 19.0196%);
--border: hsl(0 0% 27.8431%);
--input: hsl(0 0% 27.8431%);
--ring: hsl(95.0847 71.0843% 67.451%);
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -24,7 +23,7 @@ import { useParams } from 'react-router';
import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -66,7 +65,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
toast({
description: t`Folder created successfully`,
});
} catch (_err) {
} catch (err) {
toast({
title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`,
@@ -122,7 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -1,6 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,6 +23,8 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
@@ -31,7 +32,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
@@ -39,6 +40,7 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
@@ -336,7 +336,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
>
<div className="w-full text-left">
<div className="flex items-center justify-between">
<p className="font-medium">
<p className="text-medium">
<Trans context="Plan price">Free</Trans>
</p>
@@ -115,7 +115,7 @@ export const OrganisationEmailDomainDeleteDialog = ({
<FormLabel>
<Trans>
Confirm by typing{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -370,7 +370,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
<button
type="button"
className={cn(
'inline-flex h-10 w-10 items-center justify-start text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={organisationMemberInvites.length === 1}
@@ -1,6 +1,5 @@
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -26,13 +25,14 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({
passkeyName: ZNameSchema,
passkeyName: z.string().min(3),
});
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,5 +1,4 @@
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -20,16 +19,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
data: true,
}).shape.data;
const ZUpdateTeamEmailFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
});
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -45,7 +44,6 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: {
name: teamEmail.name,
},
mode: 'onSubmit',
});
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,250 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
const NEVER_EXPIRE = 'NEVER' as const;
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`,
THREE_MONTHS: msg`3 months`,
SIX_MONTHS: msg`6 months`,
ONE_YEAR: msg`12 months`,
[NEVER_EXPIRE]: msg`Never`,
} as const;
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
tokenName: true,
expirationDate: true,
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
export type TokenCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TokenCreateDialog = ({ trigger, ...props }: TokenCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<string | null>(null);
const form = useForm<TCreateTokenFormSchema>({
resolver: zodResolver(ZCreateTokenFormSchema),
defaultValues: {
tokenName: '',
expirationDate: 'THREE_MONTHS',
},
});
const { mutateAsync: createToken } = trpc.apiToken.create.useMutation();
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
try {
const { token } = await createToken({
teamId: team.id,
tokenName,
expirationDate: expirationDate === NEVER_EXPIRE ? null : expirationDate,
});
setCreatedToken(token);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
.otherwise(() => msg`Something went wrong. Please try again later.`);
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
duration: 5000,
});
}
};
useEffect(() => {
if (open) {
form.reset();
setCreatedToken(null);
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="flex-shrink-0">
<Trans>Create token</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent
className="max-w-lg"
position="center"
onInteractOutside={(event) => {
// Prevent losing the created token by accidentally clicking outside the dialog.
if (createdToken) {
event.preventDefault();
}
}}
>
{createdToken ? (
<>
<DialogHeader>
<DialogTitle>
<Trans>Token created</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Copy your token now. For security reasons you will not be able to see it again.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Input
className="pr-12 font-mono text-sm"
aria-label={_(msg`Your new API token`)}
name="createdToken"
readOnly
value={createdToken}
/>
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
<CopyTextButton
value={createdToken}
onCopySuccess={() => toast({ title: _(msg`Token copied to clipboard`) })}
/>
</div>
</div>
<DialogFooter>
<Button type="button" onClick={() => setOpen(false)}>
<Trans>Done</Trans>
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>
<Trans>Create API token</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Use API tokens to authenticate with the Documenso API.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>A name to help you identify this token later.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Expires in</Trans>
</FormLabel>
<FormControl>
<Select value={field.value ?? NEVER_EXPIRE} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{_(date)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete token</Trans>
<Trans>Are you sure you want to delete this token?</Trans>
</DialogTitle>
<DialogDescription>
@@ -126,7 +126,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
@@ -139,18 +139,21 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" className="flex-1" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>I'm sure! Delete it</Trans>
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
@@ -117,7 +117,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -503,7 +503,7 @@ export const ConfigureFieldsView = ({
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -1,10 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils';
@@ -28,15 +23,15 @@ import { useCspNonce } from '~/utils/nonce';
import { FormStickySaveBar } from './form-sticky-save-bar';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
brandingLogo: z
.instanceof(File)
.refine(
(file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
)
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
@@ -250,7 +245,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative">
<Input
type="file"
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
accept={ACCEPTED_FILE_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import {
Form,
FormControl,
@@ -16,8 +15,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: ZNameSchema,
fromName: ZNameSchema,
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(),
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
+2 -2
View File
@@ -1,8 +1,8 @@
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onChange',
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormSchema),
});
+254
View File
@@ -0,0 +1,254 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`,
THREE_MONTHS: msg`3 months`,
SIX_MONTHS: msg`6 months`,
ONE_YEAR: msg`12 months`,
} as const;
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
tokenName: true,
expirationDate: true,
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
type NewlyCreatedToken = {
id: number;
token: string;
};
export type ApiTokenFormProps = {
className?: string;
tokens?: Pick<ApiToken, 'id'>[];
};
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [, copy] = useCopyToClipboard();
const team = useCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
const [noExpirationDate, setNoExpirationDate] = useState(false);
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data);
},
});
const form = useForm<TCreateTokenFormSchema>({
resolver: zodResolver(ZCreateTokenFormSchema),
defaultValues: {
tokenName: '',
expirationDate: '',
},
});
const copyToken = async (token: string) => {
try {
const copied = await copy(token);
if (!copied) {
throw new Error('Unable to copy the token');
}
toast({
title: _(msg`Token copied to clipboard`),
description: _(msg`The token was copied to your clipboard.`),
});
} catch (error) {
toast({
title: _(msg`Unable to copy token`),
description: _(msg`We were unable to copy the token to your clipboard. Please try again.`),
variant: 'destructive',
});
}
};
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
try {
await createTokenMutation({
teamId: team.id,
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});
toast({
title: _(msg`Token created`),
description: _(msg`A new token was created successfully.`),
duration: 5000,
});
form.reset();
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
.otherwise(() => msg`Something went wrong. Please try again later.`);
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
duration: 5000,
});
}
};
return (
<div className={cn(className)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">
<Trans>Token name</Trans>
</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Input type="text" {...field} />
</FormControl>
</div>
<FormDescription className="text-xs italic">
<Trans>Please enter a meaningful name for your token. This will help you identify it later.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">
<Trans>Token expiration date</Trans>
</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
<SelectTrigger className="w-full">
<SelectValue placeholder={_(msg`Choose...`)} />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{_(date)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel className="mt-2 text-muted-foreground">
<Trans>Never expire</Trans>
</FormLabel>
<div className="block md:py-1.5">
<Switch
className="mt-2 bg-background"
checked={noExpirationDate}
onCheckedChange={setNoExpirationDate}
/>
</div>
</div>
</div>
<Button type="submit" className="hidden md:inline-flex" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
<div className="md:hidden">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
<AnimatePresence>
{newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: -40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 40 }}
>
<Card gradient>
<CardContent className="p-4">
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Your token was created successfully! Make sure to copy it because you won't be able to see it again!
</Trans>
</p>
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
{newlyCreatedToken.token}
</p>
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken.token)}>
<Trans>Copy token</Trans>
</Button>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -26,72 +25,38 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean;
/** When viewing a team, the parent organisation settings the team inherits from. */
inheritedSettings?: OrganisationGlobalSettings | null;
};
export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const notSet = <Trans>Not set</Trans>;
const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
return value;
};
const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value && value.trim() !== '') {
return value;
const brandingTextValue = (value: string | null | undefined) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
}
if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
return value;
};
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
if (value !== null && value !== undefined) {
return booleanLabel(value);
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
};
const visibilityLabel = (value: string | null | undefined) => {
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
};
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
const label = visibilityLabel(value);
if (label !== null) {
return label;
}
return inheritedValue(visibilityLabel(inherited));
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
};
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -100,82 +65,70 @@ export const AdminGlobalSettingsSection = ({
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
@@ -192,7 +145,7 @@ export const AdminGlobalSettingsSection = ({
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
@@ -87,7 +87,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="mb-2 flex items-end font-medium text-foreground text-sm leading-tight">
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">
<Trans>Documenso License</Trans>
</h3>
File diff suppressed because it is too large Load Diff
@@ -1,36 +0,0 @@
import type { MessageDescriptor } from '@lingui/core';
import type { LucideIcon } from 'lucide-react';
export type PromptItem = {
id: string;
label: string | MessageDescriptor;
sublabel?: string;
path?: string;
onAction?: () => void;
icon?: LucideIcon;
initials?: string;
shortcut?: string;
isChecked?: boolean;
};
export type PromptCategory = {
id: string;
label: MessageDescriptor;
items: PromptItem[];
/**
* The number of actual results, excluding utility rows such as the
* "View all results" link.
*/
count: number;
/**
* The count shown on the category chip, or null to not show a chip at all.
* Categories which only contain hardcoded page links have no chip.
*/
chipCount: number | null;
isCapped: boolean;
/**
* Global admin categories are marked with a globe icon to distinguish them
* from the equally named personal categories.
*/
isGlobal: boolean;
};
@@ -1,7 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -20,6 +19,7 @@ import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z
.object({
name: ZNameSchema,
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
email: zEmail().min(1),
password: ZPasswordSchema,
})
@@ -1,4 +1,11 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react';
@@ -6,13 +13,6 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input';
/**
* The rate-limit editor renders its own per-row inline errors, but a submit
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
* window). Rendering the field's message here guarantees the form never fails
* silently when those errors are not tied to a row the editor is showing.
*/
type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,12 +20,6 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean;
};
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
@@ -36,33 +30,13 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>;
const limitGroups: LimitGroup[] = [
{
title: <Trans>Documents</Trans>,
quotaKey: 'documentQuota',
rateLimitKey: 'documentRateLimits',
},
{
title: <Trans>Emails</Trans>,
quotaKey: 'emailQuota',
rateLimitKey: 'emailRateLimits',
},
{
title: <Trans>API</Trans>,
quotaKey: 'apiQuota',
rateLimitKey: 'apiRateLimits',
},
];
const renderQuotaField = (group: LimitGroup) => (
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
<FormField
control={control}
name={name(group.quotaKey)}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type="number"
@@ -73,18 +47,20 @@ export const ClaimLimitFields = <T extends FieldValues>({
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
const renderRateLimitField = (group: LimitGroup) => (
const renderRateLimitField = (key: string, label: ReactNode) => (
<FormField
control={control}
name={name(group.rateLimitKey)}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
@@ -95,30 +71,27 @@ export const ClaimLimitFields = <T extends FieldValues>({
);
return (
<div className="space-y-3">
<div>
<h3 className="font-semibold text-base">
<Trans>Limits</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
</Trans>
</p>
</div>
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
<div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
{limitGroups.map((group) => (
<div key={group.quotaKey} className="space-y-4 p-4">
<h4 className="font-semibold text-sm">{group.title}</h4>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
{renderQuotaField(group)}
{renderRateLimitField(group)}
</div>
))}
</div>
</div>
{renderQuotaField(
'emailQuota',
<Trans>Monthly email quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
</div>
);
};
@@ -270,7 +270,7 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
@@ -20,18 +20,20 @@ import {
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangleIcon, FileTextIcon, FormInputIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -78,7 +80,8 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { envelope, editorFields, navigateToStep, editorConfig, flushAutosave, syncEnvelope } =
useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -86,7 +89,32 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const [acroFormHasFieldsByItemRevision, setAcroFormHasFieldsByItemRevision] = useState<Record<string, boolean>>({});
const { revalidate } = useRevalidator();
const { toast } = useToast();
const { mutateAsync: importFieldsFromPdf, isPending: isImportingFieldsFromPdf } =
trpc.envelope.field.importFromPdf.useMutation();
const currentEnvelopeItemRevision = currentEnvelopeItem
? `${currentEnvelopeItem.id}:${currentEnvelopeItem.documentDataId}`
: null;
const currentItemHasAcroForm =
currentEnvelopeItemRevision !== null && acroFormHasFieldsByItemRevision[currentEnvelopeItemRevision] === true;
const onAcroFormDetected = useCallback(
(hasFields: boolean) => {
if (!currentEnvelopeItemRevision) {
return;
}
setAcroFormHasFieldsByItemRevision((prev) =>
prev[currentEnvelopeItemRevision] === hasFields ? prev : { ...prev, [currentEnvelopeItemRevision]: hasFields },
);
},
[currentEnvelopeItemRevision],
);
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
@@ -201,6 +229,40 @@ export const EnvelopeEditorFieldsPage = () => {
});
};
const onImportFromPdfClick = async () => {
try {
await flushAutosave();
const result = await importFieldsFromPdf({ envelopeId: envelope.id });
if (result.fieldsCreated === 0) {
toast({
title: _(msg`No form fields found`),
description: _(msg`This PDF does not contain any importable form fields.`),
duration: 5000,
});
return;
}
await syncEnvelope();
toast({
title: _(msg`Fields imported`),
description: _(
msg`Imported ${result.fieldsCreated} field${result.fieldsCreated === 1 ? '' : 's'} from the PDF form. Review and reassign in the editor.`,
),
duration: 5000,
});
} catch {
toast({
title: _(msg`Could not import fields`),
description: _(msg`Something went wrong while importing fields from the PDF.`),
variant: 'destructive',
duration: 5000,
});
}
};
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
@@ -288,6 +350,7 @@ export const EnvelopeEditorFieldsPage = () => {
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
onAcroFormDetected={onAcroFormDetected}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -380,6 +443,20 @@ export const EnvelopeEditorFieldsPage = () => {
/>
</>
)}
{currentItemHasAcroForm && envelope.status === DocumentStatus.DRAFT && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={() => void onImportFromPdfClick()}
disabled={isImportingFieldsFromPdf}
>
<FormInputIcon className="mr-2 -ml-1 h-4 w-4" />
{isImportingFieldsFromPdf ? <Trans>Importing...</Trans> : <Trans>Import from PDF form</Trans>}
</Button>
)}
</section>
{/* Field details section. */}
@@ -174,7 +174,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
{type === EnvelopeType.DOCUMENT ? <Trans>Upload Document</Trans> : <Trans>Upload Template</Trans>}
</h2>
<p className="mt-4 text-base text-muted-foreground">
<p className="mt-4 text-md text-muted-foreground">
<Trans>Drag and drop your document here</Trans>
</p>
@@ -1,38 +1,13 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import {
getQuotaUsagePercent,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from '@documenso/lib/universal/quota-usage';
import { cn } from '@documenso/ui/lib/utils';
import type { BadgeProps } from '@documenso/ui/primitives/badge';
import { Badge } from '@documenso/ui/primitives/badge';
import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useId, useState } from 'react';
import { useState } from 'react';
import { match } from 'ts-pattern';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type CapacityUsage = {
members: number;
teams: number;
};
type UsageRow = {
counter: 'document' | 'email' | 'api';
label: ReactNode;
icon: LucideIcon;
used: number;
effectiveLimit: number | null;
};
type OrganisationUsagePanelProps = {
organisationId: string;
monthlyStats: Pick<
@@ -40,151 +15,13 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
organisationClaim: OrganisationClaim;
capacityUsage?: CapacityUsage;
};
type UsageCardState = {
status: {
label: ReactNode;
variant: NonNullable<BadgeProps['variant']>;
};
percent: number;
hasFiniteLimit: boolean;
progressClassName: string;
subtext: ReactNode;
};
type UsageCardStateOptions = {
used: number;
limit: number | null | undefined;
footnote?: ReactNode;
};
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
const percent = getQuotaUsagePercent(used, limit ?? null);
const hasFiniteLimit = Boolean(limit && limit > 0);
if (limit === null || limit === undefined) {
return {
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
}
if (limit === 0) {
return {
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? <Trans>Resource blocked</Trans>,
};
}
if (used > limit) {
return {
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-destructive',
subtext: footnote ?? null,
};
}
if (isQuotaExceeded(limit, used)) {
return {
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
subtext: footnote ?? null,
};
}
if (isQuotaNearing(limit, used)) {
return {
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
subtext: footnote ?? null,
};
}
return {
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
};
type UsageStatCardProps = {
label: ReactNode;
icon: LucideIcon;
used: number;
limit: number | null | undefined;
/** When true the card is a plain counter with no limit, status or progress. */
countOnly?: boolean;
footnote?: ReactNode;
action?: ReactNode;
};
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
return (
<div className="flex flex-col rounded-lg border bg-background p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
<Icon className="h-4 w-4 text-muted-foreground" />
<span>{label}</span>
</div>
{!countOnly && (
<Badge variant={status.variant} size="small">
{status.label}
</Badge>
)}
</div>
<div className="mt-4 flex flex-1 flex-col">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5">
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
{used.toLocaleString()}
</span>
{hasFiniteLimit ? (
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
) : null}
</div>
{hasFiniteLimit ? (
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
) : null}
</div>
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
</div>
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
</div>
);
};
export const OrganisationUsagePanel = ({
organisationId,
monthlyStats,
organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
@@ -193,105 +30,86 @@ export const OrganisationUsagePanel = ({
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const capacityRows = capacityUsage
? [
{
key: 'members',
label: <Trans>Members</Trans>,
icon: UsersIcon,
used: capacityUsage.members,
limit: normalizeCapacityLimit(organisationClaim.memberCount),
},
{
key: 'teams',
label: <Trans>Teams</Trans>,
icon: UsersRoundIcon,
used: capacityUsage.teams,
limit: normalizeCapacityLimit(organisationClaim.teamCount),
},
]
: [];
const monthlyRows: UsageRow[] = [
const rows = [
{
counter: 'document',
counter: 'document' as const,
label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email',
counter: 'email' as const,
label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api',
counter: 'api' as const,
label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
return (
<div className="mt-4 space-y-6">
{capacityRows.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{capacityRows.map((row) => (
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<Trans>Monthly usage</Trans>
</h3>
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{monthlyStats.length > 0 ? (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
{rows.map((row) => {
const percent =
row.effectiveLimit && row.effectiveLimit > 0
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
: 0;
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{monthlyRows.map((row) => (
<UsageStatCard
key={row.counter}
label={row.label}
icon={row.icon}
used={row.used}
limit={row.effectiveLimit}
action={
selectedStat && isCurrentPeriod ? (
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
) : undefined
}
/>
))}
return (
<div key={row.counter} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{row.label}</span>
<span className="text-muted-foreground">
{row.used} /{' '}
{match(row.effectiveLimit)
.with(null, () => <Trans>Unlimited</Trans>)
.with(0, () => <Trans>Blocked</Trans>)
.otherwise(String)}
</span>
</div>
<UsageStatCard
label={<Trans>Reports</Trans>}
icon={MailOpenIcon}
used={selectedStat?.emailReports ?? 0}
limit={null}
countOnly
footnote={<Trans>Sent this period</Trans>}
/>
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div>
)}
</div>
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
</div>
</div>
</div>
@@ -2,7 +2,6 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = {
@@ -33,7 +32,6 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending}
onClick={() => reset({ organisationId, counter })}
>
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans>
</Button>
);
@@ -46,7 +46,7 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
return (
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}-${currentEnvelopeItem.documentDataId}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
@@ -50,6 +50,7 @@ export type PDFViewerProps = {
scrollParentRef: ScrollTarget;
onDocumentLoad?: () => void;
onAcroFormDetected?: (hasFields: boolean) => void;
/**
* Additional component to render next to the image, such as a Konva canvas
@@ -63,6 +64,7 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
onAcroFormDetected,
customPageRenderer,
...props
}: PDFViewerProps) {
@@ -124,6 +126,20 @@ export default function PDFViewer({
// eslint-disable-next-line require-atomic-updates
pdfRef.current = loadedPdf;
if (onAcroFormDetected) {
try {
const fieldObjects = await loadedPdf.getFieldObjects();
if (!isCancelled) {
onAcroFormDetected(fieldObjects !== null && Object.keys(fieldObjects).length > 0);
}
} catch {
if (!isCancelled) {
onAcroFormDetected(false);
}
}
}
// Fetch the pages
const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => {
const page = await loadedPdf.getPage(pageIndex + 1);
@@ -168,7 +184,7 @@ export default function PDFViewer({
pdfRef.current = null;
}
};
}, [data]);
}, [data, onAcroFormDetected]);
// Notify when document is loaded
useEffect(() => {
@@ -1,9 +1,7 @@
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number };
@@ -13,153 +11,50 @@ type RateLimitArrayInputProps = {
disabled?: boolean;
};
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
/** Keep in-progress rows; drop rows that are completely empty. */
const persistEntries = (entries: RateLimitEntryValue[]) => {
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
};
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const { t } = useLingui();
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
const window = entry.window.trim();
if (!hasEntryInput(entry)) {
return null;
}
if (window === '') {
return t`Enter a window, e.g. 5m`;
}
if (!RATE_LIMIT_WINDOW_REGEX.test(window)) {
return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`;
}
const isDuplicateWindow = entries.some((otherEntry, otherIndex) => {
return otherIndex !== index && otherEntry.window.trim() === window;
});
return isDuplicateWindow ? t`Use a unique window for each rate limit` : null;
};
const getMaxError = (entry: RateLimitEntryValue) => {
if (!hasEntryInput(entry)) {
return null;
}
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
};
const entries = value ?? [];
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
if (index >= value.length) {
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
if (hasEntryInput(nextDraftEntry)) {
onChange(persistEntries([...value, nextDraftEntry]));
setDraftEntry(null);
return;
}
setDraftEntry(nextDraftEntry);
return;
}
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(persistEntries(next));
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(next);
};
const removeEntry = (index: number) => {
if (index >= value.length) {
setDraftEntry(null);
return;
}
const next = value.filter((_, i) => i !== index);
onChange(persistEntries(next));
onChange(entries.filter((_, i) => i !== index));
};
const addEntry = () => {
setDraftEntry(EMPTY_ENTRY);
onChange([...entries, { window: '5m', max: 100 }]);
};
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span className="w-20 shrink-0">
<Trans>Window</Trans>
</span>
<span className="flex-1">
<Trans>Max requests</Trans>
</span>
<span className="w-9 shrink-0" aria-hidden="true" />
</div>
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="w-24"
placeholder="5m"
value={entry.window}
disabled={disabled}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="w-32"
type="number"
min={1}
value={entry.max}
disabled={disabled}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
{entries.map((entry, index) => {
const windowError = getWindowError(entry, index);
const maxError = getMaxError(entry);
return (
<div key={index} className="space-y-1">
<div className="flex items-center gap-2">
<Input
className="w-20 shrink-0"
placeholder="5m"
value={entry.window}
disabled={disabled}
aria-invalid={Boolean(windowError)}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="flex-1"
type="number"
min={1}
placeholder="100"
value={entry.max || ''}
disabled={disabled}
aria-invalid={Boolean(maxError)}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
disabled={disabled}
aria-label={t`Remove rate limit`}
onClick={() => removeEntry(index)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-dashed"
disabled={isAddDisabled}
onClick={addEntry}
>
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit window</Trans>
<Trans>Add rate limit</Trans>
</Button>
</div>
);
@@ -1,144 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
import type { TAdminSearchResultType } from '@documenso/trpc/server/admin-router/admin-search.types';
import { ADMIN_SEARCH_MAX_QUERY_LENGTH } from '@documenso/trpc/server/admin-router/admin-search.types';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { keepPreviousData } from '@tanstack/react-query';
import type { LucideIcon } from 'lucide-react';
import { ArrowRightIcon, Building2Icon, CreditCardIcon, FileTextIcon, UserIcon, UsersIcon } from 'lucide-react';
import { useMemo } from 'react';
import type { PromptCategory, PromptItem } from './app-command-menu.types';
/**
* The maximum number of results the admin search returns per resource type.
*/
const ADMIN_SEARCH_RESULTS_CAP = 5;
const ADMIN_GROUP_LABELS: Record<TAdminSearchResultType, MessageDescriptor> = {
document: msg`Documents`,
user: msg`Users`,
organisation: msg`Organisations`,
team: msg`Teams`,
recipient: msg`Recipients`,
subscription: msg`Subscriptions`,
};
const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
document: FileTextIcon,
user: UserIcon,
organisation: Building2Icon,
team: UsersIcon,
recipient: UserIcon,
subscription: CreditCardIcon,
};
/**
* Admin list pages which support prefilling their search from the URL, used
* for the "View all results" links on capped groups. Teams, recipients and
* subscriptions have no admin list pages.
*/
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
};
export type UseAdminSearchCategoriesOptions = {
/**
* The trimmed, debounced search query.
*/
query: string;
open: boolean;
};
/**
* The isolated admin portion of the command prompt: searches every admin
* resource and maps the results to prompt categories marked as global.
*
* Returns no categories and never queries for non admin users. The admin
* search endpoint is additionally guarded server side by the admin procedure.
*/
export const useAdminSearchCategories = ({ query, open }: UseAdminSearchCategoriesOptions) => {
const { user } = useSession();
const isUserAdmin = isAdmin(user);
// Admin searches hit every resource table, so require a longer query unless
// it is a number, which could be a resource ID of any length. Queries over
// the endpoint's length limit are skipped entirely instead of being sent
// and rejected.
const hasValidAdminSearch =
isUserAdmin && query.length <= ADMIN_SEARCH_MAX_QUERY_LENGTH && (query.length > 3 || /^\d+$/.test(query));
const {
data: adminSearchData,
isFetching,
isError,
} = trpcReact.admin.search.useQuery(
{
query,
},
{
enabled: open && hasValidAdminSearch,
placeholderData: keepPreviousData,
// Retyping is the retry in a search-as-you-type flow: fail fast so the
// prompt can surface an honest error state instead of retrying.
retry: false,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const categories = useMemo((): PromptCategory[] => {
if (!hasValidAdminSearch || !adminSearchData) {
return [];
}
return adminSearchData.groups.map((group) => {
const isCapped = group.results.length >= ADMIN_SEARCH_RESULTS_CAP;
const buildListPath = ADMIN_GROUP_LIST_PATHS[group.type];
const items: PromptItem[] = group.results.map((result) => ({
id: `admin-${group.type}-${result.value}`,
label: result.label,
sublabel: result.sublabel,
path: result.path,
icon: ADMIN_GROUP_ICONS[group.type],
initials: group.type === 'user' || group.type === 'recipient' ? extractInitials(result.label) : undefined,
}));
// Capped groups link to the full admin list page with the search
// prefilled so the cap is never a dead end.
if (isCapped && buildListPath) {
items.push({
id: `admin-${group.type}-view-all`,
label: msg`View all results`,
path: buildListPath(query),
icon: ArrowRightIcon,
});
}
return {
id: `admin-${group.type}`,
label: ADMIN_GROUP_LABELS[group.type],
items,
count: group.results.length,
chipCount: group.results.length,
isCapped,
isGlobal: true,
};
});
}, [hasValidAdminSearch, adminSearchData, query]);
return {
isUserAdmin,
categories,
isFetching,
isError,
};
};
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -30,7 +29,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
};
const ZUpdatePasskeySchema = z.object({
name: ZNameSchema,
name: z.string(),
});
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
@@ -8,7 +8,6 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -31,7 +30,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
@@ -43,6 +42,7 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,32 +268,54 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<SettingsHeader
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<div className="mt-6 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-sm">
<Trans>Organisation usage</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
capacityUsage={{
members: organisation.members.length,
teams: organisation.teams.length,
}}
/>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<DetailsCard label={<Trans>Members</Trans>}>
<DetailsValue>
{organisation.members.length} /{' '}
{organisation.organisationClaim.memberCount === 0
? t`Unlimited`
: organisation.organisationClaim.memberCount}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Teams</Trans>}>
<DetailsValue>
{organisation.teams.length} /{' '}
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
</DetailsValue>
</DetailsCard>
</div>
<div className="mt-4">
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
/>
</div>
</div>
<div className="mt-6 rounded-lg border p-4">
<Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<p className="font-semibold text-base">
<p className="font-medium text-sm">
<Trans>Global Settings</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<p className="mt-1 font-normal text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans>
</p>
</div>
@@ -313,15 +335,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16"
/>
<Alert
className={cn(
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
)}
variant="neutral"
>
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Subscription</Trans>
@@ -329,12 +343,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2">
{organisation.subscription ? (
<span className="flex items-center gap-2">
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
)}
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
</span>
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
) : (
<span>
<Trans>No subscription found</Trans>
@@ -347,7 +356,6 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<Button
variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
@@ -358,7 +366,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" className="bg-background" asChild>
<Button variant="outline" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -375,13 +383,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline" className="bg-background">
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" className="bg-background" asChild>
<Button variant="outline" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -398,27 +406,21 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10">
<div>
<h3 className="font-semibold text-base">
<label className="font-medium text-sm leading-none">
<Trans>Organisation Members</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
</label>
<div className="mt-3">
<div className="my-2">
<DataTable columns={organisationMembersColumns} data={organisation.members} />
</div>
</div>
<div>
<h3 className="font-semibold text-base">
<label className="font-medium text-sm leading-none">
<Trans>Organisation Teams</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
</label>
<div className="mt-3">
<div className="my-2">
<DataTable columns={teamsColumns} data={organisation.teams} />
</div>
</div>
@@ -646,7 +648,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
@@ -679,15 +681,10 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent>
</Tooltip>
</FormLabel>
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
{field.value ? (
<span className="font-mono text-foreground">{field.value}</span>
) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -718,113 +715,108 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<h3 className="font-semibold text-base">
<FormLabel>
<Trans>Feature Flags</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
</FormLabel>
<div className="mt-3 space-y-2 rounded-md border p-4">
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
@@ -287,11 +287,7 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
</div>
</AccordionContent>
</AccordionItem>
@@ -1,6 +1,7 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -48,29 +49,26 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
let uploadedBrandingLogo: string | undefined;
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateOrganisationBrandingLogo(formData);
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
const result = await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -3,7 +3,6 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -29,6 +28,7 @@ import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { z } from 'zod';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import {
@@ -36,6 +36,7 @@ import {
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
@@ -112,7 +113,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}
const ZUpdateOrganisationGroupFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1, msg`Name is required`.id),
organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()),
});
@@ -1,5 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -37,7 +38,6 @@ export default function TeamsSettingsPage() {
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
@@ -48,23 +48,22 @@ export default function TeamsSettingsPage() {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
let uploadedBrandingLogo: string | undefined;
formData.append('payload', JSON.stringify({ teamId: team.id }));
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateTeamBrandingLogo(formData);
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
const result = await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
@@ -1,18 +1,14 @@
import { trpc } from '@documenso/trpc/react';
import type { TGetApiTokensResponse } from '@documenso/trpc/server/api-token-router/get-api-tokens.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { useMemo } from 'react';
import { DateTime } from 'luxon';
import { TokenCreateDialog } from '~/components/dialogs/token-create-dialog';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -22,88 +18,33 @@ export function meta() {
}
export default function ApiTokensPage() {
const { t, i18n } = useLingui();
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const team = useOptionalCurrentTeam();
const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN;
const {
data: tokens,
isLoading,
isError,
} = trpc.apiToken.getMany.useQuery(undefined, {
enabled: !isUnauthorized,
});
const columns = useMemo(() => {
return [
{
header: t`Name`,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span>,
},
{
header: t`Created`,
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: t`Expires`,
cell: ({ row }) => {
if (!row.original.expires) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
if (row.original.expires < new Date()) {
return (
<Badge variant="destructive" size="small">
<Trans>Expired</Trans>
</Badge>
);
}
return i18n.date(row.original.expires);
},
},
{
header: t`Actions`,
cell: ({ row }) => (
<TokenDeleteDialog token={row.original}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
),
},
] satisfies DataTableColumnDef<TGetApiTokensResponse[number]>[];
}, []);
return (
<div>
<SettingsHeader
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
Create and manage API tokens. See our{' '}
On this page, you can create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
rel="noopener"
>
documentation
Documentation
</a>{' '}
for more information.
</Trans>
}
>
{!isUnauthorized && <TokenCreateDialog />}
</SettingsHeader>
/>
{isUnauthorized ? (
{team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? (
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
<div>
<AlertTitle>
@@ -115,43 +56,58 @@ export default function ApiTokensPage() {
</div>
</Alert>
) : (
<DataTable
columns={columns}
data={tokens ?? []}
perPage={0}
currentPage={0}
totalPages={0}
error={{
enable: isError,
}}
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
<Trans>You have no API tokens yet. Your tokens will be shown here once you create them.</Trans>
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mt-8 mb-4" />
<h4 className="font-medium text-xl">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="mt-2 text-muted-foreground text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
/>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="mt-2 text-muted-foreground text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
@@ -82,7 +82,7 @@ export default function WaitingForTurnToSignPage({ loaderData }: Route.Component
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md text-center">
<h2 className="font-bold text-3xl tracking-tight">
<h2 className="font-bold text-3xl tracking-tigh">
<Trans>Waiting for Your Turn</Trans>
</h2>
+6 -6
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
@@ -100,11 +100,11 @@
"esbuild": "^0.27.0",
"remix-flat-routes": "^0.8.5",
"rollup": "^4.53.3",
"tsx": "^4.23.1",
"tsx": "^4.20.6",
"typescript": "5.6.2",
"vite": "^7.2.4",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.15.0"
"version": "2.14.0"
}
+28 -1
View File
@@ -1,8 +1,9 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
@@ -11,11 +12,14 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -57,6 +61,29 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,6 +13,27 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
+1
View File
@@ -105,6 +105,7 @@ app.route('/api/auth', auth);
// Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute);
// AI route.
+715
View File
@@ -0,0 +1,715 @@
%PDF-1.7
%âãÏÓ
1 0 obj
<<
/Type /Pages
/Kids [4 0 R 5 0 R]
/Count 2
>>
endobj
2 0 obj
<<
/Type /Catalog
/Pages 1 0 R
/AcroForm 6 0 R
>>
endobj
3 0 obj
<<
/Title (Untitled)
/Author (Unknown)
/Creator (@libpdf/core)
/Producer (@libpdf/core)
/CreationDate (D:20260521033345Z)
/ModDate (D:20260521033345Z)
>>
endobj
4 0 obj
<<
/Type /Page
/MediaBox [0 0 612 792]
/Resources <<
>>
/Parent 1 0 R
/Annots [8 0 R 11 0 R 14 0 R]
>>
endobj
5 0 obj
<<
/Type /Page
/MediaBox [0 0 612 792]
/Resources <<
>>
/Parent 1 0 R
/Annots [18 0 R 21 0 R 24 0 R 27 0 R 31 0 R 34 0 R 37 0 R]
>>
endobj
6 0 obj
<<
/Fields [7 0 R 10 0 R 13 0 R 17 0 R 20 0 R 30 0 R 33 0 R 36 0 R]
/DR <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
/DA (/Helv 0 Tf 0 g)
/NeedAppearances false
/SigFlags 3
>>
endobj
7 0 obj
<<
/FT /Tx
/T (CustomerName)
/Kids [8 0 R]
/DA (0 g)
>>
endobj
8 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 620 280 644]
/P 4 0 R
/Parent 7 0 R
/F 4
/AP <<
/N 9 0 R
>>
>>
endobj
9 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 198 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
10 0 obj
<<
/FT /Tx
/T (signed_date)
/Kids [11 0 R]
/DA (0 g)
>>
endobj
11 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 560 280 584]
/P 4 0 R
/Parent 10 0 R
/F 4
/AP <<
/N 12 0 R
>>
>>
endobj
12 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 198 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
13 0 obj
<<
/FT /Btn
/T (accept_terms)
/Kids [14 0 R]
/V /Off
>>
endobj
14 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 500 98 518]
/P 4 0 R
/Parent 13 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Yes 15 0 R
/Off 16 0 R
>>
>>
>>
endobj
15 0 obj
<<
/Length 47/Type /XObject
/Subtype /Form
/BBox [0 0 18 18]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 12.6 Tf
0 g
2.7 4.59 Td
(4) Tj
ET
Q
endstream
endobj
16 0 obj
<<
/Length 0/Type /XObject
/Subtype /Form
/BBox [0 0 18 18]
/Resources <<
>>
>>
stream
endstream
endobj
17 0 obj
<<
/FT /Ch
/T (country)
/Ff 131072
/Kids [18 0 R]
/Opt [(USA) (Canada) (Germany)]
/DA (0 g)
/V (USA)
/DV (USA)
>>
endobj
18 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 700 280 724]
/P 5 0 R
/Parent 17 0 R
/F 4
/AP <<
/N 19 0 R
>>
>>
endobj
19 0 obj
<<
/Length 76/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 178 22 re
W
n
BT
/Helv 14 Tf
0 g
2 9.872 Td
(USA) Tj
ET
Q
EMC
endstream
endobj
20 0 obj
<<
/FT /Btn
/T (payment_method)
/Ff 32768
/Kids [21 0 R 24 0 R 27 0 R]
/V /PayPal
/DV /PayPal
/Opt [(Credit Card) (PayPal) (Bank Transfer)]
>>
endobj
21 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 640 96 656]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Credit#20Card 22 0 R
/Off 23 0 R
>>
>>
>>
endobj
22 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
23 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
24 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 615 96 631]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /PayPal
/AP <<
/N <<
/PayPal 25 0 R
/Off 26 0 R
>>
>>
>>
endobj
25 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
26 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
27 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 590 96 606]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Bank#20Transfer 28 0 R
/Off 29 0 R
>>
>>
>>
endobj
28 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
29 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
30 0 obj
<<
/FT /Tx
/T (initials)
/Kids [31 0 R]
/DA (0 g)
>>
endobj
31 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 540 140 564]
/P 5 0 R
/Parent 30 0 R
/F 4
/AP <<
/N 32 0 R
>>
>>
endobj
32 0 obj
<<
/Length 72/Type /XObject
/Subtype /Form
/BBox [0 0 60 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 58 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
33 0 obj
<<
/FT /Tx
/T (contact_email)
/Kids [34 0 R]
/DA (0 g)
>>
endobj
34 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [160 540 380 564]
/P 5 0 R
/Parent 33 0 R
/F 4
/AP <<
/N 35 0 R
>>
>>
endobj
35 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 220 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 218 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
36 0 obj
<<
/FT /Tx
/T (item_qty)
/Kids [37 0 R]
/DA (0 g)
/MaxLen 4
>>
endobj
37 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [400 540 460 564]
/P 5 0 R
/Parent 36 0 R
/F 4
/AP <<
/N 38 0 R
>>
>>
endobj
38 0 obj
<<
/Length 72/Type /XObject
/Subtype /Form
/BBox [0 0 60 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 58 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
xref
0 39
0000000000 65535 f
0000000015 00000 n
0000000078 00000 n
0000000143 00000 n
0000000312 00000 n
0000000430 00000 n
0000000577 00000 n
0000000866 00000 n
0000000937 00000 n
0000001058 00000 n
0000001319 00000 n
0000001391 00000 n
0000001515 00000 n
0000001777 00000 n
0000001849 00000 n
0000002004 00000 n
0000002242 00000 n
0000002356 00000 n
0000002486 00000 n
0000002610 00000 n
0000002875 00000 n
0000003034 00000 n
0000003199 00000 n
0000003436 00000 n
0000003711 00000 n
0000003872 00000 n
0000004109 00000 n
0000004384 00000 n
0000004551 00000 n
0000004788 00000 n
0000005063 00000 n
0000005132 00000 n
0000005256 00000 n
0000005516 00000 n
0000005590 00000 n
0000005715 00000 n
0000005977 00000 n
0000006056 00000 n
0000006181 00000 n
trailer
<<
/Size 39
/Root 2 0 R
/Info 3 0 R
/ID [<4798EA6ACCB2CE19827C36CD841F759A> <4798EA6ACCB2CE19827C36CD841F759A>]
>>
startxref
6441
%%EOF
+2 -2
View File
@@ -19,7 +19,7 @@ WORKDIR /app
COPY . .
RUN npm install -g "turbo@^2.10.0"
RUN npm install -g "turbo@^1.9.3"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@@ -79,7 +79,7 @@ COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
RUN npm install -g "turbo@^2.10.0"
RUN npm install -g "turbo@^1.9.3"
RUN turbo run build --filter=@documenso/remix...
+2015 -4838
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.15.0",
"version": "2.14.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -61,13 +61,12 @@
"@ts-rest/serverless": "^3.52.1",
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.27.0",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^9.0.0",
"nodemailer": "^8.0.5",
"pdfjs-dist": "5.4.296",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
@@ -79,7 +78,7 @@
"rimraf": "^6.1.2",
"superjson": "^2.2.5",
"syncpack": "^14.0.0-alpha.27",
"turbo": "^2.10.0",
"turbo": "^1.13.4",
"vite": "^7.2.4",
"vite-plugin-static-copy": "^3.1.4",
"zod-openapi": "^4.2.4",
@@ -105,7 +104,6 @@
"overrides": {
"lodash": "4.18.1",
"pdfjs-dist": "5.4.296",
"postcss": "^8.5.19",
"typescript": "5.6.2",
"zod": "$zod",
"fumadocs-mdx": {
@@ -1,439 +0,0 @@
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../fixtures/authentication';
import { openCommandMenu } from '../fixtures/command-menu';
test.describe.configure({ mode: 'parallel' });
const nanoid = customAlphabet('1234567890abcdef', 10);
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetUser.id));
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
// The category chips include the admin groups with their result counts.
await expect(page.getByRole('button', { name: /Global Users/ })).toBeVisible();
const userOption = page.getByRole('option').filter({ hasText: targetUser.email }).first();
// Admin results are real links so they support native link behaviour such
// as opening in a new tab.
await expect(userOption.getByRole('link')).toHaveAttribute('href', `/admin/users/${targetUser.id}`);
await userOption.click();
await page.waitForURL(`/admin/users/${targetUser.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified team result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { team: targetTeam } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetTeam.id));
await expect(page.getByText('Global Teams', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: targetTeam.url }).first().click();
await page.waitForURL(`/admin/teams/${targetTeam.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: text query shows document result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: document.secondaryId }).first().click();
await page.waitForURL(`/admin/documents/${document.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: envelope_ prefixed query resolves exact document', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.id);
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: document.title }).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: admin search requires more than 3 characters unless numeric', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
// A 3 character non-numeric query must not trigger the admin search. The
// personal document search fires for any non-empty query, so its response
// is the synchronization anchor proving the debounced queries have fired.
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
await input.fill('abc');
await documentSearchResponse;
await expect(page.getByText(/^Global /)).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
// A numeric query fires regardless of length.
const adminSearchRequest = page.waitForRequest((request) => request.url().includes('admin.search'));
await input.fill('7');
await adminSearchRequest;
});
test('[ADMIN][GLOBAL_SEARCH]: search bar position stays fixed while searching', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
const initialY = (await input.boundingBox())?.y;
expect(initialY).toBeGreaterThan(0);
// The height of the prompt may change as results come and go, but the
// search bar must never move.
await input.fill(String(targetUser.id));
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
const resultsY = (await input.boundingBox())?.y;
expect(resultsY).toBe(initialY);
// The search bar must not move when there are no results at all.
await input.fill('zzzz-no-such-thing-9x7q');
await expect(page.getByText('No results for')).toBeVisible();
const emptyY = (await input.boundingBox())?.y;
expect(emptyY).toBe(initialY);
});
test('[ADMIN][GLOBAL_SEARCH]: default view shows the document page links outside a team context', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
// Admin pages have no current team, the page links must still show.
await page.goto('/admin/stats');
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await expect(page.getByRole('option').filter({ hasText: 'All documents' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'All templates' })).toBeVisible();
// Chips only show for categories with actual results, not for the
// hardcoded page links.
await expect(page.getByRole('button', { name: /^Documents/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Templates/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Settings/ })).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: theme can be changed from the prompt', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
// The sub page has a contextual placeholder and a back option.
await expect(page.getByPlaceholder('Search themes…')).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Back' }).first()).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
await page.getByRole('option').filter({ hasText: 'Dark Mode' }).first().click();
await expect(page.locator('html')).toHaveClass(/dark/);
// The back option returns to the root view.
await page.getByRole('option').filter({ hasText: 'Back' }).first().click();
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: capped admin groups offer a view all link', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const namePrefix = `viewall-${nanoid()}`;
// Seed enough users sharing a name prefix to hit the 5 result cap.
for (let i = 0; i < 5; i++) {
await seedUser({ name: `${namePrefix}-${i}` });
}
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(namePrefix);
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
'href',
`/admin/users?search=${encodeURIComponent(namePrefix)}`,
);
});
test('[ADMIN][GLOBAL_SEARCH]: first result is highlighted after every search', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: firstUser } = await seedUser();
const { user: secondUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
// First search selects the first result.
await input.fill(String(firstUser.id));
await expect(page.getByRole('option').filter({ hasText: firstUser.email }).first()).toBeVisible();
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
// A subsequent search with entirely new results must select the first
// result again.
await input.fill(String(secondUser.id));
await expect(page.getByRole('option').filter({ hasText: secondUser.email }).first()).toBeVisible();
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
});
test('[ADMIN][GLOBAL_SEARCH]: static items match fuzzy queries', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
// "setg" is a non-contiguous abbreviation of "Settings".
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('setg');
// Wait for the debounced filter to apply first, "Draft documents" can
// never match "setg" under either matching strategy.
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toHaveCount(0);
await expect(page.getByRole('option').filter({ hasText: 'Settings' }).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: page scrollbar is hidden while the prompt is open', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await expect
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
.toBe('hidden');
await page.keyboard.press('Escape');
await expect
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
.toBe('visible');
});
test('[ADMIN][GLOBAL_SEARCH]: non-admin gets the prompt without the admin search', async ({ page }) => {
const { user, team } = await seedUser({ isAdmin: false });
const document = await seedPendingDocument(user, team.id, []);
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: user.email });
// Non-admins get the same prompt with a non-admin placeholder.
await openCommandMenu(page, 'Type a command or search...');
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER)).toHaveCount(0);
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
// Wait for the regular (non-admin) search to resolve so we know the
// debounced queries have fired.
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
await expect(page.getByText(/^Global /)).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: typing on a sub page fires no search requests', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const searchRequests: string[] = [];
page.on('request', (request) => {
if (/api\/trpc\/(document|template|admin)\.search/.test(request.url())) {
searchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
const input = page.getByPlaceholder('Search themes…');
await expect(input).toBeVisible();
// Long enough to pass the admin search threshold if it were enabled.
await input.fill('dark');
// The client-side filter applying proves the typing registered.
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Light Mode' })).toHaveCount(0);
// Wait out the 200ms search debounce with a wide margin before asserting
// that no requests fired: there is no response to anchor on when the
// desired behaviour is "no requests at all".
await page.waitForTimeout(750);
expect(searchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: failed searches show an error state instead of no results', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await page.route(/api\/trpc\/(document|template|admin)\.search/, async (route) => {
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('zzzz-no-such-thing-9x7q');
// A failed search must be honest about it, not claim there are no results.
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByText('No results for')).toHaveCount(0);
});
test('[ADMIN][GLOBAL_SEARCH]: partial search failure still shows results with a notice', async ({ page }) => {
const { user: adminUser, team } = await seedUser({ isAdmin: true });
const document = await seedPendingDocument(adminUser, team.id, [], {
createDocumentOptions: { title: `partial-fail-${nanoid()}` },
});
// Only the admin search fails: the personal searches succeed.
await page.route(/api\/trpc\/admin\.search/, async (route) => {
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
// The successful personal document search must still render its results.
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
// The failed admin search must be flagged rather than silently dropped.
await expect(page.getByText('Some searches failed')).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without erroring', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
// The admin search endpoint rejects queries longer than 100 characters, so
// the client must not send them. The personal searches accept up to 1024
// characters and still run, anchoring the debounced query flush.
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('a'.repeat(150));
await documentSearchResponse;
// The personal searches ran and found nothing: the honest empty state, with
// no error in sight.
await expect(page.getByText('No results for')).toBeVisible();
await expect(page.getByText('Something went wrong')).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
});
@@ -1,249 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../../../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
type AdminSearchGroup = {
type: string;
results: Array<{ label: string; sublabel?: string; path: string; value: string }>;
};
const callAdminSearch = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.search?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
groups: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json.groups as AdminSearchGroup[])
: null,
};
};
const findGroup = (groups: AdminSearchGroup[] | null, type: string) =>
(groups ?? []).find((group) => group.type === type);
// ─── Access control ──────────────────────────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: unauthenticated request is rejected with 401', async ({ page }) => {
const { res } = await callAdminSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('[ADMIN][TRPC][SEARCH]: non-admin authenticated user is rejected with 401', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
await apiSignin({ page, email: nonAdminUser.email });
const { res } = await callAdminSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
// ─── Numeric queries: verified ID lookups ────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified user and team rows', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser, team: targetTeam } = await seedUser();
await apiSignin({ page, email: adminUser.email });
// Search by user ID.
const userSearch = await callAdminSearch(page, String(targetUser.id));
expect(userSearch.res.ok()).toBeTruthy();
const userGroup = findGroup(userSearch.groups, 'user');
expect(userGroup).toBeDefined();
expect(userGroup?.results).toHaveLength(1);
expect(userGroup?.results[0].path).toBe(`/admin/users/${targetUser.id}`);
expect(userGroup?.results[0].sublabel).toContain(targetUser.email);
// The cmdk `value` contract: value must contain the raw query.
expect(userGroup?.results[0].value).toContain(String(targetUser.id));
// Search by team ID.
const teamSearch = await callAdminSearch(page, String(targetTeam.id));
expect(teamSearch.res.ok()).toBeTruthy();
const teamGroup = findGroup(teamSearch.groups, 'team');
expect(teamGroup).toBeDefined();
expect(teamGroup?.results).toHaveLength(1);
expect(teamGroup?.results[0].path).toBe(`/admin/teams/${targetTeam.id}`);
expect(teamGroup?.results[0].label).toBe(targetTeam.name);
});
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified document and recipient rows', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const { user: recipientUser } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [recipientUser]);
const legacyDocumentId = document.secondaryId.replace('document_', '');
const recipient = document.recipients[0];
await apiSignin({ page, email: adminUser.email });
// Search by legacy document ID (bare number).
const documentSearch = await callAdminSearch(page, legacyDocumentId);
expect(documentSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(documentSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results).toHaveLength(1);
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
expect(documentGroup?.results[0].label).toBe(document.title);
// Search by recipient ID: links to the parent document.
const recipientSearch = await callAdminSearch(page, String(recipient.id));
expect(recipientSearch.res.ok()).toBeTruthy();
const recipientGroup = findGroup(recipientSearch.groups, 'recipient');
expect(recipientGroup).toBeDefined();
expect(recipientGroup?.results).toHaveLength(1);
expect(recipientGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
expect(recipientGroup?.results[0].label).toBe(recipient.email);
expect(recipientGroup?.results[0].sublabel).toBe(`#${recipient.id} · ${recipient.name} · ${document.title}`);
// Search by the full document_<id> secondary ID: exercises the prefix branch.
const secondaryIdSearch = await callAdminSearch(page, document.secondaryId);
expect(secondaryIdSearch.res.ok()).toBeTruthy();
const secondaryIdGroup = findGroup(secondaryIdSearch.groups, 'document');
expect(secondaryIdGroup).toBeDefined();
expect(secondaryIdGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
});
test('[ADMIN][TRPC][SEARCH]: numeric query with no matches returns no groups', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, '999999999');
expect(res.ok()).toBeTruthy();
expect(groups).toEqual([]);
});
test('[ADMIN][TRPC][SEARCH]: oversized number does not error and falls back to text search', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
// treated as text (and must not 500).
const oversizedNumber = '99999999999999';
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `${oversizedNumber}-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, oversizedNumber);
expect(res.ok()).toBeTruthy();
const documentGroup = findGroup(groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
});
// ─── Prefixed ID queries: exact lookups ──────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: envelope_ and org_ prefixes resolve exact matches', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, organisation, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, []);
await apiSignin({ page, email: adminUser.email });
// envelope_<id> resolves the document.
const envelopeSearch = await callAdminSearch(page, document.id);
expect(envelopeSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(envelopeSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
// Only the document group is returned for a recognized prefix.
expect(envelopeSearch.groups).toHaveLength(1);
// org_<id> resolves the organisation.
const orgSearch = await callAdminSearch(page, organisation.id);
expect(orgSearch.res.ok()).toBeTruthy();
const orgGroup = findGroup(orgSearch.groups, 'organisation');
expect(orgGroup).toBeDefined();
expect(orgGroup?.results[0].path).toBe(`/admin/organisations/${organisation.id}`);
expect(orgGroup?.results[0].label).toBe(organisation.name);
// Only the organisation group is returned for a recognized prefix.
expect(orgSearch.groups).toHaveLength(1);
});
// ─── Free text queries ───────────────────────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: text query matches documents by title and users by email', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
// A unique title: the default seeded title is shared across the whole suite,
// and global search only returns the newest few matches.
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
// Search by document title.
const titleSearch = await callAdminSearch(page, document.title);
expect(titleSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(titleSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
// Search by user email (emails are unique nanoid-based, so this is specific).
const emailSearch = await callAdminSearch(page, sender.email);
expect(emailSearch.res.ok()).toBeTruthy();
const userGroup = findGroup(emailSearch.groups, 'user');
expect(userGroup).toBeDefined();
expect(userGroup?.results[0].path).toBe(`/admin/users/${sender.id}`);
});
test('[ADMIN][TRPC][SEARCH]: gibberish query returns no groups', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, 'zzzz-no-such-thing-9x7q');
expect(res.ok()).toBeTruthy();
expect(groups).toEqual([]);
});
@@ -44,6 +44,46 @@ test.describe('File upload endpoint authorization', () => {
expect(res.status()).toBe(401);
});
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -1,37 +0,0 @@
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
import { expect, test } from '@playwright/test';
import sharp from 'sharp';
const makePng = async (width = 1200, height = 1200) =>
sharp({
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
test.describe('optimiseBrandingLogo', () => {
test('re-encodes a valid image to a PNG buffer', async () => {
const input = await makePng();
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.format).toBe('png');
});
test('bounds the image to a maximum of 512px on its largest side', async () => {
const input = await makePng(2000, 1000);
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.width).toBeLessThanOrEqual(512);
expect(metadata.height).toBeLessThanOrEqual(512);
});
test('rejects input that is not a valid image', async () => {
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
});
});
@@ -1,225 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from './fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
const enableBrandingAndUpload = async (page: Page) => {
// Enable custom branding so the file input is no longer disabled.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
// Upload the logo file through the real multipart route.
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
};
/**
* POST a logo straight to the dedicated multipart tRPC route using the
* authenticated browser cookies. This bypasses the client-side form validation,
* which is the only way to exercise the server-side image validation /
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
*/
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
const multipart: Record<string, string | MultipartFile> = {
payload: JSON.stringify({ organisationId }),
};
if (file) {
multipart.brandingLogo = file;
}
return await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
};
/**
* Grant the organisation the custom-branding entitlement. The positive branding
* flows require it whenever billing is enabled; with billing disabled the gate is
* bypassed, so this keeps these tests valid in both modes.
*/
const grantCustomBranding = async (organisationClaimId: string) => {
await prisma.organisationClaim.update({
where: { id: organisationClaimId },
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
});
};
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
const parsed = JSON.parse(settings.brandingLogo);
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
// through the team relation.
const teamWithSettings = await prisma.team.findUniqueOrThrow({
where: { id: team.id },
include: { teamGlobalSettings: true },
});
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// Confirm the logo was stored before we clear it.
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
// Remove the logo and save again.
await page.getByRole('button', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
// Clearing the logo persists an empty string via the dedicated route.
await expect
.poll(async () => {
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
return updated.brandingLogo;
})
.toBe('');
});
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Positive control: a genuine PNG is accepted and stored. This also proves the
// direct multipart request shape matches what the route expects.
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(validResponse.ok()).toBeTruthy();
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(afterValid.brandingLogo).toBeTruthy();
// Bytes that pass the MIME/size allowlist but are not a real image must be
// rejected by the server (the `sharp` re-encode) without changing stored state.
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'fake.png',
mimeType: 'image/png',
buffer: Buffer.from('this is definitely not a valid png'),
});
expect(invalidResponse.ok()).toBeFalsy();
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
expect(invalidResponse.status()).toBeLessThan(500);
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
// The previously stored, valid logo is left untouched by the rejected upload.
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
});
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
// The entitlement is only enforced when billing is enabled; with billing off
// the check is intentionally skipped server-side, so this can't be exercised.
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
'Entitlement is only enforced when billing is enabled.',
);
// Seeded organisations have no `allowCustomBranding` claim flag.
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
const response = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(response.ok()).toBeFalsy();
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeFalsy();
});
@@ -3,9 +3,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { openCommandMenu } from '../fixtures/command-menu';
const COMMAND_MENU_PLACEHOLDER = 'Type a command or search...';
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
const { user, team } = await seedUser();
@@ -17,9 +14,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
email: user.email,
});
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
await page.keyboard.press('Meta+K');
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
@@ -33,9 +30,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
email: recipient.email,
});
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
await page.keyboard.press('Meta+K');
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
@@ -49,8 +46,8 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
email: user.email,
});
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
await page.keyboard.press('Meta+K');
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(recipient.email);
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
@@ -1,199 +0,0 @@
import { prisma } from '@documenso/prisma';
import { expect, type Page, test } from '@playwright/test';
import {
clickAddSignerButton,
clickEnvelopeEditorStep,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
setRecipientEmail,
setRecipientName,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
/**
* Reproduction for the recipient autosave race condition.
*
* Symptom (production only, where there is real network lag):
* 1. The author adds a recipient and types its name/email.
* 2. They navigate to the "Add Fields" step.
* 3. The recipient selector shows the default "Recipient 1" placeholder
* instead of the recipient they just typed, and the typed name/email is
* silently lost.
*
* Theory (see packages/lib/client-only/hooks/use-envelope-autosave.ts):
* When the author navigates, `flushAutosave()` is awaited before the Add
* Fields page renders. If an *earlier* (empty) recipient save is still
* in-flight at that moment, `flush()` awaits that in-flight save and returns
* WITHOUT committing the newer typed data sitting in `lastArgsRef` (whose
* debounce timer it just cleared). The typed data is dropped, the empty
* recipient persists, and the selector renders "Recipient 1".
*
* This only happens when a save is still in-flight at navigation time, which is
* why it never reproduces locally (fast saves) but does on a laggy network.
*
* The test below simulates that lag by holding the first `envelope.recipient.set`
* request open. It asserts the CORRECT behaviour (typed recipient survives), so
* it is RED while the bug exists and GREEN once the autosave hook is fixed.
*/
const RECIPIENT_SET_PROCEDURE = 'envelope.recipient.set';
// How long to hold the first recipient autosave "in-flight" to emulate prod lag.
const SIMULATED_NETWORK_LAG_MS = 5000;
const FIRST_RECIPIENT = {
name: 'Alice Author',
email: 'alice-autosave-race@example.com',
};
const SECOND_RECIPIENT = {
name: 'Bob Builder',
email: 'bob-autosave-race@example.com',
};
type RecipientSetLagHandle = {
/** Resolves the instant the first recipient.set request is in-flight on the client. */
firstRecipientSetInFlight: Promise<void>;
/** Raw request bodies of every recipient.set call we intercepted. */
recipientSetRequestBodies: string[];
};
/**
* Installs a fake "production network lag" on the recipient autosave mutation.
*
* Only the FIRST recipient.set request is held open for `lagMs` (this is the save
* that must still be in-flight at navigation time for the race to occur). It
* resolves `firstRecipientSetInFlight` the instant it is intercepted so the test
* can keep typing while that save is pending. Subsequent recipient.set requests
* (e.g. the follow-up save the fixed hook issues) are forwarded immediately so the
* test does not pay the lag twice.
*/
const installRecipientSetLag = async (page: Page, lagMs: number): Promise<RecipientSetLagHandle> => {
let markFirstInFlight: () => void = () => {};
const firstRecipientSetInFlight = new Promise<void>((resolve) => {
markFirstInFlight = resolve;
});
const recipientSetRequestBodies: string[] = [];
await page.route('**/api/trpc/**', async (route) => {
const request = route.request();
if (request.method() !== 'POST' || !request.url().includes(RECIPIENT_SET_PROCEDURE)) {
await route.continue();
return;
}
const callIndex = recipientSetRequestBodies.length + 1;
recipientSetRequestBodies.push(request.postData() ?? '');
if (callIndex === 1) {
// eslint-disable-next-line no-console
console.log(`[test] holding first ${RECIPIENT_SET_PROCEDURE} for ${lagMs}ms (simulated network lag)`);
// The empty save is now in-flight from the client's perspective.
markFirstInFlight();
await new Promise((resolve) => setTimeout(resolve, lagMs));
} else {
// eslint-disable-next-line no-console
console.log(`[test] forwarding ${RECIPIENT_SET_PROCEDURE} #${callIndex} (no lag)`);
}
await route.continue();
});
return { firstRecipientSetInFlight, recipientSetRequestBodies };
};
const assertEnvelopeRecipientsPersisted = async (surface: TEnvelopeEditorSurface) => {
if (!surface.envelopeId) {
throw new Error('Expected the document editor surface to have an envelopeId');
}
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: surface.envelopeId },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
const persistedEmails = envelope.recipients.map((recipient) => recipient.email).filter(Boolean);
// eslint-disable-next-line no-console
console.log(
'[test] persisted recipients:',
JSON.stringify(
envelope.recipients.map((recipient) => ({ name: recipient.name, email: recipient.email })),
null,
2,
),
);
expect(persistedEmails).toContain(FIRST_RECIPIENT.email);
expect(persistedEmails).toContain(SECOND_RECIPIENT.email);
};
test.describe('envelope editor recipient autosave race (network lag)', () => {
test('document editor: typed recipient survives navigation to Add Fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { firstRecipientSetInFlight, recipientSetRequestBodies } = await installRecipientSetLag(
page,
SIMULATED_NETWORK_LAG_MS,
);
// 1. Add a second signer row. A blank document already has one empty default
// signer, so this schedules an autosave of TWO empty recipients
// (name='' / email='') - this is the save that will be in-flight.
await clickAddSignerButton(surface.root);
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
// 2. Wait until that empty autosave is actually in-flight on the client. This
// is the precondition the bug needs: a slow save holding the autosave lock.
await firstRecipientSetInFlight;
// 3. The author now fills in the recipients they are adding.
await setRecipientName(surface.root, 0, FIRST_RECIPIENT.name);
await setRecipientEmail(surface.root, 0, FIRST_RECIPIENT.email);
await setRecipientName(surface.root, 1, SECOND_RECIPIENT.name);
await setRecipientEmail(surface.root, 1, SECOND_RECIPIENT.email);
// 4. Immediately navigate to Add Fields (before the typed data's debounce
// fires). flushAutosave() awaits the in-flight EMPTY save; with the bug
// present it returns without ever committing the typed data.
await clickEnvelopeEditorStep(surface.root, 'addFields');
// 5. Wait for the Add Fields page to render (after the lagged flush resolves).
await expect(surface.root.getByText('Selected Recipient')).toBeVisible({
timeout: SIMULATED_NETWORK_LAG_MS + 15000,
});
// Diagnostics - the request bodies show what actually reached the server.
// Buggy: only the first (empty) save is ever sent. Fixed: a follow-up save
// carrying the typed recipients is sent too.
// eslint-disable-next-line no-console
console.log('\n===== AUTOSAVE RACE DIAGNOSTICS =====');
// eslint-disable-next-line no-console
console.log(`recipient.set requests sent to server: ${recipientSetRequestBodies.length}`);
// eslint-disable-next-line no-console
console.log(
`server ever received "${FIRST_RECIPIENT.email}": ${recipientSetRequestBodies.some((body) => body.includes(FIRST_RECIPIENT.email))}`,
);
// eslint-disable-next-line no-console
console.log('=====================================\n');
// 6. THE USER-VISIBLE BUG: the selected recipient must be the one we typed
// (Alice), not the default "Recipient 1" placeholder.
const selectedRecipientSection = surface.root.locator('section').filter({ hasText: 'Selected Recipient' });
await expect(selectedRecipientSection.getByRole('combobox')).toContainText(FIRST_RECIPIENT.name);
// 7. THE DATA LOSS: the typed recipients must actually be persisted.
await assertEnvelopeRecipientsPersisted(surface);
});
});
@@ -1,18 +0,0 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
/**
* Opens the app command menu via the keyboard shortcut.
*
* Retries the shortcut until the menu appears since the keypress is a no-op
* when it happens before the page has hydrated.
*
* @param placeholder The search input placeholder to wait for, which differs
* between admin and non-admin users.
*/
export const openCommandMenu = async (page: Page, placeholder: string) => {
await expect(async () => {
await page.keyboard.press('Meta+K');
await expect(page.getByPlaceholder(placeholder).first()).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
};
@@ -0,0 +1,368 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { PDF, PdfString } from '@libpdf/core';
import { type APIRequestContext, expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const ACROFORM_FIXTURE = fs.readFileSync(path.join(__dirname, '../../../../assets/acroform-import-test.pdf'));
const ACROFORM_DOCUMENT_PAYLOAD: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document',
recipients: [
{
email: 'signer@example.com',
name: 'Signer',
role: RecipientRole.SIGNER,
},
],
};
const API_REQUEST_METADATA: ApiRequestMetadata = {
requestMetadata: {},
source: 'apiV1',
auth: 'api',
};
type TestUser = Awaited<ReturnType<typeof seedUser>>['user'];
type TestTeam = Awaited<ReturnType<typeof seedUser>>['team'];
const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser; team: TestTeam }> => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
return { token, user, team };
};
const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const form = pdfDoc.getForm();
return (form?.fieldCount ?? 0) > 0;
};
const createSignedSignatureAcroFormPdf = (): Promise<Uint8Array> => {
const pdf = PDF.create();
const page = pdf.addPage({ size: 'letter' });
const form = pdf.getOrCreateForm();
const textField = form.createTextField('full_name');
const signatureField = form.createSignatureField('signed_signature');
page.drawField(textField, { x: 100, y: 700, width: 200, height: 24 });
signatureField.getDict().set('V', PdfString.fromString('fake-signature'));
return pdf.save();
};
const uploadAcroFormEnvelope = async ({
request,
token,
payload = ACROFORM_DOCUMENT_PAYLOAD,
file = ACROFORM_FIXTURE,
fileName = 'acroform-import-test.pdf',
}: {
request: APIRequestContext;
token: string;
payload?: TCreateEnvelopePayload;
file?: Uint8Array;
fileName?: string;
}): Promise<TCreateEnvelopeResponse> => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([file], fileName, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const importAcroFormFieldsWithSession = ({
page,
teamId,
envelopeId,
}: {
page: Page;
teamId: number;
envelopeId: string;
}) =>
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.importFromPdf`, {
headers: {
'content-type': 'application/json',
'x-team-id': String(teamId),
},
data: JSON.stringify({ json: { envelopeId } }),
});
const loadEnvelopeForImport = async (envelopeId: string) =>
prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
test.describe.configure({
mode: 'parallel',
});
test.describe('AcroForm Import', () => {
test('upload does not create fields and preserves widgets in the stored PDF', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(envelope.fields).toHaveLength(0);
const pdfBuffer = await getFileServerSide(envelope.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('replacement preserves widgets in the stored PDF for later import', async ({ request }) => {
const { token, user } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await loadEnvelopeForImport(response.id);
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
await UNSAFE_replaceEnvelopeItemPdf({
envelope,
recipients: envelope.recipients,
envelopeItemId: envelope.envelopeItems[0].id,
oldDocumentDataId,
data: {
title: 'Replacement AcroForm document',
file: new File([ACROFORM_FIXTURE], 'replacement-acroform.pdf', { type: 'application/pdf' }),
},
user,
apiRequestMetadata: API_REQUEST_METADATA,
});
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(after.fields).toHaveLength(0);
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('import creates fields assigned to the signer, flattens the PDF, and emits audit logs', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await loadEnvelopeForImport(response.id);
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
const result = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(result.fieldsCreated).toBeGreaterThan(0);
expect(result.itemsProcessed).toBe(1);
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
fields: true,
},
});
expect(after.fields.length).toBeGreaterThanOrEqual(8);
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
for (const field of after.fields) {
const meta = field.fieldMeta as { source?: string } | null;
expect(meta?.source).toBe('acroform');
}
const auditEntries = await prisma.documentAuditLog.findMany({
where: { envelopeId: after.id, type: 'FIELD_CREATED' },
});
expect(auditEntries.length).toBe(after.fields.length);
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
const flattenedPdf = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(flattenedPdf)).toBe(false);
const oldRecord = await prisma.documentData.findUnique({ where: { id: oldDocumentDataId } });
expect(oldRecord).toBeNull();
});
test('import creates a placeholder Recipient 1 SIGNER when no recipients exist', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document without recipients',
},
});
const envelope = await loadEnvelopeForImport(response.id);
expect(envelope.recipients).toHaveLength(0);
await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: { recipients: true, fields: true },
});
expect(after.recipients).toHaveLength(1);
expect(after.recipients[0].email).toBe('recipient.1@documenso.com');
expect(after.recipients[0].role).toBe(RecipientRole.SIGNER);
expect(after.fields.length).toBeGreaterThanOrEqual(8);
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
});
test('import endpoint rejects template envelopes without mutating stored widgets', async ({ page, request }) => {
const { token, user, team } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.TEMPLATE,
title: 'AcroForm template',
},
});
await apiSignin({ page, email: user.email });
const res = await importAcroFormFieldsWithSession({
page,
teamId: team.id,
envelopeId: response.id,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(after.fields).toHaveLength(0);
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('import does not duplicate fields when signed signatures prevent flattening', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const signedPdf = await createSignedSignatureAcroFormPdf();
const response = await uploadAcroFormEnvelope({
request,
token,
file: signedPdf,
fileName: 'signed-acroform.pdf',
});
const envelope = await loadEnvelopeForImport(response.id);
const firstResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(firstResult.fieldsCreated).toBeGreaterThan(0);
expect(firstResult.itemsProcessed).toBe(1);
expect(firstResult.signedSignatureCount).toBe(1);
const afterFirst = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
const firstFieldCount = afterFirst.fields.length;
const preservedPdf = await getFileServerSide(afterFirst.envelopeItems[0].documentData);
expect(await pdfHasFormFields(preservedPdf)).toBe(true);
const secondEnvelope = await loadEnvelopeForImport(response.id);
const secondResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope: secondEnvelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(secondResult.fieldsCreated).toBe(0);
expect(secondResult.itemsProcessed).toBe(0);
const afterSecond = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: { fields: true },
});
expect(afterSecond.fields).toHaveLength(firstFieldCount);
});
});
@@ -93,7 +93,7 @@ test.describe('Form Flattening', () => {
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
test.describe('Envelope Creation (DOCUMENT type)', () => {
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
test('should preserve form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -136,16 +136,16 @@ test.describe('Form Flattening', () => {
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
);
});
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
test('should preserve form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -157,7 +157,6 @@ test.describe('Form Flattening', () => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document without Form Values',
// No formValues - but form should still be flattened for DOCUMENT type
};
const formData = new FormData();
@@ -184,13 +183,10 @@ test.describe('Form Flattening', () => {
},
});
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
});
@@ -747,11 +743,10 @@ test.describe('Form Flattening', () => {
},
});
// Form should still be flattened for DOCUMENT type
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('should handle partial formValues (only some fields)', async ({ request }) => {
@@ -798,11 +793,11 @@ test.describe('Form Flattening', () => {
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
});
// Form should still be flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe('Only this field');
});
});
});
@@ -142,38 +142,3 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
});
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
page,
}) => {
const { user, team, organisation } = await seedUser();
await enableOrganisationBranding({
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
});
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
// Branding enabled → the custom logo is rendered on the signing page.
await page.goto(`/sign/${recipients[0].token}`);
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
// Disable branding while keeping the stored logo (the team inherits this).
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { brandingEnabled: false },
});
// Branding disabled → the custom logo is gone and the Documenso fallback
// (an internal link to "/") is shown instead.
await page.goto(`/sign/${recipients[0].token}`);
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
await expect(page.locator('a[href="/"]').first()).toBeVisible();
});
+1 -1
View File
@@ -18,7 +18,7 @@
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"tsx": "^4.23.1",
"tsx": "^4.20.6",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0"
},
+1 -1
View File
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
+3 -3
View File
@@ -17,7 +17,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/nodemailer-resend": "5.0.0",
"@documenso/nodemailer-resend": "4.0.0",
"@documenso/tailwind-config": "*",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
@@ -38,12 +38,12 @@
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"nodemailer": "^9.0.0",
"nodemailer": "^8.0.5",
"react-email": "^5.0.6",
"resend": "^6.5.2"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@types/nodemailer": "^8.0.1"
"@types/nodemailer": "^8.0.0"
}
}
@@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-base text-primary-foreground no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sbase no-underline"
href={signDocumentLink}
>
{match(role)
@@ -3,8 +3,6 @@ import type { SentMessageInfo, Transport } from 'nodemailer';
import type { Address } from 'nodemailer/lib/mailer';
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
import { normalizeMailHeaders } from './normalize-headers';
const VERSION = '1.0.0';
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
@@ -56,7 +54,6 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
const [from] = this.toMailChannelsAddresses(mail.data.from);
const [replyTo] = this.toMailChannelsAddresses(mail.data.replyTo);
if (!from) {
return callback(new Error('Missing required field "from"'), null);
@@ -75,8 +72,6 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
headers: requestHeaders,
body: JSON.stringify({
from: from,
reply_to: replyTo,
headers: normalizeMailHeaders(mail.data.headers),
subject: mail.data.subject,
personalizations: [
{
@@ -1,55 +0,0 @@
import type Mail from 'nodemailer/lib/mailer';
/**
* Normalizes nodemailer mail headers into the flat `Record<string, string>`
* shape accepted by HTTP email APIs such as Resend and MailChannels.
*
* Kept in sync with `toResendHeaders` in the `@documenso/nodemailer-resend`
* package, which applies the same normalization for the Resend transport.
*/
export const normalizeMailHeaders = (headers: Mail.Options['headers']): Record<string, string> | undefined => {
if (!headers) {
return undefined;
}
const normalized: Record<string, string> = {};
const appendHeader = (key: string, value: unknown) => {
if (value === null || value === undefined) {
return;
}
const stringValue = String(value);
normalized[key] = normalized[key] ? `${normalized[key]}, ${stringValue}` : stringValue;
};
if (Array.isArray(headers)) {
for (const { key, value } of headers) {
appendHeader(key, value);
}
} else {
for (const [key, value] of Object.entries(headers)) {
if (Array.isArray(value)) {
for (const item of value) {
appendHeader(key, item);
}
continue;
}
if (typeof value === 'object' && value !== null) {
appendHeader(key, value.value);
continue;
}
appendHeader(key, value);
}
}
if (Object.keys(normalized).length === 0) {
return undefined;
}
return normalized;
};
@@ -1,100 +1,84 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Debounced autosave for the envelope editor (recipients, fields, settings).
*
* Only one save runs at a time and the latest edit always wins. If the user
* keeps editing while a save is on the wire, their newest changes get saved
* right after, never dropped.
*/
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// The edit waiting to be saved. Wrapped in an object so null always means "nothing queued".
const pendingRef = useRef<{ value: T } | null>(null);
// The save currently running, if any. Shared so we never kick off two at once.
const commitPromiseRef = useRef<Promise<void> | null>(null);
// saveFn closes over editor state, so keep the latest one around without
// making triggerSave/flush depend on it.
const saveFnRef = useRef(saveFn);
saveFnRef.current = saveFn;
const lastArgsRef = useRef<T | null>(null);
const pendingPromiseRef = useRef<Promise<void> | null>(null);
const [isPending, setIsPending] = useState(false);
const [isCommiting, setIsCommiting] = useState(false);
/**
* Runs saves one at a time until the queue is empty. Anything queued
* mid-save gets picked up on the next loop.
*/
const commit = useCallback((): Promise<void> => {
if (commitPromiseRef.current) {
return commitPromiseRef.current;
}
if (!pendingRef.current) {
return Promise.resolve();
}
const pump = (async () => {
try {
setIsCommiting(true);
while (pendingRef.current) {
const { value } = pendingRef.current;
pendingRef.current = null;
await saveFnRef.current(value);
}
} finally {
// eslint-disable-next-line require-atomic-updates
commitPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
})();
commitPromiseRef.current = pump;
return pump;
}, []);
const triggerSave = useCallback(
(data: T) => {
pendingRef.current = { value: data };
lastArgsRef.current = data;
// A debounce or promise means something is pending
setIsPending(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
timeoutRef.current = setTimeout(async () => {
if (!lastArgsRef.current) {
return;
}
const args = lastArgsRef.current;
lastArgsRef.current = null;
timeoutRef.current = null;
void commit();
setIsCommiting(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}, delay);
},
[commit, delay],
[saveFn, delay],
);
/**
* Skip the debounce and save now. The editor calls this when it needs
* everything persisted, e.g. before sending or switching steps.
*/
const flush = useCallback(async () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
await commit();
}, [commit]);
if (pendingPromiseRef.current) {
// Already running → wait for it
await pendingPromiseRef.current;
return;
}
if (lastArgsRef.current) {
const args = lastArgsRef.current;
lastArgsRef.current = null;
setIsCommiting(true);
setIsPending(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}
}, [saveFn]);
// Last-ditch attempt to save if the tab closes with unsaved edits.
useEffect(() => {
const handleBeforeUnload = () => {
if (timeoutRef.current || pendingRef.current || commitPromiseRef.current) {
if (timeoutRef.current || pendingPromiseRef.current) {
void flush();
}
};
@@ -43,6 +43,7 @@ type EnvelopeRenderItem = {
title: string;
order: number;
envelopeId: string;
documentDataId: string;
/**
* The PDF data to render.
+15
View File
@@ -1,10 +1,25 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const SALT_ROUNDS = 12;
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering.
*/
export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.max(255, { message: 'Name cannot be more than 255 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso',
GOOGLE: 'Google',
-10
View File
@@ -9,13 +9,3 @@
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
/**
* Branding logo upload constraints. Enforced server-side at the TRPC request
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
*/
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
+2 -2
View File
@@ -60,8 +60,8 @@
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"postcss": "^8.5.19",
"postcss-selector-parser": "^7.1.4",
"postcss": "^8.5.14",
"postcss-selector-parser": "^7.1.1",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
@@ -1,372 +0,0 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
const MAX_POSTGRES_INT = 2147483647;
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number];
export type AdminGlobalSearchResult = {
label: string;
sublabel?: string;
path: string;
value: string;
};
export type AdminGlobalSearchGroup = {
type: AdminGlobalSearchResultType;
results: AdminGlobalSearchResult[];
};
export type AdminGlobalSearchOptions = {
query: string;
};
type PartialResults = Partial<Record<AdminGlobalSearchResultType, AdminGlobalSearchResult[]>>;
export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise<AdminGlobalSearchGroup[]> => {
const trimmedQuery = query.trim();
if (trimmedQuery.length === 0) {
return [];
}
const resultsByType = await resolveSearch(trimmedQuery);
return GROUP_ORDER.map((type) => ({
type,
results: (resultsByType[type] ?? []).map((result) => ({
...result,
// Append the raw query so cmdk's client-side filter never hides
// server-verified results.
value: `${result.value} ${trimmedQuery}`,
})),
})).filter((group) => group.results.length > 0);
};
const resolveSearch = async (query: string): Promise<PartialResults> => {
// Recognized ID prefixes resolve to a single exact lookup.
if (query.startsWith('envelope_')) {
return { document: await findDocumentsByExactId({ id: query }) };
}
if (query.startsWith('document_')) {
return { document: await findDocumentsByExactId({ secondaryId: query }) };
}
if (query.startsWith('org_')) {
return { organisation: await findOrganisationsByIdOrUrl(query) };
}
// Bare numbers are treated as verified ID lookups only. Oversized numbers
// fall through to text search.
const numericId = Number(query);
if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) {
const [document, user, team, recipient, subscription] = await Promise.all([
findDocumentsByExactId({ secondaryId: `document_${numericId}` }),
findUsersById(numericId),
findTeamsById(numericId),
findRecipientsById(numericId),
findSubscriptionsById(numericId),
]);
return { document, user, team, recipient, subscription };
}
// Free text searches all resource types in parallel.
const [document, user, organisation, team, recipient, subscription] = await Promise.all([
findDocumentsByText(query),
findUsersByText(query),
findOrganisationsByText(query),
findTeamsByText(query),
findRecipientsByText(query),
findSubscriptionsByText(query),
]);
return {
document,
user,
organisation,
team,
recipient,
subscription,
};
};
const joinSublabel = (parts: Array<string | null | undefined>) =>
parts.filter((part) => part && part.length > 0).join(' · ') || undefined;
// ─── Documents ────────────────────────────────────────────────────────────────
const documentSelect = {
id: true,
title: true,
secondaryId: true,
user: { select: { email: true } },
} as const;
type DocumentRow = {
id: string;
title: string;
secondaryId: string;
user: { email: string };
};
const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({
label: envelope.title,
sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]),
path: `/admin/documents/${envelope.id}`,
value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`,
});
const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => {
const envelope = await prisma.envelope.findFirst({
where: { ...where, type: EnvelopeType.DOCUMENT },
select: documentSelect,
});
return envelope ? [mapDocument(envelope)] : [];
};
const findDocumentsByText = async (query: string) => {
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
title: { contains: query, mode: 'insensitive' },
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: documentSelect,
});
return envelopes.map(mapDocument);
};
// ─── Users ────────────────────────────────────────────────────────────────────
const userSelect = {
id: true,
name: true,
email: true,
} as const;
type UserRow = { id: number; name: string | null; email: string };
const mapUser = (user: UserRow): AdminGlobalSearchResult => ({
label: user.name || user.email,
sublabel: joinSublabel([`#${user.id}`, user.email]),
path: `/admin/users/${user.id}`,
value: `user ${user.id} ${user.name ?? ''} ${user.email}`,
});
const findUsersById = async (id: number) => {
const user = await prisma.user.findFirst({
where: { id },
select: userSelect,
});
return user ? [mapUser(user)] : [];
};
const findUsersByText = async (query: string) => {
const users = await prisma.user.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }],
},
orderBy: { id: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: userSelect,
});
return users.map(mapUser);
};
// ─── Organisations ────────────────────────────────────────────────────────────
const organisationSelect = {
id: true,
name: true,
owner: { select: { email: true } },
} as const;
type OrganisationRow = { id: string; name: string; owner: { email: string } };
const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({
label: organisation.name,
sublabel: joinSublabel([organisation.id, organisation.owner.email]),
path: `/admin/organisations/${organisation.id}`,
value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`,
});
const findOrganisationsByIdOrUrl = async (query: string) => {
const organisations = await prisma.organisation.findMany({
where: {
OR: [{ id: query }, { url: query }],
},
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: organisationSelect,
});
return organisations.map(mapOrganisation);
};
const findOrganisationsByText = async (query: string) => {
const organisations = await prisma.organisation.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ url: { contains: query, mode: 'insensitive' } },
{ customerId: { contains: query, mode: 'insensitive' } },
{ owner: { email: { contains: query, mode: 'insensitive' } } },
],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: organisationSelect,
});
return organisations.map(mapOrganisation);
};
// ─── Teams ────────────────────────────────────────────────────────────────────
const teamSelect = {
id: true,
name: true,
url: true,
organisation: { select: { name: true } },
} as const;
type TeamRow = { id: number; name: string; url: string; organisation: { name: string } };
const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({
label: team.name,
sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]),
path: `/admin/teams/${team.id}`,
value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`,
});
const findTeamsById = async (id: number) => {
const team = await prisma.team.findFirst({
where: { id },
select: teamSelect,
});
return team ? [mapTeam(team)] : [];
};
const findTeamsByText = async (query: string) => {
const teams = await prisma.team.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: teamSelect,
});
return teams.map(mapTeam);
};
// ─── Recipients ───────────────────────────────────────────────────────────────
const recipientSelect = {
id: true,
name: true,
email: true,
envelope: { select: { id: true, title: true } },
} as const;
type RecipientRow = {
id: number;
name: string;
email: string;
envelope: { id: string; title: string };
};
const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({
label: recipient.email,
sublabel: joinSublabel([`#${recipient.id}`, recipient.name, recipient.envelope.title]),
path: `/admin/documents/${recipient.envelope.id}`,
value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`,
});
const findRecipientsById = async (id: number) => {
const recipient = await prisma.recipient.findFirst({
where: {
id,
envelope: { type: EnvelopeType.DOCUMENT },
},
select: recipientSelect,
});
return recipient ? [mapRecipient(recipient)] : [];
};
const findRecipientsByText = async (query: string) => {
const recipients = await prisma.recipient.findMany({
where: {
envelope: { type: EnvelopeType.DOCUMENT },
OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }],
},
orderBy: { id: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: recipientSelect,
});
return recipients.map(mapRecipient);
};
// ─── Subscriptions ────────────────────────────────────────────────────────────
const subscriptionSelect = {
id: true,
status: true,
planId: true,
customerId: true,
organisationId: true,
} as const;
type SubscriptionRow = {
id: number;
status: string;
planId: string;
customerId: string;
organisationId: string;
};
const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({
label: `Subscription #${subscription.id}`,
sublabel: joinSublabel([subscription.status, subscription.planId]),
path: `/admin/organisations/${subscription.organisationId}`,
value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`,
});
const findSubscriptionsById = async (id: number) => {
const subscription = await prisma.subscription.findFirst({
where: { id },
select: subscriptionSelect,
});
return subscription ? [mapSubscription(subscription)] : [];
};
const findSubscriptionsByText = async (query: string) => {
const subscriptions = await prisma.subscription.findMany({
where: {
OR: [
{ planId: { contains: query, mode: 'insensitive' } },
{ customerId: { contains: query, mode: 'insensitive' } },
],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: subscriptionSelect,
});
return subscriptions.map(mapSubscription);
};
@@ -1,26 +0,0 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { putFileServerSide } from '../../universal/upload/put-file.server';
import { optimiseBrandingLogo } from '../../utils/images/logo';
/**
* Validate, sanitise and store an uploaded branding logo. Returns the
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
* column (the same format the serving endpoints already expect).
*/
export const buildBrandingLogoData = async (file: File): Promise<string> => {
const buffer = Buffer.from(await file.arrayBuffer());
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'The branding logo must be a valid image file.',
});
});
const documentData = await putFileServerSide({
name: 'branding-logo.png',
type: 'image/png',
arrayBuffer: async () => Promise.resolve(optimised),
});
return JSON.stringify(documentData);
};
@@ -54,7 +54,7 @@ export const UNSAFE_createEnvelopeItems = async ({
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
flattenForm: false,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -0,0 +1,349 @@
import { prisma } from '@documenso/prisma';
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { nanoid } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { logger } from '../../utils/logger';
import {
type AcroFormExtractionResult,
type AcroFormSkipReason,
convertAcroFormFieldsToFieldInputs,
extractAcroFormFieldsFromPDF,
} from '../pdf/acroform-fields';
import { normalizePdf } from '../pdf/normalize-pdf';
type UnsafeImportAcroFormFieldsOptions = {
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'> & {
envelopeItems: (Pick<EnvelopeItem, 'id' | 'title' | 'documentDataId'> & {
documentData: DocumentData;
})[];
recipients: Recipient[];
};
apiRequestMetadata: ApiRequestMetadata;
};
type PerItemSkip = {
envelopeItemId: string;
envelopeItemTitle: string;
reason: AcroFormSkipReason;
};
export type ImportAcroFormFieldsResult = {
itemsProcessed: number;
fieldsCreated: number;
unsupportedCount: number;
signedSignatureCount: number;
skippedItems: PerItemSkip[];
fields: Field[];
};
type PreparedItem = {
envelopeItemId: string;
envelopeItemTitle: string;
oldDocumentDataId: string;
extraction: AcroFormExtractionResult;
newDocumentData?: DocumentData;
};
export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
envelope,
apiRequestMetadata,
}: UnsafeImportAcroFormFieldsOptions): Promise<ImportAcroFormFieldsResult> => {
if (envelope.type !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only supported for document envelopes',
});
}
const prepared: PreparedItem[] = await Promise.all(
envelope.envelopeItems.map(async (item): Promise<PreparedItem> => {
const buffer = await getFileServerSide(item.documentData);
const extraction = await extractAcroFormFieldsFromPDF(Buffer.from(buffer), {
formValuesProvided: Boolean(envelope.formValues),
});
if (extraction.skipReason) {
logger.info(
{
event: 'acroform-import.skip',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
reason: extraction.skipReason,
},
'AcroForm extraction skipped',
);
}
if (extraction.unsupported.length > 0) {
const byReason: Record<string, number> = {};
for (const entry of extraction.unsupported) {
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
}
logger.info(
{
event: 'acroform-import.unsupported',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
count: extraction.unsupported.length,
byReason,
},
'AcroForm import skipped unsupported widgets',
);
}
if (extraction.hasSignedSignature) {
logger.warn(
{
event: 'acroform-import.signed-pdf-no-flatten',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
'Signed AcroForm signature detected — skipping flatten to preserve signature',
);
}
const base: PreparedItem = {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
oldDocumentDataId: item.documentDataId,
extraction,
};
if (extraction.fields.length === 0 || extraction.hasSignedSignature) {
return base;
}
const flattened = await normalizePdf(Buffer.from(buffer), {
flattenForm: true,
});
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: item.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(flattened),
});
return {
...base,
newDocumentData,
};
}),
);
const totalFieldsToCreate = prepared.reduce((sum, p) => sum + p.extraction.fields.length, 0);
const unsupportedCount = prepared.reduce((sum, p) => sum + p.extraction.unsupported.length, 0);
const signedSignatureCount = prepared.filter((p) => p.extraction.hasSignedSignature).length;
const skippedItems: PerItemSkip[] = [];
for (const p of prepared) {
const reason = p.extraction.skipReason;
if (!reason) {
continue;
}
skippedItems.push({
envelopeItemId: p.envelopeItemId,
envelopeItemTitle: p.envelopeItemTitle,
reason,
});
}
if (totalFieldsToCreate === 0) {
return {
itemsProcessed: 0,
fieldsCreated: 0,
unsupportedCount,
signedSignatureCount,
skippedItems,
fields: [],
};
}
const { createdFields, importedItemsCount } = await prisma.$transaction(async (tx) => {
const pickFirstSignableRecipient = (recipients: Pick<Recipient, 'id' | 'email' | 'role' | 'signingOrder'>[]) => {
const signable = recipients.filter((r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER);
if (signable.length === 0) {
return null;
}
return signable.sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
})[0];
};
const signedItemIds = prepared
.filter((item) => item.extraction.hasSignedSignature && item.extraction.fields.length > 0)
.map((item) => item.envelopeItemId);
const alreadyImportedSignedItemIds = new Set<string>();
if (signedItemIds.length > 0) {
const existingImportedFields = await tx.field.findMany({
where: {
envelopeId: envelope.id,
envelopeItemId: {
in: signedItemIds,
},
},
select: {
envelopeItemId: true,
fieldMeta: true,
},
});
for (const field of existingImportedFields) {
const fieldMeta = field.fieldMeta;
if (
fieldMeta &&
typeof fieldMeta === 'object' &&
!Array.isArray(fieldMeta) &&
(fieldMeta as { source?: unknown }).source === 'acroform'
) {
alreadyImportedSignedItemIds.add(field.envelopeItemId);
}
}
}
const itemsToImport = prepared.filter((item) => {
if (item.extraction.fields.length === 0) {
return false;
}
return !(item.extraction.hasSignedSignature && alreadyImportedSignedItemIds.has(item.envelopeItemId));
});
const createdFields: Field[] = [];
if (itemsToImport.length === 0) {
return { createdFields, importedItemsCount: 0 };
}
let recipient = pickFirstSignableRecipient(
await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
}),
);
if (!recipient) {
const placeholderEmail = 'recipient.1@documenso.com';
recipient = await tx.recipient.create({
data: {
envelopeId: envelope.id,
email: placeholderEmail,
name: 'Recipient 1',
role: RecipientRole.SIGNER,
signingOrder: 1,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
select: { id: true, email: true, role: true, signingOrder: true },
});
}
let importedItemsCount = 0;
for (const item of itemsToImport) {
if (item.newDocumentData) {
await tx.envelopeItem.update({
where: { id: item.envelopeItemId },
data: { documentDataId: item.newDocumentData.id },
});
}
const fieldsToCreate = convertAcroFormFieldsToFieldInputs(
item.extraction.fields,
() => recipient,
item.envelopeItemId,
);
const itemCreatedFields = await tx.field.createManyAndReturn({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: item.envelopeItemId,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
createdFields.push(...itemCreatedFields);
importedItemsCount += 1;
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.createMany({
data: itemCreatedFields.map((createdField) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: apiRequestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: recipient.email,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
),
});
}
}
return { createdFields, importedItemsCount };
});
await Promise.all(
prepared
.filter((p) => p.newDocumentData !== undefined)
.map((p) =>
prisma.documentData.delete({ where: { id: p.oldDocumentDataId } }).catch((err) => {
logger.error(
{
event: 'acroform-import.delete-old-document-data-failed',
envelopeItemId: p.envelopeItemId,
oldDocumentDataId: p.oldDocumentDataId,
err,
},
'Failed to delete orphaned DocumentData after AcroForm import',
);
}),
),
);
return {
itemsProcessed: importedItemsCount,
fieldsCreated: createdFields.length,
unsupportedCount,
signedSignatureCount,
skippedItems,
fields: createdFields,
};
};
@@ -82,7 +82,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
flattenForm: false,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -0,0 +1,834 @@
import {
PDF,
type PDFPage,
type PdfDict,
type PdfObject,
type PdfRef,
type PdfStream,
type PdfString,
} from '@libpdf/core';
import { FieldType, type Recipient } from '@prisma/client';
import {
FIELD_CHECKBOX_META_DEFAULT_VALUES,
FIELD_DATE_META_DEFAULT_VALUES,
FIELD_DROPDOWN_META_DEFAULT_VALUES,
FIELD_EMAIL_META_DEFAULT_VALUES,
FIELD_INITIALS_META_DEFAULT_VALUES,
FIELD_NAME_META_DEFAULT_VALUES,
FIELD_NUMBER_META_DEFAULT_VALUES,
FIELD_RADIO_META_DEFAULT_VALUES,
FIELD_SIGNATURE_META_DEFAULT_VALUES,
FIELD_TEXT_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
type TDropdownFieldMeta,
type TFieldAndMeta,
type TNumberFieldMeta,
type TRadioFieldMeta,
type TTextFieldMeta,
ZEnvelopeFieldAndMetaSchema,
} from '../../types/field-meta';
import { logger } from '../../utils/logger';
import type { FieldToCreate } from './auto-place-fields';
/**
* Local shape for the widget annotations returned by @libpdf/core.
*
* The library exposes WidgetAnnotation as a class but does not re-export the
* type from its public surface. We duck-type the subset we actually read.
*/
type WidgetAnnotation = {
readonly rect: [number, number, number, number];
readonly width: number;
readonly height: number;
readonly pageRef: PdfRef | null;
isHidden(): boolean;
getOnValue(): string | null;
};
/**
* Function shape that follows a {@link PdfRef} to the referenced object.
*
* `@libpdf/core` does not re-export the `RefResolver` type alias, so we redeclare
* it locally. Built via {@link makeResolver} from a loaded {@link PDF}.
*/
type RefResolver = (ref: PdfRef) => PdfObject | null;
const makeResolver =
(pdfDoc: PDF): RefResolver =>
(ref: PdfRef) =>
pdfDoc.context.resolve(ref);
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
const MIN_HEIGHT_THRESHOLD = 0.01;
const DATE_NAME_PATTERN = /date|dob|birth/i;
const NUMBER_NAME_PATTERN = /amount|qty|count|number/i;
const EMAIL_NAME_PATTERN = /email|e[-_]?mail/i;
const NAME_NAME_PATTERN = /name/i;
const INITIALS_NAME_PATTERN = /initial/i;
const ROW_TOLERANCE_PERCENT = 2;
const ACROFORM_FIELD_SOURCE = 'acroform';
export type AcroFormUnsupportedReason =
| 'unsupported-type'
| 'hidden'
| 'off-page'
| 'zero-size'
| 'no-page-match'
| 'signed-signature'
| 'rotated-out-of-bounds';
export type AcroFormSkipReason = 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
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: AcroFormUnsupportedReason;
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/**
* True when a signed signature widget was found.
*
* Callers MUST set `flattenForm: false` for that envelope item so the signed
* PDF is not re-flattened (which would invalidate the signature).
*/
hasSignedSignature: boolean;
/**
* Set when extraction returned empty for a reason that should be surfaced in
* logs but not propagated to the user. Absent when extraction ran normally.
*/
skipReason?: AcroFormSkipReason;
};
type ResolvedGeometry = {
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
const EMPTY_RESULT = (skipReason?: AcroFormSkipReason): AcroFormExtractionResult => ({
fields: [],
unsupported: [],
hasSignedSignature: false,
skipReason,
});
/**
* Detect XFA-hybrid PDFs by inspecting the catalog's `/AcroForm` dict for an
* `/XFA` key.
*
* Uses public accessors (`pdf.context.catalog.getDict()`) and a {@link RefResolver}
* so an indirect `/AcroForm` entry is followed. Returns `false` on any error
* (e.g. malformed catalog) so the caller can fall through to normal extraction.
*/
const hasXfa = (pdfDoc: PDF, resolver: RefResolver): boolean => {
try {
const catalogDict = pdfDoc.context.catalog.getDict();
const acroFormDict = catalogDict.getDict('AcroForm', resolver);
return Boolean(acroFormDict?.has('XFA'));
} catch {
return false;
}
};
const isDateFieldByName = (name: string | null | undefined): boolean => !!name && DATE_NAME_PATTERN.test(name);
const isNumberFieldByName = (name: string | null | undefined): boolean => !!name && NUMBER_NAME_PATTERN.test(name);
const isEmailFieldByName = (name: string | null | undefined): boolean => !!name && EMAIL_NAME_PATTERN.test(name);
const isNameFieldByName = (name: string | null | undefined): boolean => !!name && NAME_NAME_PATTERN.test(name);
const isInitialsFieldByName = (name: string | null | undefined): boolean => !!name && INITIALS_NAME_PATTERN.test(name);
/**
* Detect AcroForm format actions on a text field dictionary.
*
* Adobe attaches a JavaScript format action via `/AA` → `/F` → `/JS`. The script
* body references `AFDate_FormatEx` or `AFNumber_Format` depending on the
* intended format. The action dict and its `/F` entry are frequently stored
* as indirect refs in real-world PDFs, so a {@link RefResolver} MUST be
* threaded through the lookups. We do a string-contains check on the script
* to avoid pulling in a JS parser.
*/
const getTextFieldFormatHint = (fieldDict: PdfDict, resolver: RefResolver): 'date' | 'number' | null => {
try {
const formatDict = fieldDict.getDict('AA', resolver)?.getDict('F', resolver);
if (!formatDict) {
return null;
}
const js = formatDict.get('JS', resolver);
if (!js || typeof js !== 'object') {
return null;
}
let script: string;
if (js.type === 'string') {
script = (js as PdfString).asString();
} else if (js.type === 'stream') {
script = new TextDecoder().decode((js as PdfStream).getDecodedData());
} else {
return null;
}
if (script.includes('AFDate_FormatEx') || script.includes('AFDate_Format')) {
return 'date';
}
if (script.includes('AFNumber_Format')) {
return 'number';
}
return null;
} catch {
return null;
}
};
type FormFieldWithDict = {
name: string;
partialName: string;
alternateName: string | null;
isReadOnly(): boolean;
isRequired(): boolean;
acroField(): PdfDict;
getWidgets(): WidgetAnnotation[];
};
type ResolvedTextDocumensoType =
| typeof FieldType.TEXT
| typeof FieldType.DATE
| typeof FieldType.NUMBER
| typeof FieldType.EMAIL
| typeof FieldType.NAME
| typeof FieldType.INITIALS;
const resolveTextSubtype = (
field: FormFieldWithDict,
resolver: RefResolver,
): {
documensoType: ResolvedTextDocumensoType;
} => {
const candidateNames = [field.partialName, field.alternateName];
const formatHint = getTextFieldFormatHint(field.acroField(), resolver);
// AcroForm format actions take precedence over name tokens — Adobe set them
// explicitly, so they're a stronger signal than a heuristic regex hit.
if (formatHint === 'date') {
return { documensoType: FieldType.DATE };
}
if (formatHint === 'number') {
return { documensoType: FieldType.NUMBER };
}
const maxLen = field.acroField().getNumber('MaxLen', resolver)?.value ?? Number.POSITIVE_INFINITY;
if (candidateNames.some(isDateFieldByName)) {
return { documensoType: FieldType.DATE };
}
if (maxLen <= 10 && candidateNames.some(isNumberFieldByName)) {
return { documensoType: FieldType.NUMBER };
}
if (candidateNames.some(isEmailFieldByName)) {
return { documensoType: FieldType.EMAIL };
}
if (candidateNames.some(isNameFieldByName)) {
return { documensoType: FieldType.NAME };
}
if (candidateNames.some(isInitialsFieldByName)) {
return { documensoType: FieldType.INITIALS };
}
return { documensoType: FieldType.TEXT };
};
const pickLabel = (field: FormFieldWithDict): string | undefined => {
// /TU is the human-facing tooltip/label; /T is the internal field identifier.
return field.alternateName || undefined;
};
type RotationDegrees = 0 | 90 | 180 | 270;
type RawRect = { x1: number; y1: number; x2: number; y2: number };
const getRectFromWidget = (widget: WidgetAnnotation): RawRect | null => {
const rect = widget.rect;
if (!rect || rect.length !== 4) {
return null;
}
const [x1, y1, x2, y2] = rect;
if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) {
return null;
}
return { x1, y1, x2, y2 };
};
const resolveGeometry = (
widget: WidgetAnnotation,
pageIndex: number,
page: PDFPage,
): { geometry: ResolvedGeometry | null; reason: AcroFormUnsupportedReason | null } => {
const rect = getRectFromWidget(widget);
if (!rect) {
return { geometry: null, reason: 'zero-size' };
}
const xL = Math.min(rect.x1, rect.x2);
const xR = Math.max(rect.x1, rect.x2);
const yB = Math.min(rect.y1, rect.y2);
const yT = Math.max(rect.y1, rect.y2);
if (xR - xL <= 0 || yT - yB <= 0) {
return { geometry: null, reason: 'zero-size' };
}
const mediaBox = page.getMediaBox();
const mediaW = mediaBox.width;
const mediaH = mediaBox.height;
const rotation = page.rotation as RotationDegrees;
// PDFPage.width / .height return rotation-adjusted dimensions, which is what
// we want for percent-based positioning relative to the rendered page.
const renderedW = page.width;
const renderedH = page.height;
let renderedX: number;
let renderedY: number;
let renderedFieldW: number;
let renderedFieldH: number;
if (rotation === 90) {
renderedX = yB;
renderedY = xL;
renderedFieldW = yT - yB;
renderedFieldH = xR - xL;
} else if (rotation === 180) {
renderedX = mediaW - xR;
renderedY = yB;
renderedFieldW = xR - xL;
renderedFieldH = yT - yB;
} else if (rotation === 270) {
renderedX = mediaH - yT;
renderedY = mediaW - xR;
renderedFieldW = yT - yB;
renderedFieldH = xR - xL;
} else {
renderedX = xL;
renderedY = mediaH - yT;
renderedFieldW = xR - xL;
renderedFieldH = yT - yB;
}
// Out-of-bounds: skip if the entire rect is outside the rendered page bounds.
const left = renderedX;
const right = renderedX + renderedFieldW;
const top = renderedY;
const bottom = renderedY + renderedFieldH;
if (right <= 0 || left >= renderedW || bottom <= 0 || top >= renderedH) {
return { geometry: null, reason: 'off-page' };
}
// Partial out-of-bounds: clamp.
const clampedLeft = Math.max(0, Math.min(left, renderedW));
const clampedRight = Math.max(0, Math.min(right, renderedW));
const clampedTop = Math.max(0, Math.min(top, renderedH));
const clampedBottom = Math.max(0, Math.min(bottom, renderedH));
const clampedW = clampedRight - clampedLeft;
const clampedH = clampedBottom - clampedTop;
if (clampedW <= 0 || clampedH <= 0) {
return { geometry: null, reason: 'off-page' };
}
return {
geometry: {
page: pageIndex + 1,
x: clampedLeft,
y: clampedTop,
width: clampedW,
height: clampedH,
pageWidth: renderedW,
pageHeight: renderedH,
},
reason: null,
};
};
const buildSignatureFieldAndMeta = (field: FormFieldWithDict): TFieldAndMeta => {
return ZEnvelopeFieldAndMetaSchema.parse({
type: FieldType.SIGNATURE,
fieldMeta: {
...FIELD_SIGNATURE_META_DEFAULT_VALUES,
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
},
});
};
const buildTextFieldAndMeta = (
field: FormFieldWithDict,
documensoType: ResolvedTextDocumensoType,
defaultText: string | undefined,
): TFieldAndMeta => {
const label = pickLabel(field);
const required = field.isRequired() || undefined;
const readOnly = field.isReadOnly() || undefined;
const defaultValue = defaultText && defaultText.length > 0 ? defaultText : undefined;
if (documensoType === FieldType.NUMBER) {
const fieldMeta: TNumberFieldMeta = {
...FIELD_NUMBER_META_DEFAULT_VALUES,
label: label ?? FIELD_NUMBER_META_DEFAULT_VALUES.label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
value: defaultValue,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
}
if (documensoType === FieldType.TEXT) {
const fieldMeta: TTextFieldMeta = {
...FIELD_TEXT_META_DEFAULT_VALUES,
label: label ?? FIELD_TEXT_META_DEFAULT_VALUES.label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
text: defaultValue ?? FIELD_TEXT_META_DEFAULT_VALUES.text,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
}
if (documensoType === FieldType.DATE) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_DATE_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
if (documensoType === FieldType.EMAIL) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_EMAIL_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
if (documensoType === FieldType.NAME) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_NAME_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_INITIALS_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
};
const buildCheckboxFieldAndMeta = (
field: FormFieldWithDict,
onValue: string | undefined,
isChecked: boolean,
): TFieldAndMeta => {
const required = field.isRequired();
const value = onValue && onValue.length > 0 ? onValue : 'Yes';
const fieldMeta: TCheckboxFieldMeta = {
...FIELD_CHECKBOX_META_DEFAULT_VALUES,
label: pickLabel(field) ?? FIELD_CHECKBOX_META_DEFAULT_VALUES.label,
required: required || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values: [{ id: 1, checked: isChecked, value }],
validationRule: required ? 'at-least' : '',
validationLength: required ? 1 : 0,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.CHECKBOX, fieldMeta });
};
const buildRadioFieldAndMeta = (
field: FormFieldWithDict,
options: string[],
selectedValue: string | null,
widgetOnValue: string | null,
): TFieldAndMeta => {
const values =
options.length > 0
? options.map((value, index) => ({
id: index + 1,
checked: selectedValue !== null && value === selectedValue,
value,
}))
: [
{
id: 1,
checked: widgetOnValue !== null && widgetOnValue === selectedValue,
value: widgetOnValue ?? '',
},
];
const fieldMeta: TRadioFieldMeta = {
...FIELD_RADIO_META_DEFAULT_VALUES,
label: pickLabel(field) ?? '',
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.RADIO, fieldMeta });
};
const buildDropdownFieldAndMeta = (
field: FormFieldWithDict,
options: string[],
defaultValue: string | undefined,
): TFieldAndMeta => {
const fieldMeta: TDropdownFieldMeta = {
...FIELD_DROPDOWN_META_DEFAULT_VALUES,
label: pickLabel(field) ?? '',
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values: options.length > 0 ? options.map((value) => ({ value })) : FIELD_DROPDOWN_META_DEFAULT_VALUES.values,
defaultValue: defaultValue ?? '',
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.DROPDOWN, fieldMeta });
};
type WidgetWithPage = { widget: WidgetAnnotation; pageIndex: number; page: PDFPage };
const resolveWidgetPages = (
widgets: WidgetAnnotation[],
pageByRef: Map<PdfRef, { index: number; page: PDFPage }>,
): {
matched: WidgetWithPage[];
unmatched: WidgetAnnotation[];
} => {
const matched: WidgetWithPage[] = [];
const unmatched: WidgetAnnotation[] = [];
for (const widget of widgets) {
const pageRef = widget.pageRef;
const resolved = pageRef ? pageByRef.get(pageRef) : null;
if (!resolved) {
unmatched.push(widget);
continue;
}
matched.push({ widget, pageIndex: resolved.index, page: resolved.page });
}
return { matched, unmatched };
};
export type ExtractAcroFormOptions = {
/**
* When true, `insertFormValuesInPdf` already ran for this buffer. The
* extractor will not copy AcroForm default values into `fieldMeta` to
* avoid duplicating values that are already baked into the flattened PDF.
*/
formValuesProvided?: boolean;
};
/**
* Extract AcroForm fields from a PDF and convert them to Documenso field
* imports.
*
* Runs before flattening so widget geometry is still present in the buffer.
* Returns an empty result for non-AcroForm PDFs, encrypted PDFs, pure XFA forms
* with no AcroForm widgets, and on any internal error (with `skipReason` set so
* callers can log).
*/
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
options: ExtractAcroFormOptions = {},
): Promise<AcroFormExtractionResult> => {
try {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
if (pdfDoc.isEncrypted) {
return EMPTY_RESULT('encrypted');
}
const resolver = makeResolver(pdfDoc);
const hasXfaForm = hasXfa(pdfDoc, resolver);
const form = pdfDoc.getForm();
if (!form) {
return EMPTY_RESULT(hasXfaForm ? 'xfa-hybrid' : 'no-form');
}
const formFields = form.getFields();
if (hasXfaForm && formFields.length === 0) {
return EMPTY_RESULT('xfa-hybrid');
}
const pages = pdfDoc.getPages();
const pageByRef = new Map<PdfRef, { index: number; page: PDFPage }>();
pages.forEach((page, index) => {
pageByRef.set(page.ref, { index, page });
});
const fields: AcroFormFieldImportInfo[] = [];
const unsupported: AcroFormUnsupportedFieldInfo[] = [];
let hasSignedSignature = false;
const usePdfDefaults = !options.formValuesProvided;
const addUnsupported = (fieldName: string, acroFormType: string, reason: AcroFormUnsupportedReason): void => {
unsupported.push({ fieldName, acroFormType, reason });
};
for (const field of formFields) {
const acroFormType = field.type;
if (
acroFormType === 'listbox' ||
acroFormType === 'button' ||
acroFormType === 'unknown' ||
acroFormType === 'non-terminal'
) {
addUnsupported(field.name, acroFormType, 'unsupported-type');
continue;
}
// Signed signature widgets are skipped entirely and the caller is asked
// to keep the form intact (no flatten) so the signature stays valid.
if (acroFormType === 'signature') {
type SignatureFieldDuck = FormFieldWithDict & { isSigned(): boolean };
const sigField = field as unknown as SignatureFieldDuck;
if (typeof sigField.isSigned === 'function' && sigField.isSigned()) {
hasSignedSignature = true;
addUnsupported(field.name, acroFormType, 'signed-signature');
continue;
}
}
const formField = field as unknown as FormFieldWithDict;
const widgets = formField.getWidgets();
const { matched, unmatched } = resolveWidgetPages(widgets, pageByRef);
for (let i = 0; i < unmatched.length; i += 1) {
addUnsupported(field.name, acroFormType, 'no-page-match');
}
let widgetCounter = 0;
for (const { widget, pageIndex, page } of matched) {
if (widget.isHidden()) {
addUnsupported(field.name, acroFormType, 'hidden');
continue;
}
const { geometry, reason } = resolveGeometry(widget, pageIndex, page);
if (!geometry) {
addUnsupported(field.name, acroFormType, reason ?? 'zero-size');
continue;
}
let fieldAndMeta: TFieldAndMeta;
if (acroFormType === 'signature') {
fieldAndMeta = buildSignatureFieldAndMeta(formField);
} else if (acroFormType === 'text') {
type TextFieldDuck = FormFieldWithDict & {
getValue(): string;
getDefaultValue(): string;
};
const textField = field as unknown as TextFieldDuck;
const { documensoType } = resolveTextSubtype(formField, resolver);
const defaultText = usePdfDefaults ? textField.getValue?.() || textField.getDefaultValue?.() || '' : '';
fieldAndMeta = buildTextFieldAndMeta(formField, documensoType, defaultText);
} else if (acroFormType === 'checkbox') {
type CheckboxFieldDuck = FormFieldWithDict & {
isChecked(): boolean;
getOnValue(): string;
};
const checkbox = field as unknown as CheckboxFieldDuck;
const onValue = widget.getOnValue() ?? checkbox.getOnValue?.();
const checked = usePdfDefaults ? (checkbox.isChecked?.() ?? false) : false;
fieldAndMeta = buildCheckboxFieldAndMeta(formField, onValue ?? undefined, checked);
} else if (acroFormType === 'radio') {
type RadioFieldDuck = FormFieldWithDict & {
getOptions(): string[];
getValue(): string | null;
};
const radio = field as unknown as RadioFieldDuck;
const selectedValue = usePdfDefaults ? (radio.getValue?.() ?? null) : null;
fieldAndMeta = buildRadioFieldAndMeta(
formField,
radio.getOptions?.() ?? [],
selectedValue,
widget.getOnValue(),
);
} else if (acroFormType === 'dropdown') {
type DropdownFieldDuck = FormFieldWithDict & {
getOptions(): Array<{ value: string; display: string }>;
getValue(): string;
getDefaultValue(): string;
};
const dropdown = field as unknown as DropdownFieldDuck;
const rawOptions = dropdown.getOptions?.() ?? [];
const optionValues = rawOptions.map((opt) => opt.value);
const currentSelection = usePdfDefaults ? dropdown.getValue?.() || dropdown.getDefaultValue?.() || '' : '';
fieldAndMeta = buildDropdownFieldAndMeta(formField, optionValues, currentSelection || undefined);
} else {
addUnsupported(field.name, acroFormType, 'unsupported-type');
continue;
}
fields.push({
source: ACROFORM_FIELD_SOURCE,
fieldName: field.name,
widgetIndex: widgetCounter,
fieldAndMeta,
page: geometry.page,
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
pageWidth: geometry.pageWidth,
pageHeight: geometry.pageHeight,
});
widgetCounter += 1;
}
}
return {
fields,
unsupported,
hasSignedSignature,
};
} catch (err) {
logger.error({ event: 'acroform-import.error', err }, 'AcroForm extraction threw');
return EMPTY_RESULT('error');
}
};
const sortFieldsForCreate = (fields: AcroFormFieldImportInfo[]): AcroFormFieldImportInfo[] => {
return [...fields].sort((a, b) => {
if (a.page !== b.page) {
return a.page - b.page;
}
const aRowPercent = (a.y / a.pageHeight) * 100;
const bRowPercent = (b.y / b.pageHeight) * 100;
if (Math.abs(aRowPercent - bRowPercent) > ROW_TOLERANCE_PERCENT) {
return aRowPercent - bRowPercent;
}
return a.x - b.x;
});
};
/**
* Convert pre-extracted AcroForm fields to field creation inputs.
*
* Pure data transform — converts points to percentages and resolves the
* recipient via the provided callback. No DB calls.
*/
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[] => {
return sortFieldsForCreate(fields).map((f) => {
const xPercent = (f.x / f.pageWidth) * 100;
const yPercent = (f.y / f.pageHeight) * 100;
const widthPercent = (f.width / f.pageWidth) * 100;
const heightPercent = (f.height / f.pageHeight) * 100;
const finalHeightPercent = heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
const recipient = recipientResolver(f.fieldName);
return {
...f.fieldAndMeta,
envelopeItemId,
recipientId: recipient.id,
page: f.page,
positionX: xPercent,
positionY: yPercent,
width: widthPercent,
height: finalHeightPercent,
};
});
};
@@ -1,4 +1,4 @@
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
@@ -22,6 +22,39 @@ type ComputeQuotaFlagsOptions = {
};
};
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
import { getQuotaWarningCount } from '../../universal/quota-usage';
export const QUOTA_WARNING_THRESHOLD = 0.8;
export type QuotaAlertKind = 'quota' | 'quotaNearing';
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = getQuotaWarningCount(quota);
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Platzhalter hinzufügen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "Rate-Limit hinzufügen"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "Jede Quelle"
msgid "Any Status"
msgstr "Jeder Status"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "API-Schlüssel"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "API-Rate-Limits"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "API-Anfragen"
@@ -2678,10 +2681,6 @@ msgstr "Unterzeichner kann nicht entfernt werden"
msgid "Cannot upload items after the document has been sent"
msgstr "Artikel können nicht hochgeladen werden, nachdem das Dokument versendet wurde."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "Dokumenteinstellungen"
msgid "Document preferences updated"
msgstr "Dokumentpräferenzen aktualisiert"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Dokumenten-Rate-Limits"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "Dokumentation"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "E-Mail-Präferenzen"
msgid "Email preferences updated"
msgstr "E-Mail-Präferenzen aktualisiert"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "E-Mail-Rate-Limits"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Empfänger per E-Mail benachrichtigen, wenn ein ausstehendes Dokument gelöscht wird"
@@ -5088,7 +5094,6 @@ msgstr "E-Mail-Verifizierung wurde entfernt"
msgid "Email verification has been resent"
msgstr "E-Mail-Verifizierung wurde erneut gesendet"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "E-Mails"
msgid "Embedding, 5 members included and more"
msgstr "Einbettung, 5 Mitglieder enthalten und mehr"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Leer = Unbegrenzt, 0 = Blockiert"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Leeres Feld"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Aktivieren"
@@ -5229,10 +5236,6 @@ msgstr "Stellen Sie sicher, dass Sie das Embedding-Token verwenden und nicht das
msgid "Enter"
msgstr "Eingeben"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Geben Sie einen Namen für Ihren neuen Ordner ein. Ordner helfen Ihnen, Ihre Dateien zu organisieren."
@@ -5241,10 +5244,6 @@ msgstr "Geben Sie einen Namen für Ihren neuen Ordner ein. Ordner helfen Ihnen,
msgid "Enter a new title"
msgstr "Neuen Titel eingeben"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Anspruchsname eingeben"
@@ -5503,10 +5502,6 @@ msgstr "Alle haben unterschrieben"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Alle haben unterschrieben! Sie erhalten eine Kopie des unterschriebenen Dokuments per E-Mail."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Zeitüberschreitung überschritten"
@@ -6770,10 +6765,6 @@ msgstr "Lichtmodus"
msgid "Like to have your own public profile with agreements?"
msgstr "Möchten Sie Ihr eigenes öffentliches Profil mit Vereinbarungen haben?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limits"
@@ -7079,10 +7070,6 @@ msgstr "MAU (angemeldet)"
msgid "Max"
msgstr "Max"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Maximale Dateigröße: 4MB. Maximal 100 Zeilen pro Upload. Leere Werte verwenden die Vorlagenstandards."
@@ -7129,12 +7116,12 @@ msgstr "Mitglied seit"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Monatlich aktive Benutzer: Benutzer, die mindestens eines ihrer Dokumente abgeschlossen haben"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "Monatliches API-Kontingent"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Monatliches Dokumentenkontingent"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Monatliches E-Mail-Kontingent"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "Name"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Name ist erforderlich"
@@ -7306,10 +7298,6 @@ msgstr "Name ist erforderlich"
msgid "Name Settings"
msgstr "Einstellungen für Namen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Müssen Dokumente signieren?"
@@ -7449,10 +7437,6 @@ msgstr "Es sind derzeit keine weiteren Maßnahmen Ihrerseits erforderlich."
msgid "No groups found"
msgstr "Keine Gruppen gefunden"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Keine Lizenz konfiguriert"
@@ -8169,10 +8153,6 @@ msgstr "Ausstehende Organisations­einladungen"
msgid "Pending since"
msgstr "Ausstehend seit"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "Bitte geben Sie einen aussagekräftigen Namen für Ihr Token ein. Dies w
msgid "Please enter a number"
msgstr "Bitte gib eine Zahl ein"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Bitte geben Sie einen gültigen Namen ein."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Bitte geben Sie eine gültige Nummer ein."
@@ -9073,10 +9057,6 @@ msgstr "Organisationsmitglied entfernen"
msgid "Remove Organisation Member"
msgstr "Organisationsmitglied entfernen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Empfänger entfernen"
@@ -9267,10 +9247,6 @@ msgstr "Zahlung klären"
msgid "Resolve payment"
msgstr "Zahlung klären"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Antwort"
@@ -9847,10 +9823,6 @@ msgstr "Senden..."
msgid "Sent"
msgstr "Gesendet"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sitzung widerrufen"
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "Team-URL"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "Teams"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Teams helfen Ihnen, Ihre Arbeit zu organisieren und mit anderen zusammenzuarbeiten. Erstellen Sie Ihr erstes Team, um loszulegen."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Teams, denen diese Organisationsgruppe derzeit zugewiesen ist"
@@ -12329,6 +12297,8 @@ msgstr "Unbekannter Name"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Unbegrenzt"
@@ -12621,18 +12591,15 @@ msgstr "Hochladen"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Nutzung für Zeitraum: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Verwenden"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "Whitelabeling, unbegrenzte Mitglieder und mehr"
msgid "Width:"
msgstr "Breite:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Zustimmung widerrufen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Schreiben Sie eine Beschreibung, die in Ihrem öffentlichen Profil angezeigt wird"
+43 -84
View File
@@ -1411,8 +1411,8 @@ msgid "Add Placeholders"
msgstr "Add Placeholders"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr "Add rate limit window"
msgid "Add rate limit"
msgstr "Add rate limit"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2003,7 +2003,6 @@ msgstr "Any Source"
msgid "Any Status"
msgstr "Any Status"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2013,6 +2012,10 @@ msgstr "API"
msgid "API key"
msgstr "API key"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "API rate limits"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "API requests"
@@ -2673,10 +2676,6 @@ msgstr "Cannot remove signer"
msgid "Cannot upload items after the document has been sent"
msgstr "Cannot upload items after the document has been sent"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr "Capabilities enabled for this organisation."
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4403,6 +4402,10 @@ msgstr "Document Preferences"
msgid "Document preferences updated"
msgstr "Document preferences updated"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Document rate limits"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4538,7 +4541,6 @@ msgstr "Documentation"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4988,6 +4990,10 @@ msgstr "Email Preferences"
msgid "Email preferences updated"
msgstr "Email preferences updated"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "Email rate limits"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Email recipients when a pending document is deleted"
@@ -5083,7 +5089,6 @@ msgstr "Email verification has been removed"
msgid "Email verification has been resent"
msgstr "Email verification has been resent"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5097,14 +5102,16 @@ msgstr "Emails"
msgid "Embedding, 5 members included and more"
msgstr "Embedding, 5 members included and more"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Empty = Unlimited, 0 = Blocked"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Empty field"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Enable"
@@ -5224,10 +5231,6 @@ msgstr "Ensure that you are using the embedding token, not the API token"
msgid "Enter"
msgstr "Enter"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr "Enter a max request count greater than 0"
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Enter a name for your new folder. Folders help you organise your items."
@@ -5236,10 +5239,6 @@ msgstr "Enter a name for your new folder. Folders help you organise your items."
msgid "Enter a new title"
msgstr "Enter a new title"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr "Enter a window, e.g. 5m"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Enter claim name"
@@ -5498,10 +5497,6 @@ msgstr "Everyone has signed"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Everyone has signed! You will receive an email copy of the signed document."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr "Exceeded"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Exceeded timeout"
@@ -6765,10 +6760,6 @@ msgstr "Light Mode"
msgid "Like to have your own public profile with agreements?"
msgstr "Like to have your own public profile with agreements?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr "Limit reached"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limits"
@@ -7074,10 +7065,6 @@ msgstr "MAU (signed in)"
msgid "Max"
msgstr "Max"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr "Max requests"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
@@ -7124,12 +7111,12 @@ msgstr "Member Since"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7198,12 +7185,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Monthly Active Users: Users that had at least one of their documents completed"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr "Monthly quota"
msgid "Monthly API quota"
msgstr "Monthly API quota"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "Monthly usage"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Monthly document quota"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Monthly email quota"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7294,6 +7285,7 @@ msgid "Name"
msgstr "Name"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Name is required"
@@ -7301,10 +7293,6 @@ msgstr "Name is required"
msgid "Name Settings"
msgstr "Name Settings"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr "Near limit"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Need to sign documents?"
@@ -7444,10 +7432,6 @@ msgstr "No further action is required from you at this time."
msgid "No groups found"
msgstr "No groups found"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr "No inherited claim"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "No License Configured"
@@ -8164,10 +8148,6 @@ msgstr "Pending Organisation Invites"
msgid "Pending since"
msgstr "Pending since"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr "People with access to this organisation."
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8330,6 +8310,10 @@ msgstr "Please enter a meaningful name for your token. This will help you identi
msgid "Please enter a number"
msgstr "Please enter a number"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Please enter a valid name."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Please enter a valid number"
@@ -9068,10 +9052,6 @@ msgstr "Remove organisation member"
msgid "Remove Organisation Member"
msgstr "Remove Organisation Member"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr "Remove rate limit"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Remove recipient"
@@ -9262,10 +9242,6 @@ msgstr "Resolve"
msgid "Resolve payment"
msgstr "Resolve payment"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr "Resource blocked"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Response"
@@ -9842,10 +9818,6 @@ msgstr "Sending..."
msgid "Sent"
msgstr "Sent"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr "Sent this period"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Session revoked"
@@ -10828,10 +10800,10 @@ msgid "Team URL"
msgstr "Team URL"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10842,10 +10814,6 @@ msgstr "Teams"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Teams help you organise your work and collaborate with others. Create your first team to get started."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr "Teams that belong to this organisation."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Teams that this organisation group is currently assigned to"
@@ -12324,6 +12292,8 @@ msgstr "Unknown name"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Unlimited"
@@ -12616,18 +12586,15 @@ msgstr "Uploading"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Usage for period: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Use"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr "Use a duration with a unit, e.g. 5m, 1h, or 24h"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr "Use a unique window for each rate limit"
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13529,18 +13496,10 @@ msgstr "Whitelabeling, unlimited members and more"
msgid "Width:"
msgstr "Width:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr "Window"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Withdrawing Consent"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr "Within limit"
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Write a description to display on your public profile"
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Agregar Marcadores de posición"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "Agregar límite de velocidad"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "Cualquier fuente"
msgid "Any Status"
msgstr "Cualquier estado"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "Clave API"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "Límites de velocidad de la API"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "Solicitudes de API"
@@ -2678,10 +2681,6 @@ msgstr "No se puede eliminar el firmante"
msgid "Cannot upload items after the document has been sent"
msgstr "No se pueden cargar elementos después de que el documento ha sido enviado"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "Preferencias del documento"
msgid "Document preferences updated"
msgstr "Preferencias del documento actualizadas"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Límites de velocidad de documentos"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "Documentación"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "Preferencias de correo electrónico"
msgid "Email preferences updated"
msgstr "Preferencias de correo electrónico actualizadas"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "Límites de velocidad de correo electrónico"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Enviar un correo electrónico a los destinatarios cuando se elimine un documento pendiente"
@@ -5088,7 +5094,6 @@ msgstr "La verificación de correo electrónico ha sido eliminada"
msgid "Email verification has been resent"
msgstr "La verificación de correo electrónico ha sido reenviada"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "Correos electrónicos"
msgid "Embedding, 5 members included and more"
msgstr "Incrustación, 5 miembros incluidos y más"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Vacío = Ilimitado, 0 = Bloqueado"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Campo vacío"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Habilitar"
@@ -5229,10 +5236,6 @@ msgstr "Asegúrate de que estás utilizando el token de incrustación, no el tok
msgid "Enter"
msgstr "Ingresar"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Ingrese un nombre para su nueva carpeta. Las carpetas le ayudan a organizar sus elementos."
@@ -5241,10 +5244,6 @@ msgstr "Ingrese un nombre para su nueva carpeta. Las carpetas le ayudan a organi
msgid "Enter a new title"
msgstr "Introduce un nuevo título"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Ingresar nombre de la reclamación"
@@ -5503,10 +5502,6 @@ msgstr "Todos han firmado"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "¡Todos han firmado! Recibirás una copia del documento firmado por correo electrónico."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Tiempo de espera excedido"
@@ -6770,10 +6765,6 @@ msgstr "Modo claro"
msgid "Like to have your own public profile with agreements?"
msgstr "¿Te gustaría tener tu propio perfil público con acuerdos?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Límites"
@@ -7079,10 +7070,6 @@ msgstr "MAU (con sesión iniciada)"
msgid "Max"
msgstr "Máx"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Tamaño máximo de archivo: 4MB. Máximo 100 filas por carga. Los valores en blanco usarán los valores predeterminados de la plantilla."
@@ -7129,12 +7116,12 @@ msgstr "Miembro desde"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Usuarios activos mensuales: Usuarios que completaron al menos uno de sus documentos"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "Cuota mensual de API"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Cuota mensual de documentos"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Cuota mensual de correos electrónicos"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "Nombre"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Se requiere el nombre"
@@ -7306,10 +7298,6 @@ msgstr "Se requiere el nombre"
msgid "Name Settings"
msgstr "Configuración de Nombre"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "¿Necesitas firmar documentos?"
@@ -7449,10 +7437,6 @@ msgstr "No further action is required from you at this time."
msgid "No groups found"
msgstr "No se encontraron grupos"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Licencia no configurada"
@@ -8169,10 +8153,6 @@ msgstr "Invitaciones pendientes de la organización"
msgid "Pending since"
msgstr "Pendiente desde"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "Por favor, ingresa un nombre significativo para tu token. Esto te ayudar
msgid "Please enter a number"
msgstr "Por favor ingresa un número"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Por favor, introduce un nombre válido."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Por favor, ingresa un número válido"
@@ -9073,10 +9057,6 @@ msgstr "Eliminar miembro de la organización"
msgid "Remove Organisation Member"
msgstr "Eliminar miembro de la organización"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Eliminar destinatario"
@@ -9267,10 +9247,6 @@ msgstr "Resolver"
msgid "Resolve payment"
msgstr "Resolver pago"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Respuesta"
@@ -9847,10 +9823,6 @@ msgstr "Enviando..."
msgid "Sent"
msgstr "Enviado"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sesión revocada"
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "URL del equipo"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "Equipos"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Los equipos te ayudan a organizar tu trabajo y colaborar con otros. Crea tu primer equipo para comenzar."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Equipos a los que actualmente está asignado este grupo de organización"
@@ -12329,6 +12297,8 @@ msgstr "Nombre desconocido"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Ilimitado"
@@ -12621,18 +12591,15 @@ msgstr "Subiendo"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Uso para el período: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Usar"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "Etiqueta blanca, miembros ilimitados y más"
msgid "Width:"
msgstr "Ancho:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Retirar Consentimiento"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Escribe una descripción para mostrar en tu perfil público"
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Ajouter des espaces réservés"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "Ajouter une limite de fréquence"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "Toute source"
msgid "Any Status"
msgstr "Tout statut"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "Clé dAPI"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "Limites de fréquence de lAPI"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "Requêtes API"
@@ -2678,10 +2681,6 @@ msgstr "Impossible de supprimer le signataire"
msgid "Cannot upload items after the document has been sent"
msgstr "Impossible de télécharger des éléments après l'envoi du document"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "Préférences de document"
msgid "Document preferences updated"
msgstr "Préférences de document mises à jour"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Limites de fréquence des documents"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "Documentation"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "Préférences de messagerie"
msgid "Email preferences updated"
msgstr "Préférences de messagerie mises à jour"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "Limites de fréquence des e-mails"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Envoyer un e-mail aux destinataires lorsquun document en attente est supprimé"
@@ -5088,7 +5094,6 @@ msgstr "La vérification par email a été supprimée"
msgid "Email verification has been resent"
msgstr "La vérification par email a été renvoyée"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "E-mails"
msgid "Embedding, 5 members included and more"
msgstr "Intégration, 5 membres inclus et plus"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Vide = Illimité, 0 = Bloqué"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Champ vide"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Activer"
@@ -5229,10 +5236,6 @@ msgstr "Assurez-vous dutiliser le jeton dintégration, et non le jeton d
msgid "Enter"
msgstr "Entrer"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Entrez un nom pour votre nouveau dossier. Les dossiers vous aident à organiser vos éléments."
@@ -5241,10 +5244,6 @@ msgstr "Entrez un nom pour votre nouveau dossier. Les dossiers vous aident à or
msgid "Enter a new title"
msgstr "Saisissez un nouveau titre"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Entrez le nom de la réclamation"
@@ -5503,10 +5502,6 @@ msgstr "Tout le monde a signé"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Tout le monde a signé ! Vous recevrez une copie du document signé par e-mail."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Délai dépassé"
@@ -6770,10 +6765,6 @@ msgstr "Mode clair"
msgid "Like to have your own public profile with agreements?"
msgstr "Vous voulez avoir votre propre profil public avec des accords ?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limites"
@@ -7079,10 +7070,6 @@ msgstr "MAU (connecté)"
msgid "Max"
msgstr "Maximum"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Taille maximale du fichier : 4 Mo. Maximum de 100 lignes par importation. Les valeurs vides utiliseront les valeurs par défaut du modèle."
@@ -7129,12 +7116,12 @@ msgstr "Membre depuis"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Utilisateurs actifs mensuels : utilisateurs ayant terminé au moins un de leurs documents"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "Quota dAPI mensuel"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Quota de documents mensuel"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Quota de-mails mensuel"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "Nom"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Le nom est requis"
@@ -7306,10 +7298,6 @@ msgstr "Le nom est requis"
msgid "Name Settings"
msgstr "Paramètres du nom"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Besoin de signer des documents ?"
@@ -7449,10 +7437,6 @@ msgstr "Aucune autre action n'est requise de votre part pour le moment."
msgid "No groups found"
msgstr "Aucun groupe trouvé"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Aucune licence configurée"
@@ -8169,10 +8153,6 @@ msgstr "Invitations à lorganisation en attente"
msgid "Pending since"
msgstr "En attente depuis"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "Veuillez entrer un nom significatif pour votre token. Cela vous aidera
msgid "Please enter a number"
msgstr "Veuillez entrer un nombre"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Veuiillez entrer un nom valide."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Veuillez entrer un numéro valide"
@@ -9073,10 +9057,6 @@ msgstr "Supprimer le membre de l'organisation"
msgid "Remove Organisation Member"
msgstr "Supprimer un membre de lorganisation"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Supprimer le destinataire"
@@ -9267,10 +9247,6 @@ msgstr "Résoudre"
msgid "Resolve payment"
msgstr "Résoudre le paiement"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Réponse"
@@ -9847,10 +9823,6 @@ msgstr "Envoi..."
msgid "Sent"
msgstr "Envoyé"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Session révoquée"
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "URL de l'équipe"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "Équipes"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Les équipes vous aident à organiser votre travail et à collaborer avec d'autres. Créez votre première équipe pour commencer."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Équipes auxquelles ce groupe d'organisation est actuellement attribué"
@@ -12329,6 +12297,8 @@ msgstr "Nom inconnu"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Illimité"
@@ -12621,18 +12591,15 @@ msgstr "Importation en cours"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Utilisation pour la période : {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Utiliser"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "Marque blanche, membres illimités et plus"
msgid "Width:"
msgstr "Largeur :"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Retrait du consentement"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Écrivez une description à afficher sur votre profil public"
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Aggiungi segnaposto"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "Aggiungi limite di velocità"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "Qualsiasi fonte"
msgid "Any Status"
msgstr "Qualsiasi stato"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "Chiave API"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "Limiti di velocità API"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "Richieste API"
@@ -2678,10 +2681,6 @@ msgstr "Impossibile rimuovere il firmatario"
msgid "Cannot upload items after the document has been sent"
msgstr "Non è possibile caricare gli elementi dopo che il documento è stato inviato"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "Preferenze Documento"
msgid "Document preferences updated"
msgstr "Preferenze del documento aggiornate"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Limiti di velocità documento"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "Documentazione"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "Preferenze Email"
msgid "Email preferences updated"
msgstr "Preferenze email aggiornate"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "Limiti di velocità email"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Invia un'email ai destinatari quando un documento in sospeso viene eliminato"
@@ -5088,7 +5094,6 @@ msgstr "Verifica email rimossa"
msgid "Email verification has been resent"
msgstr "Verifica email rinviata"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "Email"
msgid "Embedding, 5 members included and more"
msgstr "Incorporamento, 5 membri inclusi e altro"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Vuoto = Illimitato, 0 = Bloccato"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Campo vuoto"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Abilita"
@@ -5229,10 +5236,6 @@ msgstr "Assicurati di utilizzare il token di embedding, e non il token API"
msgid "Enter"
msgstr "Inserisci"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Inserisci un nome per la tua nuova cartella. Le cartelle ti aiutano a organizzare i tuoi elementi."
@@ -5241,10 +5244,6 @@ msgstr "Inserisci un nome per la tua nuova cartella. Le cartelle ti aiutano a or
msgid "Enter a new title"
msgstr "Inserisci un nuovo titolo"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Inserisci nome richiesta"
@@ -5503,10 +5502,6 @@ msgstr "Hanno firmato tutti"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Tutti hanno firmato! Riceverai una copia del documento firmato via email."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Tempo scaduto"
@@ -6770,10 +6765,6 @@ msgstr "Modalità chiara"
msgid "Like to have your own public profile with agreements?"
msgstr "Ti piacerebbe avere il tuo profilo pubblico con accordi?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limiti"
@@ -7079,10 +7070,6 @@ msgstr "MAU (autenticati)"
msgid "Max"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Dimensione massima del file: 4MB. Massimo 100 righe per caricamento. I valori vuoti utilizzeranno i valori predefiniti del modello."
@@ -7129,12 +7116,12 @@ msgstr "Membro dal"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Utenti attivi mensili: Utenti con almeno uno dei loro documenti completati"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "Quota API mensile"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Quota documenti mensile"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Quota email mensile"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "Nome"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Nome richiesto"
@@ -7306,10 +7298,6 @@ msgstr "Nome richiesto"
msgid "Name Settings"
msgstr "Impostazioni Nome"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Hai bisogno di firmare documenti?"
@@ -7449,10 +7437,6 @@ msgstr "Non sono richieste ulteriori azioni da parte tua in questo momento."
msgid "No groups found"
msgstr "Nessun gruppo trovato"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Nessuna licenza configurata"
@@ -8169,10 +8153,6 @@ msgstr "Inviti allorganizzazione in sospeso"
msgid "Pending since"
msgstr "In sospeso dal"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "Si prega di inserire un nome significativo per il proprio token. Questo
msgid "Please enter a number"
msgstr "Inserisci un numero"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Per favore inserisci un nome valido."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Per favore inserisci un numero valido"
@@ -9073,10 +9057,6 @@ msgstr "Rimuovere membro dell'organizzazione"
msgid "Remove Organisation Member"
msgstr "Rimuovi membro dell'organizzazione"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Rimuovi destinatario"
@@ -9267,10 +9247,6 @@ msgstr "Risolvi"
msgid "Resolve payment"
msgstr "Risolvere il pagamento"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Risposta"
@@ -9847,10 +9823,6 @@ msgstr "Invio..."
msgid "Sent"
msgstr "Inviato"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sessione revocata"
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "URL del team"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "Team"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "I team ti aiutano a organizzare il tuo lavoro e collaborare con altri. Crea il tuo primo team per iniziare."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Team a cui è attualmente assegnato questo gruppo di organizzazione"
@@ -12329,6 +12297,8 @@ msgstr "Nome sconosciuto"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Illimitato"
@@ -12621,18 +12591,15 @@ msgstr "Caricamento in corso"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Utilizzo per il periodo: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Utilizza"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "White label, membri illimitati e altro"
msgid "Width:"
msgstr "Larghezza:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Ritiro del consenso"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Scrivi una descrizione da mostrare sul tuo profilo pubblico"
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "プレースホルダーを追加"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "レート制限を追加"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "すべてのソース"
msgid "Any Status"
msgstr "すべてのステータス"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "API キー"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "API レート制限"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "API リクエスト"
@@ -2678,10 +2681,6 @@ msgstr "署名者を削除できません"
msgid "Cannot upload items after the document has been sent"
msgstr "ドキュメント送信後はアイテムをアップロードできません"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "ドキュメント設定"
msgid "Document preferences updated"
msgstr "文書設定を更新しました"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "ドキュメントのレート制限"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "ドキュメント"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "メール設定"
msgid "Email preferences updated"
msgstr "メール設定を更新しました"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "メールのレート制限"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "保留中のドキュメントが削除されたときに受信者へメール通知する"
@@ -5088,7 +5094,6 @@ msgstr "メール認証を削除しました"
msgid "Email verification has been resent"
msgstr "メール認証を再送しました"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "メール"
msgid "Embedding, 5 members included and more"
msgstr "埋め込み、5 メンバー含む など"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "空欄 = 無制限、0 = ブロックされます"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "空のフィールド"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "有効化"
@@ -5229,10 +5236,6 @@ msgstr "埋め込みトークンを使用していることを確認し、API
msgid "Enter"
msgstr "入力してください"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "新しいフォルダ名を入力してください。フォルダを使うとアイテムを整理できます。"
@@ -5241,10 +5244,6 @@ msgstr "新しいフォルダ名を入力してください。フォルダを使
msgid "Enter a new title"
msgstr "新しいタイトルを入力してください"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "クレーム名を入力"
@@ -5503,10 +5502,6 @@ msgstr "全員が署名しました"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "全員が署名しました。署名済みドキュメントのコピーがメールで送信されます。"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "タイムアウトを超えました"
@@ -6770,10 +6765,6 @@ msgstr "ライトモード"
msgid "Like to have your own public profile with agreements?"
msgstr "自分の合意書付き公開プロフィールが欲しいですか?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "上限"
@@ -7079,10 +7070,6 @@ msgstr "MAU(サインイン済み)"
msgid "Max"
msgstr "最大"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "最大ファイルサイズ: 4MB。アップロードあたり最大 100 行。空の値はテンプレートのデフォルトが使用されます。"
@@ -7129,12 +7116,12 @@ msgstr "メンバー登録日"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "月間アクティブユーザー:1 つ以上の文書が完了したユーザー"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "月間 API クォータ"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "月間ドキュメントクォータ"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "月間メールクォータ"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "名前"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "名前は必須です"
@@ -7306,10 +7298,6 @@ msgstr "名前は必須です"
msgid "Name Settings"
msgstr "名前の設定"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "文書への署名が必要ですか?"
@@ -7449,10 +7437,6 @@ msgstr "現在、お客様が行う必要のある操作はありません。"
msgid "No groups found"
msgstr "グループが見つかりません"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "ライセンスが設定されていません"
@@ -8169,10 +8153,6 @@ msgstr "保留中の組織招待"
msgid "Pending since"
msgstr "保留開始日時"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "トークンの用途が分かる名前を入力してください。後
msgid "Please enter a number"
msgstr "数値を入力してください"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "有効な名前を入力してください。"
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "有効な数値を入力してください"
@@ -9073,10 +9057,6 @@ msgstr "組織メンバーを削除"
msgid "Remove Organisation Member"
msgstr "組織メンバーを削除"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "受信者を削除"
@@ -9267,10 +9247,6 @@ msgstr "解決"
msgid "Resolve payment"
msgstr "支払いを解決"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "レスポンス"
@@ -9847,10 +9823,6 @@ msgstr "送信中..."
msgid "Sent"
msgstr "送信日時"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "セッションを取り消しました"
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "チーム URL"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "チーム"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "チームは作業を整理し、他のメンバーとコラボレーションするのに役立ちます。最初のチームを作成して始めましょう。"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "この組織グループが現在割り当てられているチーム"
@@ -12329,6 +12297,8 @@ msgstr "不明な名前"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "無制限"
@@ -12621,18 +12591,15 @@ msgstr "アップロード中"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "期間内の利用状況: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "使用"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "ホワイトラベリング、メンバー無制限など"
msgid "Width:"
msgstr "幅:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "同意の撤回"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "公開プロフィールに表示する説明文を入力してください"
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "플레이스홀더 추가"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "요청 한도 추가"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "모든 소스"
msgid "Any Status"
msgstr "모든 상태"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "API 키"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "API 요청 한도"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "API 요청"
@@ -2678,10 +2681,6 @@ msgstr "서명자를 제거할 수 없습니다."
msgid "Cannot upload items after the document has been sent"
msgstr "문서를 전송한 이후에는 항목을 업로드할 수 없습니다."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "문서 기본 설정"
msgid "Document preferences updated"
msgstr "문서 환경설정이 업데이트되었습니다"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "문서 요청 한도"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "문서"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "이메일 기본 설정"
msgid "Email preferences updated"
msgstr "이메일 기본 설정이 업데이트되었습니다."
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "이메일 요청 한도"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "보류 중인 문서가 삭제되면 수신자에게 이메일 보내기"
@@ -5088,7 +5094,6 @@ msgstr "이메일 인증이 제거되었습니다"
msgid "Email verification has been resent"
msgstr "이메일 인증이 다시 전송되었습니다"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "이메일"
msgid "Embedding, 5 members included and more"
msgstr "임베딩, 5명의 구성원 포함 등"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "비워 두기 = 무제한, 0 = 차단됨"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "빈 필드"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "활성화"
@@ -5229,10 +5236,6 @@ msgstr "임베딩 토큰이 아닌 API 토큰을 사용하고 있지 않은지
msgid "Enter"
msgstr "입력하세요"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "새 폴더 이름을 입력하세요. 폴더는 항목을 정리하는 데 도움이 됩니다."
@@ -5241,10 +5244,6 @@ msgstr "새 폴더 이름을 입력하세요. 폴더는 항목을 정리하는
msgid "Enter a new title"
msgstr "새 제목을 입력하세요."
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "클레임 이름 입력"
@@ -5503,10 +5502,6 @@ msgstr "모든 사람이 서명했습니다"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "모두 서명했습니다! 서명된 문서의 사본이 이메일로 전송됩니다."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "시간 초과됨"
@@ -6770,10 +6765,6 @@ msgstr "라이트 모드"
msgid "Like to have your own public profile with agreements?"
msgstr "계약이 포함된 나만의 공개 프로필을 원하시나요?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "제한"
@@ -7079,10 +7070,6 @@ msgstr "MAU(로그인 기준)"
msgid "Max"
msgstr "최대"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "최대 파일 크기: 4MB. 업로드당 최대 100행. 비어 있는 값은 템플릿 기본값이 사용됩니다."
@@ -7129,12 +7116,12 @@ msgstr "가입일"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "월간 활성 사용자: 문서가 하나 이상 완료된 사용자"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "월간 API 할당량"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "월간 문서 할당량"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "월간 이메일 할당량"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "이름"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "이름은 필수 항목입니다."
@@ -7306,10 +7298,6 @@ msgstr "이름은 필수 항목입니다."
msgid "Name Settings"
msgstr "이름 설정"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "문서에 서명이 필요하신가요?"
@@ -7449,10 +7437,6 @@ msgstr "현재 추가로 수행해야 할 작업은 없습니다."
msgid "No groups found"
msgstr "그룹을 찾을 수 없습니다."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "라이선스가 구성되지 않았습니다"
@@ -8169,10 +8153,6 @@ msgstr "보류 중인 조직 초대"
msgid "Pending since"
msgstr "다음 시점부터 보류 중"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "토큰을 나중에 식별할 수 있도록 의미 있는 이름을 입
msgid "Please enter a number"
msgstr "숫자를 입력하세요"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "올바른 이름을 입력해 주세요."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "올바른 숫자를 입력하세요"
@@ -9073,10 +9057,6 @@ msgstr "조직 구성원 제거"
msgid "Remove Organisation Member"
msgstr "조직 구성원 제거"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "수신자 제거"
@@ -9267,10 +9247,6 @@ msgstr "해결"
msgid "Resolve payment"
msgstr "결제 해결"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "응답"
@@ -9847,10 +9823,6 @@ msgstr "전송 중..."
msgid "Sent"
msgstr "발송됨"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "세션이 해지되었습니다."
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "팀 URL"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "팀"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "팀은 작업을 조직하고 다른 사람과 협업하는 데 도움이 됩니다. 첫 번째 팀을 생성하여 시작하세요."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "이 조직 그룹이 현재 할당된 팀"
@@ -12329,6 +12297,8 @@ msgstr "이름을 알 수 없음"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "무제한"
@@ -12621,18 +12591,15 @@ msgstr "업로드 중"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "기간별 사용량: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "사용"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "화이트라벨, 무제한 구성원 등"
msgid "Width:"
msgstr "너비:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "동의 철회"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "공개 프로필에 표시될 설명을 작성하세요."
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Placeholders toevoegen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "Snelheidslimiet toevoegen"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "Elke bron"
msgid "Any Status"
msgstr "Elke status"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "API-sleutel"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "API-snelheidslimieten"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "API-verzoeken"
@@ -2678,10 +2681,6 @@ msgstr "Ondertekenaar kan niet worden verwijderd"
msgid "Cannot upload items after the document has been sent"
msgstr "Items kunnen niet worden geüpload nadat het document is verzonden"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "Documentvoorkeuren"
msgid "Document preferences updated"
msgstr "Documentvoorkeuren bijgewerkt"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Documentsnelheidslimieten"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "Documentatie"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "E-mailvoorkeuren"
msgid "Email preferences updated"
msgstr "E-mailvoorkeuren bijgewerkt"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "E-mailsnelheidslimieten"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "E-mail ontvangers wanneer een in behandeling zijnd document wordt verwijderd"
@@ -5088,7 +5094,6 @@ msgstr "Emailverificatie is verwijderd"
msgid "Email verification has been resent"
msgstr "Emailverificatie is opnieuw verzonden"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "E-mails"
msgid "Embedding, 5 members included and more"
msgstr "Inbedding, 5 leden inbegrepen en meer"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Leeg = Onbeperkt, 0 = Geblokkeerd"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Leeg veld"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Inschakelen"
@@ -5229,10 +5236,6 @@ msgstr "Zorg ervoor dat je het embedding-token gebruikt en niet het API-token"
msgid "Enter"
msgstr "Invoeren"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Voer een naam in voor je nieuwe map. Mappen helpen je je items te organiseren."
@@ -5241,10 +5244,6 @@ msgstr "Voer een naam in voor je nieuwe map. Mappen helpen je je items te organi
msgid "Enter a new title"
msgstr "Voer een nieuwe titel in"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Voer claimnaam in"
@@ -5503,10 +5502,6 @@ msgstr "Iedereen heeft getekend"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Iedereen heeft ondertekend! U ontvangt een kopie van het ondertekende document per e-mail."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Timeout overschreden"
@@ -6770,10 +6765,6 @@ msgstr "Lichte modus"
msgid "Like to have your own public profile with agreements?"
msgstr "Wil je een eigen openbaar profiel met overeenkomsten?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limieten"
@@ -7079,10 +7070,6 @@ msgstr "MAU (ingelogd)"
msgid "Max"
msgstr "Max"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Maximale bestandsgrootte: 4 MB. Maximaal 100 rijen per upload. Lege waarden gebruiken de standaardwaarden van de sjabloon."
@@ -7129,12 +7116,12 @@ msgstr "Lid sinds"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Maandelijks actieve gebruikers: gebruikers van wie ten minste één document is voltooid"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "Maandelijkse API-limiet"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Maandelijkse documentlimiet"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Maandelijkse e-maillimiet"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "Naam"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Naam is verplicht"
@@ -7306,10 +7298,6 @@ msgstr "Naam is verplicht"
msgid "Name Settings"
msgstr "Naam-instellingen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Moet je documenten ondertekenen?"
@@ -7449,10 +7437,6 @@ msgstr "Er is op dit moment geen verdere actie van jou vereist."
msgid "No groups found"
msgstr "Geen groepen gevonden"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Geen licentie geconfigureerd"
@@ -8169,10 +8153,6 @@ msgstr "Openstaande organisatie-uitnodigingen"
msgid "Pending since"
msgstr "In behandeling sinds"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "Voer een betekenisvolle naam in voor je token. Hiermee kun je het later
msgid "Please enter a number"
msgstr "Voer een nummer in"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Voer een geldige naam in."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Voer een geldig nummer in"
@@ -9073,10 +9057,6 @@ msgstr "Organisatielid verwijderen"
msgid "Remove Organisation Member"
msgstr "Organisatielid verwijderen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Ontvanger verwijderen"
@@ -9267,10 +9247,6 @@ msgstr "Oplossen"
msgid "Resolve payment"
msgstr "Betaling oplossen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Antwoord"
@@ -9847,10 +9823,6 @@ msgstr "Verzenden..."
msgid "Sent"
msgstr "Verzonden"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sessie ingetrokken"
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "TeamURL"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "Teams"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Teams helpen je je werk te organiseren en samen te werken met anderen. Maak je eerste team aan om te beginnen."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Teams waaraan deze organisatiegroep momenteel is toegewezen"
@@ -12329,6 +12297,8 @@ msgstr "Onbekende naam"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Onbeperkt"
@@ -12621,18 +12591,15 @@ msgstr "Uploaden"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Gebruik voor periode: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Gebruiken"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "Whitelabeling, onbeperkte leden en meer"
msgid "Width:"
msgstr "Breedte:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Toestemming intrekken"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Schrijf een beschrijving die op je openbare profiel wordt weergegeven"
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Dodaj domyślny tekst"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "Dodaj limit przepustowości"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "Dowolne źródło"
msgid "Any Status"
msgstr "Dowolny status"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "Klucz API"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "Limit przepustowości API"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "Żądania API"
@@ -2678,10 +2681,6 @@ msgstr "Nie można usunąć podpisującego"
msgid "Cannot upload items after the document has been sent"
msgstr "Nie możesz przesłać elementów po wysłaniu dokumentu"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "Ustawienia dokumentu"
msgid "Document preferences updated"
msgstr "Ustawienia dokumentu zostały zaktualizowane"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Limit przepustowości dokumentów"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "Dokumentacja"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "Ustawienia adresu e-mail"
msgid "Email preferences updated"
msgstr "Ustawienia adresu e-mail zostały zaktualizowane"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "Limit przepustowości wiadomości"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Wyślij odbiorcom wiadomość, gdy oczekujący dokument zostanie usunięty"
@@ -5088,7 +5094,6 @@ msgstr "Weryfikacja adresu e-mail została usunięta"
msgid "Email verification has been resent"
msgstr "Weryfikacja adresu e-mail została ponownie wysłana"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "Adresy e-mail"
msgid "Embedding, 5 members included and more"
msgstr "Osadzanie dokumentów, 5 użytkowników i więcej"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Puste = bez ograniczeń, 0 = zablokowane"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Puste pole"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Włącz"
@@ -5229,10 +5236,6 @@ msgstr "Upewnij się, że używasz tokena osadzania, a nie tokenu API."
msgid "Enter"
msgstr "Wpisz"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Wpisz nazwę nowego folderu. Foldery pomagają uporządkować elementy."
@@ -5241,10 +5244,6 @@ msgstr "Wpisz nazwę nowego folderu. Foldery pomagają uporządkować elementy."
msgid "Enter a new title"
msgstr "Wpisz nowy tytuł"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Wpisz nazwę"
@@ -5503,10 +5502,6 @@ msgstr "Wszyscy podpisali"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Wszyscy podpisali! Otrzymasz wiadomość z podpisanym dokumentem."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Przekroczono limit czasu"
@@ -6770,10 +6765,6 @@ msgstr "Tryb jasny"
msgid "Like to have your own public profile with agreements?"
msgstr "Czy chcesz mieć własny profil publiczny z umowami?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limity"
@@ -7079,10 +7070,6 @@ msgstr "MAU (zalogowani)"
msgid "Max"
msgstr "Maks."
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Maksymalny rozmiar pliku to 4 MB. Możesz przesłać maksymalnie 100 wierszy na raz. Puste wartości zostaną zastąpione domyślnymi z szablonu."
@@ -7129,12 +7116,12 @@ msgstr "Data dołączenia"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Miesięczna liczba aktywnych użytkowników: Użytkownicy, którzy zakończyli co najmniej jeden dokument"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "Miesięczny limit API"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Miesięczny limit dokumentów"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Miesięczny limit wiadomości"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "Nazwa"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Nazwa jest wymagana"
@@ -7306,10 +7298,6 @@ msgstr "Nazwa jest wymagana"
msgid "Name Settings"
msgstr "Ustawienia nazwy"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Potrzebujesz podpisywać dokumenty?"
@@ -7449,10 +7437,6 @@ msgstr "Nie musisz nic więcej robić."
msgid "No groups found"
msgstr "Nie znaleziono grup"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Brak skonfigurowanej licencji"
@@ -8169,10 +8153,6 @@ msgstr "Oczekujące zaproszenia do organizacji"
msgid "Pending since"
msgstr "Oczekuje od"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "Wpisz nazwę tokena. Pomoże to później w jego identyfikacji."
msgid "Please enter a number"
msgstr "Wpisz liczbę"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Wpisz prawidłową nazwę."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Wpisz prawidłową liczbę"
@@ -9073,10 +9057,6 @@ msgstr "Usuń użytkownika organizacji"
msgid "Remove Organisation Member"
msgstr "Usuń użytkownika organizacji"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Usuń odbiorcę"
@@ -9267,10 +9247,6 @@ msgstr "Rozwiąż"
msgid "Resolve payment"
msgstr "Rozwiąż płatność"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Odpowiedź"
@@ -9847,10 +9823,6 @@ msgstr "Wysyłanie..."
msgid "Sent"
msgstr "Wysłano"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sesja została unieważniona"
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "Adres URL zespołu"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "Zespoły"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Zespoły pomagają organizować pracę i współpracować z innymi. Utwórz swój pierwszy zespół, aby rozpocząć."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Zespoły, do których przypisana jest grupa organizacji"
@@ -12329,6 +12297,8 @@ msgstr "Nieznana nazwa"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Bez ograniczeń"
@@ -12621,18 +12591,15 @@ msgstr "Przesyłanie"
msgid "URL"
msgstr "Adres URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Okres: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Użyj"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "Własny branding, nieograniczona liczba użytkowników i więcej"
msgid "Width:"
msgstr "Szerokość:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Wycofanie zgody"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Wpisz opis, który będzie wyświetlany w profilu publicznym"

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