mirror of
https://github.com/documenso/documenso.git
synced 2026-07-12 14:05:20 +10:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3f0c04370 | |||
| 400b6a24f1 | |||
| 1b1e3d197b | |||
| a276e18e1f | |||
| 50f272be87 | |||
| a55e6d9484 | |||
| d35d13db23 |
@@ -1,430 +0,0 @@
|
||||
---
|
||||
date: 2026-05-21
|
||||
title: Acroform Field Detection And Reuse
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Users routinely prepare PDFs in Adobe Acrobat (or other PDF editors) with AcroForm fields — signatures, text inputs, dates, checkboxes — and upload them to Documenso. Today those widgets are either stripped (`DOCUMENT` upload flattens via `form.flatten()` in `normalize-pdf.ts`) or preserved as static interactive controls (`TEMPLATE` upload), but never reused as Documenso fields. Users have to re-place every field in the editor.
|
||||
|
||||
Issue: https://github.com/documenso/documenso/issues/2697 (labels: `type: enhancement`, `apps: web`).
|
||||
|
||||
## Goal
|
||||
|
||||
On upload, detect AcroForm fields, map supported types to Documenso fields, persist their page geometry, then flatten the PDF so no duplicate interactive controls remain. Imported fields should be ordinary `Field` rows — visible in the editor, assignable to recipients, signable like any other field.
|
||||
|
||||
## Background
|
||||
|
||||
The placeholder pipeline (`{{signature, r1}}` style) already does almost everything we need:
|
||||
|
||||
- `packages/lib/server-only/pdf/auto-place-fields.ts` extracts `PlaceholderInfo[]` (with `fieldAndMeta: TFieldAndMeta`, top-left percentages, page index), then `convertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)` returns `tx.field.createMany` payloads.
|
||||
- `packages/trpc/server/envelope-router/create-envelope.ts` per file: `convertToPdf` → optional `insertFormValuesInPdf` → `normalizePdf({ flattenForm: type !== 'TEMPLATE' })` → `extractPdfPlaceholders(normalized)` → `putPdfFileServerSide(cleanedPdf)` → forwards `{ title, documentDataId, placeholders }`.
|
||||
- `packages/lib/server-only/envelope-item/create-envelope-items.ts` runs the same per-file pipeline when files are appended to an existing envelope.
|
||||
- `packages/lib/server-only/envelope/create-envelope.ts` consumes `envelopeItems[].placeholders`, creates placeholder `SIGNER` recipients (`recipient.${i}@documenso.com`) when `data.recipients` is empty, then calls `convertPlaceholdersToFieldInputs` + `tx.field.createMany` inside its existing transaction.
|
||||
|
||||
`@libpdf/core` exposes the AcroForm primitives we need (verified in `node_modules/@libpdf/core/dist/index.d.mts`):
|
||||
|
||||
- `PDF.isEncrypted: boolean`, `PDF.getForm(): PDFForm | null`, `PDF.getPages()[i].ref` + `.getRotation()`.
|
||||
- `PDFForm.getFields(): FormField[]`.
|
||||
- `FormField`: `name`, `partialName`, `alternateName` (/TU), `isRequired()`, `isReadOnly()`, `acroField(): PdfDict` (raw dict access), `type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'listbox' | 'signature' | 'button' | 'unknown' | 'non-terminal'`.
|
||||
- `WidgetAnnotation`: `rect`, `width`, `height`, `pageRef: PdfRef | null`, `isHidden()`, `isPrintable()`, `getOnValue()`.
|
||||
- Typed subclasses: `CheckboxField.isChecked()` / `getOnValues()` / `getOnValue()`, `DropdownField.getOptions()`, `RadioField.getOptions()`, `SignatureField`, `TextField.getText()`.
|
||||
|
||||
`ZBaseFieldMeta` (in `packages/lib/types/field-meta.ts`) already carries `label`, `required`, `readOnly`. We extend it once with `source?: 'acroform'` so imported fields are introspectable without UI changes.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: server-side extraction at upload time for v2 envelopes (both new envelopes and items appended to existing envelopes), per-field `FIELD_CREATED` audit log entries for imported fields, and the one-line schema extension to record provenance. Out of scope: editor UI changes (badges, banners, review modals), signing surface changes, v1 path, listbox, button/unknown/non-terminal field types, true radio-group consolidation, recipient inference beyond first-signer selection.
|
||||
|
||||
## Field Mapping
|
||||
|
||||
### Type resolution
|
||||
|
||||
| AcroForm type | Documenso field | Rule |
|
||||
| --- | --- | --- |
|
||||
| `signature` (unsigned) | `SIGNATURE` | Import. |
|
||||
| `signature` (signed — `SignatureField.isSigned()` returns true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). |
|
||||
| `text` | resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
|
||||
| `checkbox` | `CHECKBOX` | One Documenso field per widget. |
|
||||
| `radio` | `RADIO` | One Documenso field per widget. Store group's `getOptions()` in each `fieldMeta.values`. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
|
||||
| `dropdown` | `DROPDOWN` | Preserve `getOptions()` in `fieldMeta.values`, current selection in `fieldMeta.defaultValue`. |
|
||||
| `listbox`, `button`, `unknown`, `non-terminal` | — | Skip, return as `AcroFormUnsupportedFieldInfo`. Never block upload. |
|
||||
|
||||
### Text-field heuristic (resolved in order — first match wins)
|
||||
|
||||
AcroForm format actions take precedence over every name token. If `/AA → /F → /JS` references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
|
||||
|
||||
1. **DATE** if `acroField()` carries an additional-actions date format (`/AA` → `/F` → `JS` containing `AFDate_FormatEx` or `AFDate_Format`).
|
||||
2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action.
|
||||
3. **DATE** if name/alternateName matches `/date|dob|birth/i`.
|
||||
4. **NUMBER** if name/alternateName matches `/amount|qty|count|number/i` AND `/MaxLen <= 10`.
|
||||
5. **EMAIL** if name/alternateName matches `/email|e[-_]?mail/i`.
|
||||
6. **NAME** if name/alternateName matches `/name/i`.
|
||||
7. **INITIALS** if name/alternateName matches `/initial/i`.
|
||||
8. Else **TEXT**.
|
||||
|
||||
All regexes are case-insensitive and run against `partialName` then `alternateName`.
|
||||
|
||||
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. `CustomerName`, `BirthDate`) that strict word-boundary patterns miss. Expected false positives — `username` → NAME, `birth_name` → DATE, `initialize` → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
|
||||
|
||||
### Metadata mapping
|
||||
|
||||
For every imported field:
|
||||
|
||||
- `fieldMeta.required = field.isRequired()` (boolean; omit when false).
|
||||
- `fieldMeta.readOnly = field.isReadOnly()` (boolean; omit when false). Read-only fields **are** imported (rendered for reference in the editor); the renderer already honours `readOnly`.
|
||||
- `fieldMeta.label`: `alternateName ?? partialName` for label-supporting types (TEXT, NUMBER, DATE, INITIALS, NAME, EMAIL, DROPDOWN, RADIO, CHECKBOX). SIGNATURE has no label slot — drop it.
|
||||
- `fieldMeta.source = 'acroform'` on every imported field (see Schema Extension).
|
||||
|
||||
### CHECKBOX required semantics
|
||||
|
||||
AcroForm "required" on a checkbox means "must be checked". Documenso CHECKBOX has both `required` (field must be present) and `validationRule`/`validationLength` (e.g. "at least N of M"). Mapping:
|
||||
|
||||
```ts
|
||||
if (field.isRequired()) {
|
||||
fieldMeta.required = true;
|
||||
fieldMeta.validationRule = 'at-least';
|
||||
fieldMeta.validationLength = 1;
|
||||
}
|
||||
```
|
||||
|
||||
This approximates "must be checked to submit" for a single-widget checkbox field.
|
||||
|
||||
### Default values (only when `formValues` was NOT provided on this upload)
|
||||
|
||||
`formValues` (via `insertFormValuesInPdf`) is authoritative when present — it bakes values into the flattened background, so the imported fields stay empty for the signer to fill. When `formValues` is absent, we copy PDF defaults into `fieldMeta` so the editor preview matches the source PDF:
|
||||
|
||||
| Source | Target |
|
||||
| --- | --- |
|
||||
| `TextField.getText()` (non-empty) | `fieldMeta.text` (TEXT/DATE/INITIALS/NAME/EMAIL) or `fieldMeta.value` (NUMBER) |
|
||||
| `DropdownField` current selection | `fieldMeta.defaultValue` |
|
||||
| `CheckboxField.isChecked()` | `fieldMeta.values[0].checked = true` |
|
||||
| `RadioField` current selection | `fieldMeta.values[i].checked = true` on the matching option |
|
||||
|
||||
Always emit `inserted: false` and `customText: ''` — the signer still confirms each field, defaults are editor-only hints.
|
||||
|
||||
All metadata flows through `ZEnvelopeFieldAndMetaSchema.parse(...)` so imported fields match what the editor already expects.
|
||||
|
||||
## Pre-extraction Guards
|
||||
|
||||
Run in this order before any AcroForm work:
|
||||
|
||||
1. **Encrypted PDFs**: if `pdfDoc.isEncrypted` → log `{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }`, return `{ fields: [], unsupported: [] }`. Upload proceeds with zero imported fields.
|
||||
2. **XFA hybrid**: detect via the catalog's `AcroForm` dict carrying an `XFA` key (best-effort via raw dict access; if @libpdf/core's public surface can't read it, fall through — mirrored AcroForm fields in XFA hybrids are fine to import). When detected, log `{ event: 'acroform-import.skip', reason: 'xfa-hybrid' }` and return empty results.
|
||||
3. **`getForm()` is null** → return empty results silently.
|
||||
4. **Top-level try/catch** around steps 1–6: any throw → `logger.error({ event: 'acroform-import.error', envelopeItemTitle, err })`, return `{ fields: [], unsupported: [] }`. Upload proceeds untouched. Never bubble.
|
||||
|
||||
## Coordinate Handling
|
||||
|
||||
Reuse the placeholder convention (top-left percentages, see `auto-place-fields.ts:121-138`):
|
||||
|
||||
1. Build a page lookup once: `pages = pdfDoc.getPages(); pageByRef = new Map(pages.map((p, i) => [p.ref, i]))`.
|
||||
2. For each widget:
|
||||
1. Read `widget.rect = [x1, y1, x2, y2]` (bottom-left, points).
|
||||
2. Normalize: `left = min(x1, x2)`, `right = max`, `bottom = min(y1, y2)`, `top = max`.
|
||||
3. Resolve `pageIndex` via `pageByRef.get(widget.pageRef)`. Skip if no match.
|
||||
4. Read `page.width`, `page.height`, `rot = page.getRotation()` (degrees, normalized to `0|90|180|270`).
|
||||
5. Apply inverse rotation transform so the field lands at the rendered top-left percentage:
|
||||
- `rot === 0`: `x = left`, `y = pageH - top`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
|
||||
- `rot === 90`: `x = bottom`, `y = left`, `w = top - bottom`, `h = right - left`. Page dims swap: `(pageH, pageW)`.
|
||||
- `rot === 180`: `x = pageW - right`, `y = bottom`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
|
||||
- `rot === 270`: `x = pageH - top`, `y = pageW - right`, `w = top - bottom`, `h = right - left`. Page dims swap.
|
||||
6. Out-of-bounds policy: if the entire rect is outside the rotated page bounds, skip + emit `AcroFormUnsupportedFieldInfo` with `reason: 'off-page'`. Otherwise clamp to `[0, renderedW] × [0, renderedH]`.
|
||||
7. Convert to percentages against the rendered page dimensions from step 5.
|
||||
8. Apply the existing `MIN_HEIGHT_THRESHOLD` / `DEFAULT_FIELD_HEIGHT_PERCENT` fallback used by placeholders.
|
||||
3. Skip widgets that are `isHidden()` or have zero/negative `width`/`height` after normalization.
|
||||
|
||||
## Ordering
|
||||
|
||||
Sort imported fields before `createMany` by `(pageIndex asc, top-to-bottom, left-to-right)`. Concretely: ascending `pageIndex`, then ascending `y` (top-of-page first), then ascending `x` within `±2%` y-buckets so a row of fields stays a row. This matches how a signer visually scans the page; it does not rely on AcroForm `/Tabs` metadata (often wrong).
|
||||
|
||||
## Audit Logging
|
||||
|
||||
Every imported field emits one `FIELD_CREATED` entry matching `create-envelope-fields.ts:264`'s shape:
|
||||
|
||||
```ts
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: createdFields.map((f) => createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: { fieldId: f.secondaryId, fieldRecipientEmail, fieldRecipientId, fieldType: f.type },
|
||||
})),
|
||||
});
|
||||
```
|
||||
|
||||
The placeholder branch in `create-envelope.ts` is silent today and stays silent — AcroForm import does not retroactively change that. Distinguishing imported vs. placeholder vs. user-placed fields is done via `fieldMeta.source`, not a new audit type.
|
||||
|
||||
## Schema Extension
|
||||
|
||||
One field added to `ZBaseFieldMeta` in `packages/lib/types/field-meta.ts`:
|
||||
|
||||
```ts
|
||||
export const ZBaseFieldMeta = z.object({
|
||||
// existing...
|
||||
source: z.enum(['acroform']).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
No DB migration (fieldMeta is JSON). No editor change. No API contract change beyond the optional field. Forwards-compatible: future sources (`'placeholder'`, `'figma'`, etc.) extend the enum.
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Add the AcroForm extractor
|
||||
|
||||
New file `packages/lib/server-only/pdf/acroform-fields.ts`:
|
||||
|
||||
```ts
|
||||
export type AcroFormFieldImportInfo = {
|
||||
source: 'acroform';
|
||||
fieldName: string;
|
||||
widgetIndex: number;
|
||||
fieldAndMeta: TFieldAndMeta;
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
export type AcroFormUnsupportedFieldInfo = {
|
||||
fieldName: string;
|
||||
acroFormType: string;
|
||||
reason: 'unsupported-type' | 'hidden' | 'off-page' | 'zero-size' | 'no-page-match' | 'signed-signature';
|
||||
};
|
||||
|
||||
export type AcroFormExtractionResult = {
|
||||
fields: AcroFormFieldImportInfo[];
|
||||
unsupported: AcroFormUnsupportedFieldInfo[];
|
||||
/** True when a signed signature widget was found — caller MUST set flattenForm: false for that item. */
|
||||
hasSignedSignature: boolean;
|
||||
/** True when extraction returned empty for a reason that should be surfaced in logs but not propagated. */
|
||||
skipReason?: 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
|
||||
};
|
||||
|
||||
export const extractAcroFormFieldsFromPDF = async (
|
||||
pdf: Buffer,
|
||||
): Promise<AcroFormExtractionResult>;
|
||||
|
||||
export const convertAcroFormFieldsToFieldInputs = (
|
||||
fields: AcroFormFieldImportInfo[],
|
||||
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
|
||||
envelopeItemId?: string,
|
||||
): FieldToCreate[];
|
||||
```
|
||||
|
||||
`extractAcroFormFieldsFromPDF`:
|
||||
|
||||
- Wraps everything in try/catch (top-level guard).
|
||||
- Loads via `PDF.load(new Uint8Array(pdf))`.
|
||||
- Runs pre-extraction guards (encrypted, XFA, null form) — returns early with `skipReason` set.
|
||||
- Builds the page-ref → index + rotation lookup once.
|
||||
- Iterates `form.getFields()`, applies the type-resolution heuristic, geometry pipeline, default-value mapping.
|
||||
- Records signed-signature widgets in `unsupported` with `reason: 'signed-signature'` AND sets `hasSignedSignature = true`.
|
||||
- Logger is module-scoped (no apiRequestMetadata in this file — pure function).
|
||||
|
||||
`convertAcroFormFieldsToFieldInputs` mirrors `convertPlaceholdersToFieldInputs` — pure point→percentage transform, no DB access. After mapping, sort by `(page, y, x)` as in Ordering.
|
||||
|
||||
Kept separate from `auto-place-fields.ts`: placeholders are text-driven and emit white rectangles via `whiteoutRegions`; AcroForm import is widget-driven and relies on the post-extraction `form.flatten()` to clean the PDF. Sharing types prematurely would couple both paths.
|
||||
|
||||
### 2. Extract AcroForm fields before flattening
|
||||
|
||||
In both upload entry points the order becomes:
|
||||
|
||||
```
|
||||
convertToPdf (router only)
|
||||
→ insertFormValuesInPdf if formValues
|
||||
→ extractAcroFormFieldsFromPDF(pdf) // new — must run BEFORE normalizePdf
|
||||
→ const shouldFlatten = type !== 'TEMPLATE' && !extraction.hasSignedSignature
|
||||
→ normalizePdf({ flattenForm: shouldFlatten })
|
||||
→ extractPdfPlaceholders(normalized)
|
||||
→ putPdfFileServerSide(cleanedPdf)
|
||||
→ forward { placeholders, acroFormFields, formValuesProvided }
|
||||
```
|
||||
|
||||
Why before `normalizePdf`: for `DOCUMENT` uploads `normalizePdf` calls `form.flatten()` and destroys widget geometry. Extraction must read the unflattened buffer. `formValues` filling stays first so user-prefilled values still bake into the flattened background.
|
||||
|
||||
When `extraction.hasSignedSignature` is true, also `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`.
|
||||
|
||||
`formValuesProvided` (boolean) is forwarded to the converter so the default-value mapping can skip prefill when the user-supplied values pipeline already filled the PDF.
|
||||
|
||||
Template flattening policy is unchanged in this plan: templates continue to preserve AcroForm widgets (no `form.flatten()`), so imported fields will visually duplicate the still-interactive PDF widgets in the template preview. Flipping templates to flatten is a follow-up — it's a breaking change for API users relying on template `formValues`.
|
||||
|
||||
### 3. Thread `acroFormFields` through `createEnvelope`
|
||||
|
||||
Extend `CreateEnvelopeOptions.data.envelopeItems[number]` (`packages/lib/server-only/envelope/create-envelope.ts:70-75`):
|
||||
|
||||
```ts
|
||||
envelopeItems: {
|
||||
title?: string;
|
||||
documentDataId: string;
|
||||
order?: number;
|
||||
placeholders?: PlaceholderInfo[];
|
||||
acroFormFields?: AcroFormFieldImportInfo[]; // new
|
||||
formValuesProvided?: boolean; // new — already-applied prefill
|
||||
}[];
|
||||
```
|
||||
|
||||
Inside the existing transaction (alongside the `itemsWithPlaceholders` branch at `:431-538`), add an `itemsWithAcroFormFields` branch:
|
||||
|
||||
- Run AFTER the placeholder branch so `availableRecipients` reflects any placeholder signers it created.
|
||||
- Recipient resolution:
|
||||
- **First-signer rule**: pick `availableRecipients.filter(r => r.role === SIGNER || r.role === APPROVER).sort((a, b) => (a.signingOrder ?? Infinity) - (b.signingOrder ?? Infinity) || a.id - b.id)[0]`.
|
||||
- If none: the placeholder branch may have created `Recipient 1` already — reuse. If still none (no recipients, no placeholders), create one placeholder `SIGNER` via the same `recipient.1@documenso.com` shape used by the placeholder branch.
|
||||
- All imported fields → that one recipient. User reassigns in editor.
|
||||
- Call `convertAcroFormFieldsToFieldInputs(item.acroFormFields, resolver, envelopeItem.id)` then `tx.field.createMany(...)` with the same `{ envelopeId, envelopeItemId, recipientId, type, page, positionX, positionY, width, height, customText: '', inserted: false, fieldMeta }` shape used by the placeholder branch.
|
||||
- Immediately after, emit per-field `FIELD_CREATED` audit log entries (see Audit Logging).
|
||||
|
||||
### 4. Mirror in `UNSAFE_createEnvelopeItems`
|
||||
|
||||
`packages/lib/server-only/envelope-item/create-envelope-items.ts:47-77` — carry `acroFormFields` and `formValuesProvided` alongside `placeholders` in `envelopeItemsToCreate`. Inside the existing `if (envelope.recipients.length > 0)` block (`:111-160`), after the placeholder loop, run the AcroForm loop using the same first-signer rule (SIGNER|APPROVER, signingOrder asc, id asc). Emit per-field `FIELD_CREATED` entries with `apiRequestMetadata`. If `envelope.recipients.length === 0`, skip — appending widgets to a recipient-less envelope is the user's setup phase and is handled when they add recipients (matches current placeholder behavior on append).
|
||||
|
||||
### 5. Log unsupported fields, never block upload
|
||||
|
||||
In both entry points, after extraction:
|
||||
|
||||
```ts
|
||||
if (extraction.unsupported.length > 0) {
|
||||
logger.info({
|
||||
event: 'acroform-import.unsupported',
|
||||
envelopeItemTitle,
|
||||
count: extraction.unsupported.length,
|
||||
byReason: groupBy(extraction.unsupported, u => u.reason),
|
||||
});
|
||||
}
|
||||
if (extraction.skipReason) {
|
||||
logger.info({ event: 'acroform-import.skip', envelopeItemTitle, reason: extraction.skipReason });
|
||||
}
|
||||
```
|
||||
|
||||
No new error type, no upload rejection, no response-shape change. A UI surface for warnings comes later once the upload response has a stable warning shape.
|
||||
|
||||
### 6. Schema extension
|
||||
|
||||
`packages/lib/types/field-meta.ts`: add `source: z.enum(['acroform']).optional()` to `ZBaseFieldMeta`. Single-line change, no callers need updating because the field is optional.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `packages/lib/types/field-meta.ts` | Add `source?: 'acroform'` to `ZBaseFieldMeta`. |
|
||||
| `packages/lib/server-only/pdf/acroform-fields.ts` | **new** — extractor, converter, types, pre-extraction guards, heuristics, geometry pipeline, ordering. |
|
||||
| `packages/lib/server-only/envelope/create-envelope.ts` | Extend `CreateEnvelopeOptions.envelopeItems[]` with `acroFormFields` + `formValuesProvided`. Add AcroForm branch beside placeholder branch (~`:431-538`). Emit per-field `FIELD_CREATED` audit entries. |
|
||||
| `packages/trpc/server/envelope-router/create-envelope.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` in the per-file loop (`:110-141`). Downgrade `flattenForm` when `hasSignedSignature`. Forward `acroFormFields` + `formValuesProvided` into the `envelopeItems` payload (`:135-140`). Log unsupported + skipReason. |
|
||||
| `packages/lib/server-only/envelope-item/create-envelope-items.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` (`:48-77`). Same flatten downgrade. Carry `acroFormFields` + `formValuesProvided` in `envelopeItemsToCreate`. Add AcroForm loop inside `envelope.recipients.length > 0` (`:111-160`). Per-field audit entries. Log unsupported + skipReason. |
|
||||
| `packages/lib/server-only/pdf/acroform-fields.test.ts` | **new** — unit suite (see Tests). |
|
||||
| `packages/app-tests/e2e/scenarios/acroform-import.spec.ts` | **new** — e2e suite (see Tests). |
|
||||
| `scripts/generate-acroform-test-pdf.mjs` | **new** — one-off generator (committed) producing `assets/acroform-import-test.pdf` + rotated variants. |
|
||||
| `assets/acroform-import-test.pdf` | **new** — base fixture: one of each supported type. |
|
||||
| `assets/acroform-import-rotated-90.pdf` | **new** — rotated-page fixture. |
|
||||
| `assets/acroform-import-rotated-180.pdf` | **new** — rotated-page fixture. |
|
||||
| `assets/acroform-import-rotated-270.pdf` | **new** — rotated-page fixture. |
|
||||
| `assets/acroform-import-signed.pdf` | **new** — fixture with one signed signature widget + supported widgets. |
|
||||
|
||||
No DB schema change. No new tRPC route. No public API surface change beyond the optional `fieldMeta.source`.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit (`packages/lib/server-only/pdf/acroform-fields.test.ts`)
|
||||
|
||||
Drive from the committed fixture set; synthesize edge-case PDFs inline via `@libpdf/core`'s form builder where a static file is overkill.
|
||||
|
||||
Type resolution:
|
||||
- text / signature / checkbox / radio / dropdown widgets each produce the expected Documenso field type.
|
||||
- Heuristic positives: `signed_date` / `dob` → DATE; `initial` / `initials` → INITIALS; `customer_email` → EMAIL; `full_name` / `fname` → NAME; field with `AFNumber_Format` action → NUMBER; field with `MaxLen: 5` + name `qty` → NUMBER; plain `customer_id` → TEXT.
|
||||
- AcroForm format actions take precedence over name tokens (a field named `customer_name` with an `AFDate_FormatEx` action → DATE).
|
||||
|
||||
Metadata:
|
||||
- `isRequired` / `isReadOnly` round-trip into `fieldMeta`.
|
||||
- `alternateName` (or `partialName` fallback) → `fieldMeta.label` on label-supporting types; SIGNATURE has no label.
|
||||
- Required CHECKBOX → `required: true` + `validationRule: 'at-least'` + `validationLength: 1`.
|
||||
- Every imported field has `fieldMeta.source = 'acroform'`.
|
||||
|
||||
Default values:
|
||||
- TextField with non-empty value AND `formValuesProvided = false` → `fieldMeta.text` set.
|
||||
- TextField with non-empty value AND `formValuesProvided = true` → `fieldMeta.text` NOT set.
|
||||
- DropdownField selection → `fieldMeta.defaultValue`.
|
||||
- CheckboxField checked → `values[0].checked = true`.
|
||||
- RadioField selected → matching `values[i].checked = true`.
|
||||
|
||||
Geometry:
|
||||
- Bottom-left widget rect `[100, 600, 200, 620]` on a 612×792 page → top-left percentages within ±0.01% of expected.
|
||||
- 90° rotated page: same widget rect → rotated coordinates as defined in Coordinate Handling step 5.
|
||||
- 180° and 270° rotated pages: same.
|
||||
- Hidden widgets (annotation flags hidden bit) → skipped.
|
||||
- Widgets with zero/negative dimensions → skipped.
|
||||
- Widgets with `pageRef` not in `pdfDoc.getPages()` → `unsupported` with `reason: 'no-page-match'`.
|
||||
- Widget rect entirely off-page → `unsupported` with `reason: 'off-page'`.
|
||||
- Widget rect partially off-page → clamped, imported.
|
||||
|
||||
Ordering:
|
||||
- Two pages × four widgets in scrambled creation order → output sorted by `(page, y, x)`.
|
||||
|
||||
Skips and unsupported:
|
||||
- listbox / button / unknown / non-terminal → `unsupported`, never thrown.
|
||||
- Encrypted PDF → `skipReason: 'encrypted'`, `fields: []`, no throw.
|
||||
- XFA hybrid PDF (best-effort detect) → `skipReason: 'xfa-hybrid'` when detectable; otherwise extraction proceeds normally.
|
||||
- Signed signature widget (`SignatureField.isSigned()` returns true) → `unsupported` with `reason: 'signed-signature'` AND `hasSignedSignature: true`.
|
||||
- Buffer corruption → top-level try/catch, returns empty + `skipReason: 'error'`, no throw.
|
||||
|
||||
### E2E (`packages/app-tests/e2e/scenarios/acroform-import.spec.ts`)
|
||||
|
||||
- Upload `assets/acroform-import-test.pdf` as a `DOCUMENT` via the v2 envelope router with one provided SIGNER recipient → assert envelope has one `Field` per supported widget, types match, `positionX/Y/width/height` within ±1% of expected, every field's recipient is that one SIGNER, stored PDF (`documentData`) loaded via `PDF.load` reports `getForm() === null` or `getFields().length === 0`. Audit log contains N `FIELD_CREATED` entries.
|
||||
- Upload with `formValues` populated → `formValues` persists, imported fields exist but have no default values set in fieldMeta, the flattened PDF reflects the prefilled values.
|
||||
- Upload with two recipients: one CC + one SIGNER → all imported fields assigned to the SIGNER (CC skipped).
|
||||
- Upload with two recipients: one SIGNER (signingOrder=1) + one APPROVER (signingOrder=2) → all imported fields assigned to the SIGNER.
|
||||
- Upload with zero recipients → placeholder `Recipient 1` created (shared with the placeholder branch's behavior; if both placeholders and AcroForm fields exist in the same file, only one `Recipient 1` exists).
|
||||
- Upload `assets/acroform-import-signed.pdf` → signed signature widget skipped, other widgets imported, stored PDF is NOT flattened (`getForm() !== null`, widgets still present).
|
||||
- Append `assets/acroform-import-test.pdf` to an existing envelope with one SIGNER via `UNSAFE_createEnvelopeItems` → new `envelopeItem.id` carries the imported fields, all assigned to that SIGNER.
|
||||
- Append to a recipient-less envelope → AcroForm extraction runs, fields are NOT created (skipped, matching placeholder behavior).
|
||||
- Upload `TEMPLATE` → template still preserves AcroForm widgets in the stored PDF (current behavior unchanged), imported fields ALSO exist (visual duplication acknowledged in Risks).
|
||||
- Upload PDF with one `listbox` + one supported `text` field → upload succeeds, only the text field becomes a Documenso field, log line emitted for the listbox.
|
||||
- Upload rotated PDFs (90/180/270 fixtures) → field geometry lands within ±1% of the expected rendered position on each page.
|
||||
|
||||
### Regression
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit -p apps/remix/tsconfig.json
|
||||
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/form-flattening.spec.ts
|
||||
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/acroform-import.spec.ts
|
||||
```
|
||||
|
||||
## Behavior Matrix
|
||||
|
||||
| Upload | Has AcroForm | Signed sig? | `formValues`? | `recipients`? | Result |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `DOCUMENT` | yes | no | none | 1 SIGNER/APPROVER | All imported fields → that recipient. Stored PDF flat. Per-field `FIELD_CREATED` audit. |
|
||||
| `DOCUMENT` | yes | no | none | N≥2 mixed roles | All imported fields → first SIGNER|APPROVER by (signingOrder asc, id asc). CC/VIEWER skipped. User reassigns in editor. |
|
||||
| `DOCUMENT` | yes | no | none | only CC/VIEWER | Treated as "no signable recipients" — placeholder `Recipient 1` SIGNER created. |
|
||||
| `DOCUMENT` | yes | no | none | none | One placeholder `Recipient 1` SIGNER created (reused if placeholder branch already made one). |
|
||||
| `DOCUMENT` | yes | no | provided | any | `formValues` filled → flattened values visible → empty supported fields imported with `source: 'acroform'`, no `fieldMeta.text`/`defaultValue` prefill. |
|
||||
| `DOCUMENT` | yes | yes | any | any | Signed signature(s) skipped + logged. `flattenForm` downgraded to false → stored PDF retains widgets. Other supported widgets imported normally. |
|
||||
| `DOCUMENT` | no | n/a | any | any | Unchanged. |
|
||||
| `TEMPLATE` | yes | n/a | any | any | Imported fields created **and** PDF still contains interactive widgets (known artifact, follow-up). |
|
||||
| Encrypted PDF | n/a | n/a | any | any | Extraction skipped + logged. Upload proceeds with zero AcroForm imports. |
|
||||
| XFA hybrid (detected) | n/a | n/a | any | any | Extraction skipped + logged. Same as encrypted. |
|
||||
| Append to existing envelope w/ recipients | yes | no | n/a | n/a | Imported fields → first SIGNER|APPROVER of the envelope. Per-field audit. |
|
||||
| Append to existing envelope w/o recipients | yes | n/a | n/a | n/a | Skipped (matches current placeholder behavior on append). |
|
||||
|
||||
## Out of Scope / Follow-ups
|
||||
|
||||
- Flipping `TEMPLATE` uploads to flatten after import — breaking for API users relying on template AcroForm `formValues`.
|
||||
- Editor UI surface: "Imported from PDF" badge (using `fieldMeta.source`), warning toast for skipped widgets, encrypted/XFA banner. Data is captured now; UI ships separately.
|
||||
- A signed-AcroForm-signature → completed Documenso signature mapping.
|
||||
- True radio-group consolidation (one Documenso field per AcroForm radio group instead of per widget) — needs `fieldMeta` schema extension for multi-position groups.
|
||||
- Same-name multi-widget non-radio fields (one AcroForm text field rendered on N pages) — currently emit N independent Documenso fields; future work could sync values at signing time via a shared `groupId` in fieldMeta.
|
||||
- Listbox support.
|
||||
- Recipient inference from PDF authoring metadata (Adobe's role/recipient hints, tab order grouping).
|
||||
- AcroForm `/Tabs` ordering as a signal — current spatial sort suffices.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Rotated pages**: covered by inverse-rotation transform with 90/180/270 fixtures gating the unit suite. Skewed rotations (non-cardinal) are not supported; should be rejected as `off-page` if their normalized rect doesn't land within page bounds.
|
||||
- **Radio groups**: emitting one Documenso field per widget will look right visually but signing semantics differ from a single PDF radio group (each option becomes independently checkable). Gating fixture in e2e covers visual placement; signing semantics divergence is documented and ships as a known limitation.
|
||||
- **Template behavior**: leaving `TEMPLATE` uploads unflattened means imported fields and live widgets coexist. Acceptable for v1, but the template preview will show duplicated controls.
|
||||
- **Signed signature + flattenForm downgrade**: a `DOCUMENT` upload containing a signed signature now stores an un-flattened PDF. Existing code paths that assume `DOCUMENT` PDFs are always flat (signing renderer, downstream conversion) MUST be re-verified — add an integration check in the e2e suite that signing still works on the signed-fixture envelope.
|
||||
- **Recipient ambiguity**: AcroForm widgets don't encode Documenso recipients. Deterministic "all to first signer" + editor review is the safest first cut; smarter assignment is a follow-up.
|
||||
- **XFA detection**: best-effort; if @libpdf/core's public surface doesn't expose the catalog AcroForm dict, we fall through and import any mirrored AcroForm fields. Acceptable — XFA-only PDFs with no mirror produce empty AcroForm extraction and the upload proceeds. Worst case is a noisy log line on a misclassified hybrid.
|
||||
- **Heuristic false positives**: expanded heuristic (NAME/EMAIL/NUMBER/DATE/INITIALS) increases the chance of mis-typing a field. Mitigation: every imported field is editable in the editor before sending. False negatives fall through to TEXT (always safe).
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Generated
+2048
-1264
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,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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 Organisationseinladungen"
|
||||
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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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é d’API"
|
||||
|
||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||
msgid "API rate limits"
|
||||
msgstr "Limites de fréquence de l’API"
|
||||
|
||||
#: 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 lorsqu’un 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 d’utiliser le jeton d’inté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 d’API 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 d’e-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 à l’organisation 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 l’organisation"
|
||||
|
||||
#: 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"
|
||||
|
||||
|
||||
@@ -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 all’organizzazione 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 l’altro. 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 un’unità, 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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 "E‑mailverificatie is verwijderd"
|
||||
msgid "Email verification has been resent"
|
||||
msgstr "E‑mailverificatie 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 "Time‑out 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 "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 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"
|
||||
|
||||
|
||||
+339
-297
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user