Compare commits

..

7 Commits

Author SHA1 Message Date
Crowdin Bot f3f0c04370 chore: add translations 2026-07-12 03:04:01 +00:00
Tanushree Ahir 400b6a24f1 feat: enable real-time validation for email and password fields (#2204)
## Description

This MR improves the real-time validation experience on the signup page.
Currently, both the email and password fields validate only on blur,
causing users to continue seeing error messages even after correcting
their input until they click outside the field.

This update switches the React Hook Form configuration from mode:
'onBlur' to mode: 'onChange', ensuring validations update immediately as
the user types.
This results in a smoother and less confusing signup experience.

## Related Issue

Fixes #2200

## Changes Made

- Updated React Hook Form configuration in the signup form from:
- mode: 'onBlur' ➜ mode: 'onChange'
- Ensures real-time validation for email and password fields
- No schema or logic changes — only validation trigger updated


## Testing Performed

- Manually tested the signup page for:
- Real-time password rule updates
- Real-time email validation feedback
- Error disappearance immediately after correcting input

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

This is a small UX-focused improvement; the validation schema remains
unchanged.

Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2026-07-10 13:41:31 +03:00
roshboi 1b1e3d197b Add "Apply Your License Key" self-hosting guide (#3077)
Adds a dedicated self-hosting page documenting how to apply an
Enterprise license key, and cross-links it from the related docs.

New page: self-hosting/configuration/license.mdx — "Apply Your License
Key"
Set NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY (Docker Compose / docker run /
.env tabs)
2026-07-08 16:11:49 +10:00
Lucas Smith a276e18e1f chore: upgrade deps (#3065) 2026-07-08 16:11:00 +10:00
Catalin Pit 50f272be87 fix: admin organisation limits and usage UI (#3014) 2026-07-02 16:50:11 +10:00
Catalin Pit a55e6d9484 fix: block invisible & control characters and URLs in names (#2978) 2026-07-02 16:20:56 +10:00
David Nguyen d35d13db23 fix: remove presigned branding upload (#3053) 2026-07-02 15:51:19 +10:00
107 changed files with 5002 additions and 5490 deletions
@@ -1,430 +0,0 @@
---
date: 2026-05-21
title: Acroform Field Detection And Reuse
---
## Problem
Users routinely prepare PDFs in Adobe Acrobat (or other PDF editors) with AcroForm fields — signatures, text inputs, dates, checkboxes — and upload them to Documenso. Today those widgets are either stripped (`DOCUMENT` upload flattens via `form.flatten()` in `normalize-pdf.ts`) or preserved as static interactive controls (`TEMPLATE` upload), but never reused as Documenso fields. Users have to re-place every field in the editor.
Issue: https://github.com/documenso/documenso/issues/2697 (labels: `type: enhancement`, `apps: web`).
## Goal
On upload, detect AcroForm fields, map supported types to Documenso fields, persist their page geometry, then flatten the PDF so no duplicate interactive controls remain. Imported fields should be ordinary `Field` rows — visible in the editor, assignable to recipients, signable like any other field.
## Background
The placeholder pipeline (`{{signature, r1}}` style) already does almost everything we need:
- `packages/lib/server-only/pdf/auto-place-fields.ts` extracts `PlaceholderInfo[]` (with `fieldAndMeta: TFieldAndMeta`, top-left percentages, page index), then `convertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)` returns `tx.field.createMany` payloads.
- `packages/trpc/server/envelope-router/create-envelope.ts` per file: `convertToPdf` → optional `insertFormValuesInPdf``normalizePdf({ flattenForm: type !== 'TEMPLATE' })``extractPdfPlaceholders(normalized)``putPdfFileServerSide(cleanedPdf)` → forwards `{ title, documentDataId, placeholders }`.
- `packages/lib/server-only/envelope-item/create-envelope-items.ts` runs the same per-file pipeline when files are appended to an existing envelope.
- `packages/lib/server-only/envelope/create-envelope.ts` consumes `envelopeItems[].placeholders`, creates placeholder `SIGNER` recipients (`recipient.${i}@documenso.com`) when `data.recipients` is empty, then calls `convertPlaceholdersToFieldInputs` + `tx.field.createMany` inside its existing transaction.
`@libpdf/core` exposes the AcroForm primitives we need (verified in `node_modules/@libpdf/core/dist/index.d.mts`):
- `PDF.isEncrypted: boolean`, `PDF.getForm(): PDFForm | null`, `PDF.getPages()[i].ref` + `.getRotation()`.
- `PDFForm.getFields(): FormField[]`.
- `FormField`: `name`, `partialName`, `alternateName` (/TU), `isRequired()`, `isReadOnly()`, `acroField(): PdfDict` (raw dict access), `type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'listbox' | 'signature' | 'button' | 'unknown' | 'non-terminal'`.
- `WidgetAnnotation`: `rect`, `width`, `height`, `pageRef: PdfRef | null`, `isHidden()`, `isPrintable()`, `getOnValue()`.
- Typed subclasses: `CheckboxField.isChecked()` / `getOnValues()` / `getOnValue()`, `DropdownField.getOptions()`, `RadioField.getOptions()`, `SignatureField`, `TextField.getText()`.
`ZBaseFieldMeta` (in `packages/lib/types/field-meta.ts`) already carries `label`, `required`, `readOnly`. We extend it once with `source?: 'acroform'` so imported fields are introspectable without UI changes.
## Scope
In scope: server-side extraction at upload time for v2 envelopes (both new envelopes and items appended to existing envelopes), per-field `FIELD_CREATED` audit log entries for imported fields, and the one-line schema extension to record provenance. Out of scope: editor UI changes (badges, banners, review modals), signing surface changes, v1 path, listbox, button/unknown/non-terminal field types, true radio-group consolidation, recipient inference beyond first-signer selection.
## Field Mapping
### Type resolution
| AcroForm type | Documenso field | Rule |
| --- | --- | --- |
| `signature` (unsigned) | `SIGNATURE` | Import. |
| `signature` (signed — `SignatureField.isSigned()` returns true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). |
| `text` | resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
| `checkbox` | `CHECKBOX` | One Documenso field per widget. |
| `radio` | `RADIO` | One Documenso field per widget. Store group's `getOptions()` in each `fieldMeta.values`. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
| `dropdown` | `DROPDOWN` | Preserve `getOptions()` in `fieldMeta.values`, current selection in `fieldMeta.defaultValue`. |
| `listbox`, `button`, `unknown`, `non-terminal` | — | Skip, return as `AcroFormUnsupportedFieldInfo`. Never block upload. |
### Text-field heuristic (resolved in order — first match wins)
AcroForm format actions take precedence over every name token. If `/AA → /F → /JS` references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
1. **DATE** if `acroField()` carries an additional-actions date format (`/AA``/F``JS` containing `AFDate_FormatEx` or `AFDate_Format`).
2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action.
3. **DATE** if name/alternateName matches `/date|dob|birth/i`.
4. **NUMBER** if name/alternateName matches `/amount|qty|count|number/i` AND `/MaxLen <= 10`.
5. **EMAIL** if name/alternateName matches `/email|e[-_]?mail/i`.
6. **NAME** if name/alternateName matches `/name/i`.
7. **INITIALS** if name/alternateName matches `/initial/i`.
8. Else **TEXT**.
All regexes are case-insensitive and run against `partialName` then `alternateName`.
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. `CustomerName`, `BirthDate`) that strict word-boundary patterns miss. Expected false positives — `username` → NAME, `birth_name` → DATE, `initialize` → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
### Metadata mapping
For every imported field:
- `fieldMeta.required = field.isRequired()` (boolean; omit when false).
- `fieldMeta.readOnly = field.isReadOnly()` (boolean; omit when false). Read-only fields **are** imported (rendered for reference in the editor); the renderer already honours `readOnly`.
- `fieldMeta.label`: `alternateName ?? partialName` for label-supporting types (TEXT, NUMBER, DATE, INITIALS, NAME, EMAIL, DROPDOWN, RADIO, CHECKBOX). SIGNATURE has no label slot — drop it.
- `fieldMeta.source = 'acroform'` on every imported field (see Schema Extension).
### CHECKBOX required semantics
AcroForm "required" on a checkbox means "must be checked". Documenso CHECKBOX has both `required` (field must be present) and `validationRule`/`validationLength` (e.g. "at least N of M"). Mapping:
```ts
if (field.isRequired()) {
fieldMeta.required = true;
fieldMeta.validationRule = 'at-least';
fieldMeta.validationLength = 1;
}
```
This approximates "must be checked to submit" for a single-widget checkbox field.
### Default values (only when `formValues` was NOT provided on this upload)
`formValues` (via `insertFormValuesInPdf`) is authoritative when present — it bakes values into the flattened background, so the imported fields stay empty for the signer to fill. When `formValues` is absent, we copy PDF defaults into `fieldMeta` so the editor preview matches the source PDF:
| Source | Target |
| --- | --- |
| `TextField.getText()` (non-empty) | `fieldMeta.text` (TEXT/DATE/INITIALS/NAME/EMAIL) or `fieldMeta.value` (NUMBER) |
| `DropdownField` current selection | `fieldMeta.defaultValue` |
| `CheckboxField.isChecked()` | `fieldMeta.values[0].checked = true` |
| `RadioField` current selection | `fieldMeta.values[i].checked = true` on the matching option |
Always emit `inserted: false` and `customText: ''` — the signer still confirms each field, defaults are editor-only hints.
All metadata flows through `ZEnvelopeFieldAndMetaSchema.parse(...)` so imported fields match what the editor already expects.
## Pre-extraction Guards
Run in this order before any AcroForm work:
1. **Encrypted PDFs**: if `pdfDoc.isEncrypted` → log `{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }`, return `{ fields: [], unsupported: [] }`. Upload proceeds with zero imported fields.
2. **XFA hybrid**: detect via the catalog's `AcroForm` dict carrying an `XFA` key (best-effort via raw dict access; if @libpdf/core's public surface can't read it, fall through — mirrored AcroForm fields in XFA hybrids are fine to import). When detected, log `{ event: 'acroform-import.skip', reason: 'xfa-hybrid' }` and return empty results.
3. **`getForm()` is null** → return empty results silently.
4. **Top-level try/catch** around steps 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).
@@ -76,6 +76,8 @@ 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>
@@ -197,7 +199,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. Configure Enterprise features with support assistance
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
</Step>
<Step>
@@ -238,6 +240,7 @@ 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.
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.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
| `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_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 |
@@ -0,0 +1,107 @@
---
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,6 +2,7 @@
"title": "Configuration",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
@@ -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`).
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.
</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.
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).
---
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
name: ZNameSchema,
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -65,7 +66,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.`,
@@ -1,5 +1,6 @@
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';
@@ -23,8 +24,6 @@ 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;
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
name: ZNameSchema,
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
@@ -40,7 +39,6 @@ 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();
@@ -1,5 +1,6 @@
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';
@@ -25,14 +26,13 @@ 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: z.string().min(3),
passkeyName: ZNameSchema,
});
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,4 +1,5 @@
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -19,16 +20,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 { z } from 'zod';
import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
});
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
data: true,
}).shape.data;
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: {
name: teamEmail.name,
},
mode: 'onSubmit',
});
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,5 +1,10 @@
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';
@@ -23,15 +28,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 <= 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')
.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')
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
@@ -245,7 +250,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative">
<Input
type="file"
accept={ACCEPTED_FILE_TYPES.join(',')}
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import {
Form,
FormControl,
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
name: ZNameSchema,
fromName: ZNameSchema,
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/constants/auth';
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';
+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: 'onBlur',
mode: 'onChange',
resolver: zodResolver(ZSignUpFormSchema),
});
@@ -5,6 +5,7 @@ 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';
@@ -25,38 +26,72 @@ 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 }: AdminGlobalSettingsSectionProps) => {
export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
const notSet = <Trans>Not set</Trans>;
const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
}
return value;
return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
};
const brandingTextValue = (value: string | null | undefined) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value && value.trim() !== '') {
return value;
}
return value;
if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
};
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
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);
}
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
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));
};
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
<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>
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
<DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
@@ -1,6 +1,7 @@
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';
@@ -19,7 +20,6 @@ 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: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
name: ZNameSchema,
email: zEmail().min(1),
password: ZPasswordSchema,
})
@@ -1,11 +1,4 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { FormControl, 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';
@@ -13,6 +6,13 @@ 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,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean;
};
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
@@ -30,13 +36,33 @@ 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 renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
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) => (
<FormField
control={control}
name={name(key)}
name={name(group.quotaKey)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
@@ -47,20 +73,18 @@ 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 = (key: string, label: ReactNode) => (
const renderRateLimitField = (group: LimitGroup) => (
<FormField
control={control}
name={name(key)}
name={name(group.rateLimitKey)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
);
return (
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
<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>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
<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(
'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>)}
{renderQuotaField(group)}
{renderRateLimitField(group)}
</div>
))}
</div>
</div>
</div>
);
};
@@ -20,20 +20,18 @@ 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, FormInputIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -80,8 +78,7 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig, flushAutosave, syncEnvelope } =
useCurrentEnvelopeEditor();
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -89,32 +86,7 @@ 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),
@@ -229,40 +201,6 @@ 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}>
@@ -350,7 +288,6 @@ 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">
@@ -443,20 +380,6 @@ 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. */}
@@ -1,13 +1,38 @@
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 { useState } from 'react';
import { match } from 'ts-pattern';
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 { 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<
@@ -15,13 +40,151 @@ 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];
@@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [
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[] = [
{
counter: 'document' as const,
counter: 'document',
label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email' as const,
counter: 'email',
label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api' as const,
counter: 'api',
label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
return (
<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="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}
{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>
<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>
{rows.map((row) => {
const percent =
row.effectiveLimit && row.effectiveLimit > 0
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
: 0;
{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>
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>
<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
}
/>
))}
{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>
<UsageStatCard
label={<Trans>Reports</Trans>}
icon={MailOpenIcon}
used={selectedStat?.emailReports ?? 0}
limit={null}
countOnly
footnote={<Trans>Sent this period</Trans>}
/>
</div>
</div>
</div>
@@ -2,6 +2,7 @@ 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 = {
@@ -32,6 +33,7 @@ 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}-${currentEnvelopeItem.documentDataId}`}
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
@@ -50,7 +50,6 @@ export type PDFViewerProps = {
scrollParentRef: ScrollTarget;
onDocumentLoad?: () => void;
onAcroFormDetected?: (hasFields: boolean) => void;
/**
* Additional component to render next to the image, such as a Konva canvas
@@ -64,7 +63,6 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
onAcroFormDetected,
customPageRenderer,
...props
}: PDFViewerProps) {
@@ -126,20 +124,6 @@ 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);
@@ -184,7 +168,7 @@ export default function PDFViewer({
pdfRef.current = null;
}
};
}, [data, onAcroFormDetected]);
}, [data]);
// Notify when document is loaded
useEffect(() => {
@@ -1,7 +1,9 @@
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 } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number };
@@ -11,50 +13,153 @@ 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 entries = value ?? [];
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 updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(next);
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 removeEntry = (index: number) => {
onChange(entries.filter((_, i) => i !== index));
if (index >= value.length) {
setDraftEntry(null);
return;
}
const next = value.filter((_, i) => i !== index);
onChange(persistEntries(next));
};
const addEntry = () => {
onChange([...entries, { window: '5m', max: 100 }]);
setDraftEntry(EMPTY_ENTRY);
};
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">
{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>
))}
<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>
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
{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}
>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit</Trans>
<Trans>Add rate limit window</Trans>
</Button>
</div>
);
@@ -1,3 +1,4 @@
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';
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
};
const ZUpdatePasskeySchema = z.object({
name: z.string(),
name: ZNameSchema,
});
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
@@ -8,6 +8,7 @@ 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';
@@ -30,7 +31,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 } from '@prisma/client';
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
@@ -42,7 +43,6 @@ 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,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<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>
<SettingsHeader
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<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>
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
capacityUsage={{
members: organisation.members.length,
teams: organisation.teams.length,
}}
/>
<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-medium text-sm">
<p className="font-semibold text-base">
<Trans>Global Settings</Trans>
</p>
<p className="mt-1 font-normal text-muted-foreground text-sm">
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans>
</p>
</div>
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16"
/>
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<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"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Subscription</Trans>
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2">
{organisation.subscription ? (
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
<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>
<Trans>No subscription found</Trans>
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<Button
variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline">
<Button variant="outline" className="bg-background">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10">
<div>
<label className="font-medium text-sm leading-none">
<h3 className="font-semibold text-base">
<Trans>Organisation Members</Trans>
</label>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
<div className="my-2">
<div className="mt-3">
<DataTable columns={organisationMembersColumns} data={organisation.members} />
</div>
</div>
<div>
<label className="font-medium text-sm leading-none">
<h3 className="font-semibold text-base">
<Trans>Organisation Teams</Trans>
</label>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
<div className="my-2">
<div className="mt-3">
<DataTable columns={teamsColumns} data={organisation.teams} />
</div>
</div>
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
<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>
</FormItem>
)}
/>
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<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>
)}
/>
<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.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>
)}
/>
<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>
<div>
<FormLabel>
<h3 className="font-semibold text-base">
<Trans>Feature Flags</Trans>
</FormLabel>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
<div className="mt-2 space-y-2 rounded-md border p-4">
<div className="mt-3 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,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
<AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -1,7 +1,6 @@
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';
@@ -49,26 +48,29 @@ 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;
let uploadedBrandingLogo: string | undefined;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateOrganisationBrandingLogo(formData);
}
const result = await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -3,6 +3,7 @@ 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';
@@ -28,7 +29,6 @@ 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,7 +36,6 @@ 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) {
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}
const ZUpdateOrganisationGroupFormSchema = z.object({
name: z.string().min(1, msg`Name is required`.id),
name: ZNameSchema,
organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()),
});
@@ -1,6 +1,5 @@
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';
@@ -38,6 +37,7 @@ 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,22 +48,23 @@ export default function TeamsSettingsPage() {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
formData.append('payload', JSON.stringify({ teamId: team.id }));
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateTeamBrandingLogo(formData);
}
const result = await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
+4 -4
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.12.0",
"@react-router/serve": "^7.12.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@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.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
+1 -28
View File
@@ -1,9 +1,8 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } 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';
@@ -12,14 +11,11 @@ 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';
@@ -61,29 +57,6 @@ 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,27 +13,6 @@ 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,7 +105,6 @@ 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
@@ -1,715 +0,0 @@
%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
+2048 -1264
View File
File diff suppressed because it is too large Load Diff
@@ -44,46 +44,6 @@ 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);
@@ -0,0 +1,37 @@
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();
});
});
@@ -0,0 +1,225 @@
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();
});
@@ -1,368 +0,0 @@
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 preserve form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
test('should flatten 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);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should preserve form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -157,6 +157,7 @@ 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();
@@ -183,10 +184,13 @@ test.describe('Form Flattening', () => {
},
});
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
});
@@ -743,10 +747,11 @@ 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(true);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
test('should handle partial formValues (only some fields)', async ({ request }) => {
@@ -793,11 +798,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(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe('Only this field');
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
});
});
@@ -142,3 +142,38 @@ 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
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
@@ -43,7 +43,6 @@ type EnvelopeRenderItem = {
title: string;
order: number;
envelopeId: string;
documentDataId: string;
/**
* The PDF data to render.
-15
View File
@@ -1,25 +1,10 @@
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,3 +9,13 @@
* 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'];
@@ -0,0 +1,26 @@
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: false,
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -1,349 +0,0 @@
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: false,
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -1,834 +0,0 @@
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 { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
@@ -22,39 +22,6 @@ 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 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
import { getQuotaWarningCount } from '../../universal/quota-usage';
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 = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const warningCount = getQuotaWarningCount(quota);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
+91 -49
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-22 13:53\n"
"PO-Revision-Date: 2026-07-02 07:07\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -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"
msgstr "Rate-Limit hinzufügen"
msgid "Add rate limit window"
msgstr "Rate-Limit-Zeitfenster hinzufügen"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ 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"
@@ -2017,10 +2018,6 @@ 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"
@@ -2681,6 +2678,10 @@ 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 "Für diese Organisation aktivierte Funktionen."
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4407,10 +4408,6 @@ 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"
@@ -4546,6 +4543,7 @@ 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
@@ -4995,10 +4993,6 @@ 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"
@@ -5094,6 +5088,7 @@ 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
@@ -5107,16 +5102,14 @@ 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 "Leeres Kontingent bedeutet unbegrenzt, 0 blockiert die Ressource. Rate-Limit-Zeitfenster akzeptieren Werte wie 5m, 1h oder 24h."
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Aktivieren"
@@ -5236,6 +5229,10 @@ 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 "Geben Sie eine maximale Anfragenanzahl größer als 0 ein"
#: 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."
@@ -5244,6 +5241,10 @@ 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 "Geben Sie ein Zeitfenster ein, z. B. 5m"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Anspruchsname eingeben"
@@ -5502,6 +5503,10 @@ 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 "Überschritten"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Zeitüberschreitung überschritten"
@@ -6765,6 +6770,10 @@ 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 "Limit erreicht"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limits"
@@ -7070,6 +7079,10 @@ msgstr "MAU (angemeldet)"
msgid "Max"
msgstr "Max"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr "Maximale Anfragen"
#: 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."
@@ -7116,12 +7129,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
@@ -7190,16 +7203,12 @@ 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 API quota"
msgstr "Monatliches API-Kontingent"
msgid "Monthly quota"
msgstr "Monatliches Kontingent"
#: 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/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "Monatliche Nutzung"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7290,7 +7299,6 @@ 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"
@@ -7298,6 +7306,10 @@ 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 "Nahe am Limit"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Müssen Dokumente signieren?"
@@ -7437,6 +7449,10 @@ 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 "Kein geerbter Anspruch"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Keine Lizenz konfiguriert"
@@ -7947,7 +7963,7 @@ msgstr "Andernfalls wird das Dokument als Entwurf erstellt."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
msgstr "Überlappende Felder erkannt"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
@@ -8153,6 +8169,10 @@ 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 "Personen mit Zugriff auf diese Organisation."
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8315,10 +8335,6 @@ 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."
@@ -8927,7 +8943,7 @@ msgstr "Weiterleitung"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
msgstr "Weiterleitung zu {0}..."
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -9057,6 +9073,10 @@ 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 "Rate-Limit entfernen"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Empfänger entfernen"
@@ -9247,6 +9267,10 @@ msgstr "Zahlung klären"
msgid "Resolve payment"
msgstr "Zahlung klären"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr "Ressource blockiert"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Antwort"
@@ -9823,6 +9847,10 @@ msgstr "Senden..."
msgid "Sent"
msgstr "Gesendet"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr "In diesem Zeitraum gesendet"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sitzung widerrufen"
@@ -10233,7 +10261,7 @@ msgstr "Überspringen"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
msgstr "Einige Felder sind übereinander platziert. Dies kann den Signaturvorgang erschweren oder dazu führen, dass Felder nicht wie erwartet funktionieren."
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -10805,10 +10833,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
@@ -10819,6 +10847,10 @@ 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 "Teams, die zu dieser Organisation gehören."
#: 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"
@@ -12297,8 +12329,6 @@ 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"
@@ -12317,7 +12347,7 @@ msgstr "Lösen"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
msgstr "Nicht gespeicherte Änderungen"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
@@ -12591,15 +12621,18 @@ 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 "Verwenden Sie eine Dauer mit Einheit, z. B. 5m, 1h oder 24h"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr "Verwenden Sie ein eigenes Zeitfenster für jedes Rate-Limit"
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13501,10 +13534,18 @@ msgstr "Whitelabeling, unbegrenzte Mitglieder und mehr"
msgid "Width:"
msgstr "Breite:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr "Zeitfenster"
#: 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 "Innerhalb des Limits"
#: 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"
@@ -14049,7 +14090,7 @@ msgstr "Sie haben das Recht, Ihre Zustimmung zur Verwendung elektronischer Unter
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
msgstr "Sie haben nicht gespeicherte Änderungen"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}."
@@ -14730,3 +14771,4 @@ msgstr "Ihr Verifizierungscode:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+91 -49
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-22 13:53\n"
"PO-Revision-Date: 2026-07-02 07:07\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -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"
msgstr "Agregar límite de velocidad"
msgid "Add rate limit window"
msgstr "Agregar ventana de límite de frecuencia"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ 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"
@@ -2017,10 +2018,6 @@ 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"
@@ -2681,6 +2678,10 @@ 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 "Capacidades habilitadas para esta organización."
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4407,10 +4408,6 @@ 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"
@@ -4546,6 +4543,7 @@ 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
@@ -4995,10 +4993,6 @@ 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"
@@ -5094,6 +5088,7 @@ 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
@@ -5107,16 +5102,14 @@ 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 "Una cuota vacía significa sin límite, 0 bloquea el recurso. Las ventanas de límite de frecuencia aceptan valores como 5m, 1h o 24h."
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Habilitar"
@@ -5236,6 +5229,10 @@ 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 "Introduce un número máximo de solicitudes mayor que 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 "Ingrese un nombre para su nueva carpeta. Las carpetas le ayudan a organizar sus elementos."
@@ -5244,6 +5241,10 @@ 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 "Introduce una ventana, p. ej., 5m"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Ingresar nombre de la reclamación"
@@ -5502,6 +5503,10 @@ 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 "Excedido"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Tiempo de espera excedido"
@@ -6765,6 +6770,10 @@ 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 "Límite alcanzado"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Límites"
@@ -7070,6 +7079,10 @@ 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 "Solicitudes máximas"
#: 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."
@@ -7116,12 +7129,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
@@ -7190,16 +7203,12 @@ 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 API quota"
msgstr "Cuota mensual de API"
msgid "Monthly quota"
msgstr "Cuota mensual"
#: 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/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "Uso mensual"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7290,7 +7299,6 @@ 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"
@@ -7298,6 +7306,10 @@ 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 "Cerca del límite"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "¿Necesitas firmar documentos?"
@@ -7437,6 +7449,10 @@ 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 "Sin reclamación heredada"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Licencia no configurada"
@@ -7947,7 +7963,7 @@ msgstr "De lo contrario, el documento se creará como un borrador."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
msgstr "Se detectaron campos superpuestos"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
@@ -8153,6 +8169,10 @@ 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 "Personas con acceso a esta organización."
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8315,10 +8335,6 @@ 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"
@@ -8927,7 +8943,7 @@ msgstr "Redireccionando"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
msgstr "Redirigiendo a {0}..."
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -9057,6 +9073,10 @@ 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 "Eliminar límite de velocidad"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Eliminar destinatario"
@@ -9247,6 +9267,10 @@ msgstr "Resolver"
msgid "Resolve payment"
msgstr "Resolver pago"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr "Recurso bloqueado"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Respuesta"
@@ -9823,6 +9847,10 @@ msgstr "Enviando..."
msgid "Sent"
msgstr "Enviado"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr "Enviado en este período"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sesión revocada"
@@ -10233,7 +10261,7 @@ msgstr "Omitir"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
msgstr "Algunos campos están colocados uno encima de otro. Esto puede complicar el proceso de firma o hacer que los campos no funcionen como se espera."
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -10805,10 +10833,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
@@ -10819,6 +10847,10 @@ 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 "Equipos que pertenecen a esta organización."
#: 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"
@@ -12297,8 +12329,6 @@ 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"
@@ -12317,7 +12347,7 @@ msgstr "Desanclar"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
msgstr "Cambios sin guardar"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
@@ -12591,15 +12621,18 @@ 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 "Usa una duración con una unidad, p. ej., 5m, 1h o 24h"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr "Usar una ventana única para cada límite de velocidad"
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13501,10 +13534,18 @@ 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 "Ventana"
#: 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 "Dentro del límite"
#: 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"
@@ -14049,7 +14090,7 @@ msgstr "Usted tiene el derecho de retirar su consentimiento para usar firmas ele
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
msgstr "Tienes cambios sin guardar"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}."
@@ -14730,3 +14771,4 @@ msgstr "Su código de verificación:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "su-dominio.com otro-dominio.com"
+92 -50
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-22 13:53\n"
"PO-Revision-Date: 2026-07-02 07:07\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -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"
msgstr "Ajouter une limite de fréquence"
msgid "Add rate limit window"
msgstr "Ajouter une fenêtre de limitation de débit"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ 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"
@@ -2017,10 +2018,6 @@ 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"
@@ -2681,6 +2678,10 @@ 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 "Fonctionnalités activées pour cette organisation."
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4407,10 +4408,6 @@ 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"
@@ -4546,6 +4543,7 @@ 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
@@ -4995,10 +4993,6 @@ 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é"
@@ -5094,6 +5088,7 @@ 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
@@ -5107,16 +5102,14 @@ 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 "Un quota vide signifie illimité, 0 bloque la ressource. Les fenêtres de limitation de débit acceptent des valeurs comme 5m, 1h ou 24h."
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Activer"
@@ -5236,6 +5229,10 @@ 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 "Saisissez un nombre maximal de requêtes supérieur à 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 "Entrez un nom pour votre nouveau dossier. Les dossiers vous aident à organiser vos éléments."
@@ -5244,6 +5241,10 @@ 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 "Saisissez une fenêtre, par ex. 5m"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Entrez le nom de la réclamation"
@@ -5502,6 +5503,10 @@ 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 "Dépassé"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Délai dépassé"
@@ -6765,6 +6770,10 @@ 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 "Limite atteinte"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limites"
@@ -7070,6 +7079,10 @@ msgstr "MAU (connecté)"
msgid "Max"
msgstr "Maximum"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr "Nombre maximal de requêtes"
#: 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."
@@ -7116,12 +7129,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
@@ -7190,16 +7203,12 @@ 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 API quota"
msgstr "Quota dAPI mensuel"
msgid "Monthly quota"
msgstr "Quota mensuelle"
#: 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/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "Utilisation mensuelle"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7290,7 +7299,6 @@ 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"
@@ -7298,6 +7306,10 @@ 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 "Proche de la limite"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Besoin de signer des documents ?"
@@ -7437,6 +7449,10 @@ 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 "Aucun droit hérité"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Aucune licence configurée"
@@ -7947,7 +7963,7 @@ msgstr "Sinon, le document sera créé sous forme de brouillon."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
msgstr "Chevauchement de champs détecté"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
@@ -8153,6 +8169,10 @@ 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 "Personnes ayant accès à cette organisation."
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8315,10 +8335,6 @@ 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"
@@ -8431,7 +8447,7 @@ msgstr "Veuillez réessayer ou contacter notre support."
#. placeholder {0}: `'${t(deleteMessage)}'`
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please type {0} to confirm"
msgstr "Veuillez taper {0} pour confirmer"
msgstr "Veuiillez taper {0} pour confirmer"
#. placeholder {0}: user.email
#: apps/remix/app/components/dialogs/account-delete-dialog.tsx
@@ -8927,7 +8943,7 @@ msgstr "Redirection"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
msgstr "Redirection vers {0}..."
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -9057,6 +9073,10 @@ 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 "Supprimer la limite de débit"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Supprimer le destinataire"
@@ -9247,6 +9267,10 @@ msgstr "Résoudre"
msgid "Resolve payment"
msgstr "Résoudre le paiement"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr "Ressource bloquée"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Réponse"
@@ -9823,6 +9847,10 @@ msgstr "Envoi..."
msgid "Sent"
msgstr "Envoyé"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr "Envoyé pendant cette période"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Session révoquée"
@@ -10233,7 +10261,7 @@ msgstr "Ignorer"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
msgstr "Certains champs sont placés les uns sur les autres. Cela peut compliquer le processus de signature ou empêcher certains champs de fonctionner comme prévu."
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -10805,10 +10833,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
@@ -10819,6 +10847,10 @@ 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 "Équipes appartenant à cette organisation."
#: 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é"
@@ -12297,8 +12329,6 @@ 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é"
@@ -12317,7 +12347,7 @@ msgstr "Détacher"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
msgstr "Modifications non enregistrées"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
@@ -12591,15 +12621,18 @@ 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 "Utilisez une durée avec une unité, par ex. 5m, 1h ou 24h"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr "Utiliser une fenêtre unique pour chaque limite de débit"
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13501,10 +13534,18 @@ 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 "Fenêtre"
#: 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 "Dans la limite"
#: 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"
@@ -14049,7 +14090,7 @@ msgstr "Vous avez le droit de retirer votre consentement à l'utilisation des si
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
msgstr "Vous avez des modifications non enregistrées"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}."
@@ -14730,3 +14771,4 @@ msgstr "Votre code de vérification :"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+91 -49
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: it\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-22 13:53\n"
"PO-Revision-Date: 2026-07-02 07:07\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Aggiungi segnaposto"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Aggiungi limite di velocità"
msgid "Add rate limit window"
msgstr "Aggiungi finestra di limitazione della frequenza (rate limit)"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ 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"
@@ -2017,10 +2018,6 @@ 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"
@@ -2681,6 +2678,10 @@ 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 "Funzionalità abilitate per questa organizzazione."
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4407,10 +4408,6 @@ 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"
@@ -4546,6 +4543,7 @@ 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
@@ -4995,10 +4993,6 @@ 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"
@@ -5094,6 +5088,7 @@ 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
@@ -5107,16 +5102,14 @@ 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 "Quota vuota significa illimitata, 0 blocca la risorsa. Le finestre di limitazione della frequenza accettano valori come 5m, 1h o 24h."
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Abilita"
@@ -5236,6 +5229,10 @@ 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 "Inserisci un numero massimo di richieste maggiore di 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 "Inserisci un nome per la tua nuova cartella. Le cartelle ti aiutano a organizzare i tuoi elementi."
@@ -5244,6 +5241,10 @@ 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 "Inserisci una finestra, ad es. 5m"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Inserisci nome richiesta"
@@ -5502,6 +5503,10 @@ 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 "Superato"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Tempo scaduto"
@@ -6765,6 +6770,10 @@ 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 "Limite raggiunto"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limiti"
@@ -7070,6 +7079,10 @@ msgstr "MAU (autenticati)"
msgid "Max"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr "Numero massimo di richieste"
#: 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."
@@ -7116,12 +7129,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
@@ -7190,16 +7203,12 @@ 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 API quota"
msgstr "Quota API mensile"
msgid "Monthly quota"
msgstr "Quota mensile"
#: 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/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "Utilizzo mensile"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7290,7 +7299,6 @@ 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"
@@ -7298,6 +7306,10 @@ msgstr "Nome richiesto"
msgid "Name Settings"
msgstr "Impostazioni Nome"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr "Vicino al limite"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Hai bisogno di firmare documenti?"
@@ -7437,6 +7449,10 @@ 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 "Nessun diritto ereditato"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Nessuna licenza configurata"
@@ -7947,7 +7963,7 @@ msgstr "Altrimenti, il documento sarà creato come bozza."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
msgstr "Campi sovrapposti rilevati"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
@@ -8153,6 +8169,10 @@ 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 "Persone con accesso a questa organizzazione."
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8315,10 +8335,6 @@ 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"
@@ -8927,7 +8943,7 @@ msgstr "Reindirizzamento"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
msgstr "Reindirizzamento a {0}..."
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -9057,6 +9073,10 @@ 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 "Rimuovi limite di velocità"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Rimuovi destinatario"
@@ -9247,6 +9267,10 @@ msgstr "Risolvi"
msgid "Resolve payment"
msgstr "Risolvere il pagamento"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr "Risorsa bloccata"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Risposta"
@@ -9823,6 +9847,10 @@ msgstr "Invio..."
msgid "Sent"
msgstr "Inviato"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr "Inviato in questo periodo"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sessione revocata"
@@ -10233,7 +10261,7 @@ msgstr "Salta"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
msgstr "Alcuni campi sono posizionati uno sopra laltro. Questo può complicare il processo di firma o causare il malfunzionamento dei campi."
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -10805,10 +10833,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
@@ -10819,6 +10847,10 @@ 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 "Team che appartengono a questa organizzazione."
#: 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"
@@ -12297,8 +12329,6 @@ 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"
@@ -12317,7 +12347,7 @@ msgstr "Rimuovi"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
msgstr "Modifiche non salvate"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
@@ -12591,15 +12621,18 @@ 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 "Usa una durata con ununità, ad es. 5m, 1h o 24h"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr "Usa una finestra unica per ogni limite di velocità"
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13501,10 +13534,18 @@ msgstr "White label, membri illimitati e altro"
msgid "Width:"
msgstr "Larghezza:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr "Finestra"
#: 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 "Entro il limite"
#: 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"
@@ -14049,7 +14090,7 @@ msgstr "Hai il diritto di ritirare il tuo consenso all'uso delle firme elettroni
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
msgstr "Hai modifiche non salvate"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}."
@@ -14730,3 +14771,4 @@ msgstr "Il tuo codice di verifica:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "tuo-dominio.com altro-dominio.com"
+91 -49
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ja\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-22 13:53\n"
"PO-Revision-Date: 2026-07-02 07:07\n"
"Last-Translator: \n"
"Language-Team: Japanese\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "プレースホルダーを追加"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "レート制限を追加"
msgid "Add rate limit window"
msgstr "レート制限ウィンドウを追加"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ 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"
@@ -2017,10 +2018,6 @@ 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 リクエスト"
@@ -2681,6 +2678,10 @@ 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"
@@ -4407,10 +4408,6 @@ 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"
@@ -4546,6 +4543,7 @@ 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
@@ -4995,10 +4993,6 @@ 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 "保留中のドキュメントが削除されたときに受信者へメール通知する"
@@ -5094,6 +5088,7 @@ 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
@@ -5107,16 +5102,14 @@ 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 "空のクオータは無制限を意味し、0 はリソースをブロックします。レート制限ウィンドウには 5m、1h、24h などの値を指定できます。"
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "有効化"
@@ -5236,6 +5229,10 @@ 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 "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 "新しいフォルダ名を入力してください。フォルダを使うとアイテムを整理できます。"
@@ -5244,6 +5241,10 @@ 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 "ウィンドウを入力してください(例: 5m)"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "クレーム名を入力"
@@ -5502,6 +5503,10 @@ 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 "タイムアウトを超えました"
@@ -6765,6 +6770,10 @@ 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 "上限"
@@ -7070,6 +7079,10 @@ 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 行。空の値はテンプレートのデフォルトが使用されます。"
@@ -7116,12 +7129,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
@@ -7190,16 +7203,12 @@ 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 API quota"
msgstr "月間 API クォータ"
msgid "Monthly quota"
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/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "月間使用量"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7290,7 +7299,6 @@ 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 "名前は必須です"
@@ -7298,6 +7306,10 @@ 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 "文書への署名が必要ですか?"
@@ -7437,6 +7449,10 @@ 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 "ライセンスが設定されていません"
@@ -7947,7 +7963,7 @@ msgstr "チェックを入れない場合、文書は下書きとして作成さ
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
msgstr "フィールドの重なりが検出されました"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
@@ -8153,6 +8169,10 @@ 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"
@@ -8315,10 +8335,6 @@ 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 "有効な数値を入力してください"
@@ -8927,7 +8943,7 @@ msgstr "リダイレクト中"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
msgstr "{0} にリダイレクトしています…"
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -9057,6 +9073,10 @@ 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 "受信者を削除"
@@ -9247,6 +9267,10 @@ 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 "レスポンス"
@@ -9823,6 +9847,10 @@ 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 "セッションを取り消しました"
@@ -10233,7 +10261,7 @@ msgstr "スキップ"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
msgstr "一部のフィールドが互いに重なるように配置されています。これにより、署名プロセスが複雑になったり、フィールドが期待どおりに動作しない可能性があります。"
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -10805,10 +10833,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
@@ -10819,6 +10847,10 @@ 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 "この組織グループが現在割り当てられているチーム"
@@ -12297,8 +12329,6 @@ 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 "無制限"
@@ -12317,7 +12347,7 @@ msgstr "ピン留めを解除"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
msgstr "未保存の変更があります"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
@@ -12591,15 +12621,18 @@ 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 "5m、1h、24h のように、単位付きの時間を使用してください"
#: 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"
@@ -13501,10 +13534,18 @@ 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 "公開プロフィールに表示する説明文を入力してください"
@@ -14049,7 +14090,7 @@ msgstr "署名プロセスを完了する前であれば、電子署名の利用
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
msgstr "変更内容が保存されていません"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}."
@@ -14730,3 +14771,4 @@ msgstr "認証コード:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+91 -49
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ko\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-22 13:53\n"
"PO-Revision-Date: 2026-07-02 07:07\n"
"Last-Translator: \n"
"Language-Team: Korean\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "플레이스홀더 추가"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "요청 한도 추가"
msgid "Add rate limit window"
msgstr "요청 제한(레이트 리밋) 기간 추가하기"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ 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"
@@ -2017,10 +2018,6 @@ 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 요청"
@@ -2681,6 +2678,10 @@ 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"
@@ -4407,10 +4408,6 @@ 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"
@@ -4546,6 +4543,7 @@ 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
@@ -4995,10 +4993,6 @@ 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 "보류 중인 문서가 삭제되면 수신자에게 이메일 보내기"
@@ -5094,6 +5088,7 @@ 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
@@ -5107,16 +5102,14 @@ 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 "쿼터를 비워 두면 무제한이며, 0으로 설정하면 리소스가 차단됩니다. 요청 제한(레이트 리밋) 기간은 5m, 1h, 24h와 같은 값을 허용합니다."
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "활성화"
@@ -5236,6 +5229,10 @@ 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 "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 "새 폴더 이름을 입력하세요. 폴더는 항목을 정리하는 데 도움이 됩니다."
@@ -5244,6 +5241,10 @@ 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 "예: 5m 과 같은 기간을 입력하세요."
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "클레임 이름 입력"
@@ -5502,6 +5503,10 @@ 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 "시간 초과됨"
@@ -6765,6 +6770,10 @@ 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 "제한"
@@ -7070,6 +7079,10 @@ 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행. 비어 있는 값은 템플릿 기본값이 사용됩니다."
@@ -7116,12 +7129,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
@@ -7190,16 +7203,12 @@ 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 API quota"
msgstr "월간 API 할당량"
msgid "Monthly quota"
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/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "월별 사용량"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7290,7 +7299,6 @@ 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 "이름은 필수 항목입니다."
@@ -7298,6 +7306,10 @@ 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 "문서에 서명이 필요하신가요?"
@@ -7437,6 +7449,10 @@ 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 "라이선스가 구성되지 않았습니다"
@@ -7947,7 +7963,7 @@ msgstr "그렇지 않으면 문서는 초안으로 생성됩니다."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
msgstr "필드가 서로 겹쳐 있습니다"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
@@ -8153,6 +8169,10 @@ 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"
@@ -8315,10 +8335,6 @@ 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 "올바른 숫자를 입력하세요"
@@ -8927,7 +8943,7 @@ msgstr "리디렉션 중"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
msgstr "{0}(으)로 리디렉션하는 중입니다..."
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -9057,6 +9073,10 @@ 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 "수신자 제거"
@@ -9247,6 +9267,10 @@ 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 "응답"
@@ -9823,6 +9847,10 @@ 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 "세션이 해지되었습니다."
@@ -10233,7 +10261,7 @@ msgstr "건너뛰기"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
msgstr "일부 필드가 서로 겹쳐 배치되어 있습니다. 이는 서명 과정을 복잡하게 만들거나 필드가 예상대로 작동하지 않게 할 수 있습니다."
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -10805,10 +10833,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
@@ -10819,6 +10847,10 @@ 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 "이 조직 그룹이 현재 할당된 팀"
@@ -12297,8 +12329,6 @@ 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 "무제한"
@@ -12317,7 +12347,7 @@ msgstr "고정 해제"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
msgstr "저장되지 않은 변경 사항"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
@@ -12591,15 +12621,18 @@ 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 "단위가 포함된 기간을 사용하세요. 예: 5m, 1h, 24h"
#: 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"
@@ -13501,10 +13534,18 @@ 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 "공개 프로필에 표시될 설명을 작성하세요."
@@ -14049,7 +14090,7 @@ msgstr "전자 서명을 완료하기 전 언제든지 전자 서명 사용에
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
msgstr "저장되지 않은 변경 사항이 있습니다"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}."
@@ -14730,3 +14771,4 @@ msgstr "인증 코드:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+91 -49
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: nl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-22 13:53\n"
"PO-Revision-Date: 2026-07-02 07:07\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Placeholders toevoegen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Snelheidslimiet toevoegen"
msgid "Add rate limit window"
msgstr "Venster voor snelheidslimiet toevoegen"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ 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"
@@ -2017,10 +2018,6 @@ 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"
@@ -2681,6 +2678,10 @@ 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 "Functies ingeschakeld voor deze organisatie."
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4407,10 +4408,6 @@ 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"
@@ -4546,6 +4543,7 @@ 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
@@ -4995,10 +4993,6 @@ 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"
@@ -5094,6 +5088,7 @@ 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
@@ -5107,16 +5102,14 @@ 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 "Een lege quota betekent onbeperkt, 0 blokkeert de resource. Vensters voor snelheidslimieten accepteren waarden zoals 5m, 1u of 24u."
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Inschakelen"
@@ -5236,6 +5229,10 @@ 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 "Voer een maximaal aantal aanvragen groter dan 0 in"
#: 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."
@@ -5244,6 +5241,10 @@ 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 "Voer een venster in, bijv. 5m"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Voer claimnaam in"
@@ -5502,6 +5503,10 @@ 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 "Overschreden"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Timeout overschreden"
@@ -6765,6 +6770,10 @@ 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 "Limiet bereikt"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limieten"
@@ -7070,6 +7079,10 @@ msgstr "MAU (ingelogd)"
msgid "Max"
msgstr "Max"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr "Maximaal aantal aanvragen"
#: 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."
@@ -7116,12 +7129,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
@@ -7190,16 +7203,12 @@ 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 API quota"
msgstr "Maandelijkse API-limiet"
msgid "Monthly quota"
msgstr "Maandelijkse limiet"
#: 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/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "Maandelijks gebruik"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7290,7 +7299,6 @@ 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"
@@ -7298,6 +7306,10 @@ msgstr "Naam is verplicht"
msgid "Name Settings"
msgstr "Naam-instellingen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr "Dicht bij limiet"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Moet je documenten ondertekenen?"
@@ -7437,6 +7449,10 @@ 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 "Geen overgeërfde claim"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Geen licentie geconfigureerd"
@@ -7947,7 +7963,7 @@ msgstr "Anders wordt het document als concept aangemaakt."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
msgstr "Overlappende velden gedetecteerd"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
@@ -8153,6 +8169,10 @@ 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 "Mensen met toegang tot deze organisatie."
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8315,10 +8335,6 @@ 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"
@@ -8927,7 +8943,7 @@ msgstr "Doorsturen"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
msgstr "Doorsturen naar {0}..."
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -9057,6 +9073,10 @@ 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 "Snelheidslimiet verwijderen"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Ontvanger verwijderen"
@@ -9247,6 +9267,10 @@ msgstr "Oplossen"
msgid "Resolve payment"
msgstr "Betaling oplossen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr "Bron geblokkeerd"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Antwoord"
@@ -9823,6 +9847,10 @@ msgstr "Verzenden..."
msgid "Sent"
msgstr "Verzonden"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr "In deze periode verzonden"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sessie ingetrokken"
@@ -10233,7 +10261,7 @@ msgstr "Overslaan"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
msgstr "Sommige velden zijn boven op elkaar geplaatst. Dit kan het ondertekenproces bemoeilijken of ervoor zorgen dat velden niet naar verwachting werken."
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -10805,10 +10833,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
@@ -10819,6 +10847,10 @@ 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 "Teams die bij deze organisatie horen."
#: 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"
@@ -12297,8 +12329,6 @@ 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"
@@ -12317,7 +12347,7 @@ msgstr "Losmaken"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
msgstr "Niet-opgeslagen wijzigingen"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
@@ -12591,15 +12621,18 @@ 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 "Gebruik een duur met een eenheid, bijv. 5m, 1u of 24u"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr "Gebruik een uniek venster voor elke snelheidslimiet"
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13501,10 +13534,18 @@ msgstr "Whitelabeling, onbeperkte leden en meer"
msgid "Width:"
msgstr "Breedte:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr "Venster"
#: 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 "Binnen de limiet"
#: 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"
@@ -14049,7 +14090,7 @@ msgstr "Je hebt het recht je toestemming voor het gebruik van elektronische hand
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
msgstr "U hebt niet-opgeslagen wijzigingen"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}."
@@ -14730,3 +14771,4 @@ msgstr "Uw verificatiecode:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
File diff suppressed because it is too large Load Diff
+91 -49
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-22 13:53\n"
"PO-Revision-Date: 2026-07-02 07:07\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "添加占位符"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "添加速率限制"
msgid "Add rate limit window"
msgstr "添加速率限制窗口"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ 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"
@@ -2017,10 +2018,6 @@ 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 请求"
@@ -2681,6 +2678,10 @@ 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"
@@ -4407,10 +4408,6 @@ 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"
@@ -4546,6 +4543,7 @@ 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
@@ -4995,10 +4993,6 @@ 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 "在待处理文档被删除时向收件人发送电子邮件通知"
@@ -5094,6 +5088,7 @@ 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
@@ -5107,16 +5102,14 @@ 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 "配额留空表示无限制,填入 0 则会阻止访问该资源。速率限制时间窗口可使用例如 5m、1h 或 24h 这样的值。"
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "启用"
@@ -5236,6 +5229,10 @@ 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 "请输入大于 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 "为新文件夹输入名称。文件夹可以帮助您整理项目。"
@@ -5244,6 +5241,10 @@ 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 "请输入一个时间窗口,例如 5m"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "输入声明名称"
@@ -5502,6 +5503,10 @@ 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 "超时"
@@ -6765,6 +6770,10 @@ 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 "限制"
@@ -7070,6 +7079,10 @@ msgstr "月活跃用户(已登录)"
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 行。空值将使用模板默认值。"
@@ -7116,12 +7129,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
@@ -7190,16 +7203,12 @@ 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 API quota"
msgstr "每月 API 配额"
msgid "Monthly quota"
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/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "每月使用量"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7290,7 +7299,6 @@ 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 "名称为必填项"
@@ -7298,6 +7306,10 @@ 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 "需要签署文档?"
@@ -7437,6 +7449,10 @@ 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 "未配置许可证"
@@ -7947,7 +7963,7 @@ msgstr "否则将把文档创建为草稿。"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
msgstr "检测到字段重叠"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
@@ -8153,6 +8169,10 @@ 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"
@@ -8315,10 +8335,6 @@ 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 "请输入有效的数字"
@@ -8927,7 +8943,7 @@ msgstr "正在重定向"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
msgstr "正在重定向到 {0}…"
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
@@ -9057,6 +9073,10 @@ 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 "移除收件人"
@@ -9247,6 +9267,10 @@ 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 "响应"
@@ -9823,6 +9847,10 @@ 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 "会话已撤销"
@@ -10233,7 +10261,7 @@ msgstr "跳过"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
msgstr "有些字段彼此重叠放置。这可能会使签署流程变得复杂,或导致字段无法按预期工作。"
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -10805,10 +10833,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
@@ -10819,6 +10847,10 @@ 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 "当前已将此组织组分配给以下团队"
@@ -12297,8 +12329,6 @@ 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 "不限"
@@ -12317,7 +12347,7 @@ msgstr "取消固定"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
msgstr "未保存的更改"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
@@ -12591,15 +12621,18 @@ 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 "使用带有单位的时长,例如 5m、1h 或 24h"
#: 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"
@@ -13501,10 +13534,18 @@ 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 "撰写将在您的公共主页上展示的简介"
@@ -14049,7 +14090,7 @@ msgstr "在完成签署流程之前,你有权随时撤回对使用电子签名
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
msgstr "你有未保存的更改"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}."
@@ -14730,3 +14771,4 @@ msgstr "您的验证码:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
-1
View File
@@ -72,7 +72,6 @@ export const ZBaseFieldMeta = z.object({
readOnly: z.boolean().optional(),
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
overflow: ZFieldOverflowMode.optional(),
source: z.enum(['acroform']).optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { ZNameSchema } from './name';
describe('ZNameSchema', () => {
describe('valid names', () => {
it('accepts a normal name', () => {
expect(ZNameSchema.safeParse('Example User')).toEqual({
success: true,
data: 'Example User',
});
});
it('accepts international characters', () => {
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
success: true,
data: 'Døcumensø Üser',
});
});
it('trims surrounding whitespace', () => {
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
success: true,
data: 'Documenso User',
});
});
it('accepts names at the minimum length', () => {
expect(ZNameSchema.safeParse('DU')).toEqual({
success: true,
data: 'DU',
});
});
it('accepts names at the maximum length', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
expect(name.length).toBe(100);
expect(ZNameSchema.safeParse(name)).toEqual({
success: true,
data: name,
});
});
});
describe('length validation', () => {
it('rejects names shorter than 2 characters', () => {
expect(ZNameSchema.safeParse('D')).toMatchObject({
success: false,
error: {
issues: [{ message: 'Please enter a valid name.' }],
},
});
});
it('rejects names longer than 100 characters', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
expect(name.length).toBe(101);
expect(ZNameSchema.safeParse(name)).toMatchObject({
success: false,
error: {
issues: [{ message: 'Name cannot be more than 100 characters.' }],
},
});
});
it('rejects whitespace-only input after trim', () => {
expect(ZNameSchema.safeParse(' ')).toMatchObject({
success: false,
});
});
});
describe('URL validation', () => {
it.each([
'https://example.com',
'http://example.com',
'HTTPS://EXAMPLE.COM',
'Northwind www.example.com',
'www.example.com',
])('rejects URLs in names: %s', (value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
},
});
});
});
describe('invalid character validation', () => {
it.each([
['NUL character', 'Acme\u0000Corp'],
['zero-width space', 'Acme\u200bCorp'],
['bidi override', 'Acme\u202eCorp'],
['byte order mark', 'Acme\ufeffCorp'],
['lone surrogate', 'Acme\ud800Corp'],
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
['noncharacter', 'Acme\ufffeCorp'],
['private use character', 'Acme\ue000Corp'],
['Hangul filler', 'Acme\u3164Corp'],
['braille blank', 'Acme\u2800Corp'],
['combining grapheme joiner', 'Acme\u034fCorp'],
])('rejects names containing a %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['fixed form', String.raw`Acme\u200bCorp`],
['uppercase U', String.raw`Acme\U200BCorp`],
['braced form', String.raw`Acme\u{200b}Corp`],
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
['lone surrogate', String.raw`Acme\ud800Corp`],
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['escape of a valid code point', String.raw`Acme\u0041Corp`],
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
['astral characters such as emoji', 'Acme 😀 Corp'],
['emoji with a variation selector', 'I ❤️ Docs'],
])('accepts %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: true,
});
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Characters that render as empty/invisible or break text layout:
*
* - `\p{C}` - control, format, lone surrogate, private use and
* unassigned code points (NUL, zero-width spaces, bidi
* overrides, BOM, tag characters, noncharacters).
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
* character class because it is a combining mark, which
* lint rules reject inside classes.
* - remaining - letters that render as blank (Hangul fillers, braille blank).
*
* The `\p{...}` classes are maintained by the Unicode database, so newly
* assigned characters in these categories are covered automatically.
*/
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
/**
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
* break rendering downstream if anything decodes them.
*/
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
const hasInvalidEscapeSequence = (value: string) => {
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
if (codePoint > 0x10ffff) {
continue;
}
// Decode the escape and run it through the same character policy as the
// unescaped check, so the two can never drift apart.
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
return true;
}
}
return false;
};
export const hasInvalidTextCharacters = (value: string) =>
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering,
* and invisible/control characters that render as empty or break the UI.
*/
export const ZNameSchema = z
.string()
.trim()
.min(2, { message: 'Please enter a valid name.' })
.max(100, { message: 'Name cannot be more than 100 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
})
.refine((value) => !hasInvalidTextCharacters(value), {
message: 'Name contains invalid characters.',
});
export type TName = z.infer<typeof ZNameSchema>;
+32 -7
View File
@@ -6,14 +6,39 @@ import { z } from 'zod';
*
* Example: "5m", "1h", "1d"
*/
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
export const ZRateLimitArraySchema = z.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
);
const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
message: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
});
export const ZRateLimitArraySchema = z
.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
)
.superRefine((entries, ctx) => {
const windows = new Set<string>();
entries.forEach((entry, index) => {
const window = entry.window.trim();
if (windows.has(window)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
path: [index, 'window'],
});
}
windows.add(window);
});
});
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
getQuotaUsagePercent,
getQuotaWarningCount,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from './quota-usage';
describe('isQuotaExceeded', () => {
it('treats null quota as unlimited (never exceeded)', () => {
expect(isQuotaExceeded(null, 0)).toBe(false);
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
});
it('treats a zero quota as blocked (always exceeded)', () => {
expect(isQuotaExceeded(0, 0)).toBe(true);
expect(isQuotaExceeded(0, 5)).toBe(true);
});
it('is exceeded once usage reaches the quota (>= boundary)', () => {
expect(isQuotaExceeded(10, 9)).toBe(false);
expect(isQuotaExceeded(10, 10)).toBe(true);
expect(isQuotaExceeded(10, 11)).toBe(true);
});
});
describe('getQuotaWarningCount', () => {
it('rounds the 80% threshold up', () => {
expect(getQuotaWarningCount(10)).toBe(8);
expect(getQuotaWarningCount(100)).toBe(80);
// 5 * 0.8 = 4 exactly.
expect(getQuotaWarningCount(5)).toBe(4);
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
expect(getQuotaWarningCount(3)).toBe(3);
});
});
describe('isQuotaNearing', () => {
it('is never nearing for unlimited or blocked quotas', () => {
expect(isQuotaNearing(null, 5)).toBe(false);
expect(isQuotaNearing(0, 5)).toBe(false);
});
it('is nearing from the warning threshold up to (but not including) the quota', () => {
expect(isQuotaNearing(10, 7)).toBe(false);
expect(isQuotaNearing(10, 8)).toBe(true);
expect(isQuotaNearing(10, 9)).toBe(true);
});
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
expect(isQuotaNearing(10, 10)).toBe(false);
expect(isQuotaNearing(10, 11)).toBe(false);
});
it('can never fire for tiny quotas where the warning count equals the quota', () => {
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
expect(isQuotaNearing(3, 2)).toBe(false);
expect(isQuotaNearing(3, 3)).toBe(false);
});
it('agrees with the warning-count helper at the boundary', () => {
const quota = 250;
const warningCount = getQuotaWarningCount(quota);
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
expect(isQuotaNearing(quota, warningCount)).toBe(true);
});
});
describe('getQuotaUsagePercent', () => {
it('returns 0 for unlimited or non-positive quotas', () => {
expect(getQuotaUsagePercent(5, null)).toBe(0);
expect(getQuotaUsagePercent(5, 0)).toBe(0);
expect(getQuotaUsagePercent(5, -10)).toBe(0);
});
it('rounds the percentage to the nearest integer', () => {
expect(getQuotaUsagePercent(1, 3)).toBe(33);
expect(getQuotaUsagePercent(2, 3)).toBe(67);
expect(getQuotaUsagePercent(50, 100)).toBe(50);
});
it('clamps the percentage to 100 when usage exceeds the quota', () => {
expect(getQuotaUsagePercent(150, 100)).toBe(100);
});
});
describe('normalizeCapacityLimit', () => {
it('maps 0 (unlimited for capacity limits) to null', () => {
expect(normalizeCapacityLimit(0)).toBeNull();
});
it('passes positive limits through unchanged', () => {
expect(normalizeCapacityLimit(1)).toBe(1);
expect(normalizeCapacityLimit(25)).toBe(25);
});
});
+57
View File
@@ -0,0 +1,57 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
/**
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
*/
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* The usage count at which a positive quota starts "nearing" (80% rounded up).
* The single source for the warning threshold math so the UI panel, quota flags,
* and the per-request alert path can't drift apart.
*/
export const getQuotaWarningCount = (quota: number): number => {
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
/**
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
*/
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= getQuotaWarningCount(quota);
};
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
if (quota === null || quota <= 0) {
return 0;
}
return Math.min(100, Math.round((usage / quota) * 100));
};
/** Member/team capacity limits use `0` for unlimited. */
export const normalizeCapacityLimit = (limit: number): number | null => {
if (limit === 0) {
return null;
}
return limit;
};
+1 -71
View File
@@ -1,10 +1,5 @@
import { env } from '@documenso/lib/utils/env';
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error';
type File = {
@@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
return result;
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File, options?: PutFileOptions) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}, options))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
.otherwise(async () => putFileInDatabase(file));
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildUploadAuthHeaders(options),
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});
if (!getPresignedUrlResponse.ok) {
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
}
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
const body = await file.arrayBuffer();
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};
+12
View File
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
content,
};
};
/**
* Validate and sanitise an uploaded branding logo. Re-encoding through `sharp`
* proves the bytes are a real raster image and strips any embedded payloads.
* Throws if the input cannot be parsed as an image.
*/
export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise<Buffer> => {
return await sharp(input)
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
.png({ quality: 80 })
.toBuffer();
};
@@ -1,11 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateAdminOrganisationRequestSchema = z.object({
ownerUserId: z.number(),
data: z.object({
name: ZOrganisationNameSchema,
name: ZNameSchema,
}),
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
import { z } from 'zod';
export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: z.string().min(1),
name: ZNameSchema,
teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0),
envelopeItemCount: z.number().int().min(1),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateUserRequestSchema = z.object({
@@ -1,9 +1,10 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
name: ZNameSchema,
fromName: ZNameSchema,
fromAddress: z.string().email(),
config: ZEmailTransportConfigSchema,
});
@@ -4,6 +4,7 @@ import {
ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
// Reuses the canonical transport config schemas, but relaxes the secret field so
@@ -21,8 +22,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [
export const ZUpdateEmailTransportRequestSchema = z.object({
id: z.string(),
data: z.object({
name: z.string().min(1),
fromName: z.string().min(1),
name: ZNameSchema,
fromName: ZNameSchema,
fromAddress: z.string().email(),
config: ZUpdateConfigSchema,
}),
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
name: true,
url: true,
ownerUserId: true,
organisationGlobalSettings: true,
},
},
teamEmail: true,
@@ -1,5 +1,6 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true,
url: true,
ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}),
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
@@ -1,13 +1,13 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateAdminOrganisationRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
name: ZOrganisationNameSchema.optional(),
name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(),
claims: ZCreateSubscriptionClaimRequestSchema.pick({
teamCount: true,
@@ -1,10 +1,11 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { Role } from '@prisma/client';
import { z } from 'zod';
export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
name: ZNameSchema.nullish(),
email: zEmail().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateApiTokenRequestSchema = z.object({
teamId: z.number(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
tokenName: ZNameSchema,
expirationDate: z.string().nullable(),
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { z } from 'zod';
export const ZCreatePasskeyRequestSchema = z.object({
passkeyName: z.string().trim().min(1),
passkeyName: ZNameSchema,
verificationResponse: ZRegistrationResponseJSONSchema,
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZUpdatePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1),
name: ZNameSchema,
});
export const ZUpdatePasskeyResponseSchema = z.void();
@@ -1,9 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(),
emailName: z.string().min(1).max(100),
emailName: ZNameSchema,
email: zEmail().toLowerCase(),
// This does not need to be validated to be part of the domain.
@@ -6,6 +6,7 @@ import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-plac
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { EnvelopeType } from '@prisma/client';
import type { Logger } from 'pino';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
@@ -119,7 +120,7 @@ export const createEnvelopeRouteCaller = async ({
}
const normalized = await normalizePdf(pdf, {
flattenForm: false,
flattenForm: type !== EnvelopeType.TEMPLATE,
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
@@ -1,68 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
ZImportAcroFormFieldsRequestSchema,
ZImportAcroFormFieldsResponseSchema,
} from './import-acroform-fields.types';
export const importAcroFormFieldsRoute = authenticatedProcedure
.input(ZImportAcroFormFieldsRequestSchema)
.output(ZImportAcroFormFieldsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { envelopeId } = input;
ctx.logger.info({ input: { envelopeId } });
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: true,
envelopeItems: {
include: { documentData: true },
orderBy: { order: 'asc' },
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only supported for version 2 envelopes',
});
}
if (envelope.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only allowed while the envelope is in draft',
});
}
if (envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope has no items to import from',
});
}
return UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: metadata,
});
});
@@ -1,24 +0,0 @@
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
import { z } from 'zod';
export const ZImportAcroFormFieldsRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZImportAcroFormFieldsResponseSchema = z.object({
itemsProcessed: z.number().int().min(0),
fieldsCreated: z.number().int().min(0),
unsupportedCount: z.number().int().min(0),
signedSignatureCount: z.number().int().min(0),
skippedItems: z.array(
z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
reason: z.enum(['encrypted', 'xfa-hybrid', 'no-form', 'error']),
}),
),
fields: z.array(ZEnvelopeFieldSchema),
});
export type TImportAcroFormFieldsRequest = z.infer<typeof ZImportAcroFormFieldsRequestSchema>;
export type TImportAcroFormFieldsResponse = z.infer<typeof ZImportAcroFormFieldsResponseSchema>;
@@ -34,7 +34,6 @@ import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { importAcroFormFieldsRoute } from './import-acroform-fields';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
import { saveAsTemplateRoute } from './save-as-template';
@@ -84,7 +83,6 @@ export const envelopeRouter = router({
delete: deleteEnvelopeFieldRoute,
set: setEnvelopeFieldsRoute,
sign: signEnvelopeFieldRoute,
importFromPdf: importAcroFormFieldsRoute,
},
find: findEnvelopesRoute,
auditLog: {
+3 -2
View File
@@ -1,4 +1,5 @@
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
@@ -42,7 +43,7 @@ const ZFolderParentIdSchema = z
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
export const ZCreateFolderRequestSchema = z.object({
name: z.string(),
name: ZNameSchema,
parentId: ZFolderParentIdSchema.optional(),
type: ZFolderTypeSchema.optional(),
});
@@ -52,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'),
data: z.object({
name: z.string().optional().describe('The name of the folder'),
name: ZNameSchema.optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZCreateOrganisationGroupRequestSchema = z.object({
organisationId: z.string(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
name: z.string().max(100),
name: ZNameSchema,
memberIds: z.array(z.string()),
});
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
// export const createOrganisationMeta: TrpcOpenApiMeta = {
@@ -10,13 +11,8 @@ import { z } from 'zod';
// },
// };
export const ZOrganisationNameSchema = z
.string()
.min(3, { message: 'Minimum 3 characters' })
.max(50, { message: 'Maximum 50 characters' });
export const ZCreateOrganisationRequestSchema = z.object({
name: ZOrganisationNameSchema,
name: ZNameSchema,
priceId: z.string().optional(),
});
@@ -20,6 +20,7 @@ import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation';
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
import { updateOrganisationRoute } from './update-organisation';
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
import { updateOrganisationGroupRoute } from './update-organisation-group';
import { updateOrganisationMemberRoute } from './update-organisation-members';
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
@@ -55,6 +56,7 @@ export const organisationRouter = router({
},
settings: {
update: updateOrganisationSettingsRoute,
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
},
internal: {
getOrganisationSession: getOrganisationSessionRoute,
@@ -0,0 +1,69 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationBrandingLogoRequestSchema,
ZUpdateOrganisationBrandingLogoResponseSchema,
} from './update-organisation-branding-logo.types';
export const updateOrganisationBrandingLogoRoute = authenticatedProcedure
.input(ZUpdateOrganisationBrandingLogoRequestSchema)
.output(ZUpdateOrganisationBrandingLogoResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { payload, brandingLogo } = input;
const { organisationId } = payload;
ctx.logger.info({
input: {
organisationId,
},
});
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this organisation.',
});
}
// Setting a logo requires the custom-branding entitlement; clearing it is
// always allowed so a downgraded organisation can still remove its logo.
if (brandingLogo && IS_BILLING_ENABLED()) {
const claim = await getOrganisationClaim({ organisationId });
if (claim.flags?.allowCustomBranding !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Your plan does not allow custom branding.',
});
}
}
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
organisationGlobalSettings: {
update: {
brandingLogo: brandingLogoValue,
},
},
},
});
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({
payload: zfd.json(
z.object({
organisationId: z.string(),
}),
),
brandingLogo: zfdBrandingImageFile().optional(),
});
export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void();
export type TUpdateOrganisationBrandingLogoRequest = z.infer<typeof ZUpdateOrganisationBrandingLogoRequestSchema>;
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZUpdateOrganisationGroupRequestSchema = z.object({
id: z.string(),
name: z.string().nullable().optional(),
name: ZNameSchema.nullable().optional(),
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
memberIds: z.array(z.string()).optional(),
});
@@ -44,7 +44,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -174,7 +173,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
@@ -32,7 +32,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
// Branding related settings.
brandingEnabled: z.boolean().optional(),
brandingLogo: z.string().optional(),
brandingUrl: z.string().optional(),
brandingCompanyDetails: z.string().optional(),
brandingColors: ZCssVarsSchema.nullish(),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZFindUserSecurityAuditLogsSchema = z.object({
@@ -1,5 +1,6 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
import { ZTeamUrlSchema } from './schema';
// export const createTeamMeta: TrpcOpenApiMeta = {
// openapi: {
@@ -13,7 +14,7 @@ import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
export const ZCreateTeamRequestSchema = z.object({
organisationId: z.string(),
teamName: ZTeamNameSchema,
teamName: ZNameSchema,
teamUrl: ZTeamUrlSchema,
inheritMembers: z
.boolean()
@@ -25,6 +25,7 @@ import {
ZUpdateTeamEmailMutationSchema,
} from './schema';
import { updateTeamRoute } from './update-team';
import { updateTeamBrandingLogoRoute } from './update-team-branding-logo';
import { updateTeamGroupRoute } from './update-team-group';
import { updateTeamMemberRoute } from './update-team-member';
import { updateTeamSettingsRoute } from './update-team-settings';
@@ -50,6 +51,7 @@ export const teamRouter = router({
},
settings: {
update: updateTeamSettingsRoute,
updateBrandingLogo: updateTeamBrandingLogoRoute,
},
// Old routes (to be migrated)
+1 -10
View File
@@ -1,5 +1,5 @@
import { URL_PATTERN, ZNameSchema } from '@documenso/lib/constants/auth';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -32,15 +32,6 @@ export const ZTeamUrlSchema = z
message: 'This URL is already in use.',
});
export const ZTeamNameSchema = z
.string()
.trim()
.min(3, { message: 'Team name must be at least 3 characters long.' })
.max(30, { message: 'Team name must not exceed 30 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Team name cannot contain URLs.',
});
export const ZCreateTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(),
name: ZNameSchema,

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