Compare commits

..

9 Commits

Author SHA1 Message Date
github-actions a4bc88439a chore: extract translations 2026-07-13 02:59:07 +00:00
Lucas Smith c5efd34e95 v2.15.0 2026-07-13 12:43:22 +10:00
roshboi 21cff7a727 docs: update rate limit doc for organisation limit for self hosters (#3089) 2026-07-12 14:13:23 +08:00
Tanushree Ahir 400b6a24f1 feat: enable real-time validation for email and password fields (#2204)
## Description

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

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

## Related Issue

Fixes #2200

## Changes Made

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


## Testing Performed

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

## Checklist

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

## Additional Notes

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

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

New page: self-hosting/configuration/license.mdx — "Apply Your License
Key"
Set NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY (Docker Compose / docker run /
.env tabs)
2026-07-08 16:11:49 +10:00
Lucas Smith a276e18e1f chore: upgrade deps (#3065) 2026-07-08 16:11:00 +10:00
Catalin Pit 50f272be87 fix: admin organisation limits and usage UI (#3014) 2026-07-02 16:50:11 +10:00
Catalin Pit a55e6d9484 fix: block invisible & control characters and URLs in names (#2978) 2026-07-02 16:20:56 +10:00
David Nguyen d35d13db23 fix: remove presigned branding upload (#3053) 2026-07-02 15:51:19 +10:00
113 changed files with 4859 additions and 5275 deletions
@@ -1,430 +0,0 @@
---
date: 2026-05-21
title: Acroform Field Detection And Reuse
---
## Problem
Users routinely prepare PDFs in Adobe Acrobat (or other PDF editors) with AcroForm fields — signatures, text inputs, dates, checkboxes — and upload them to Documenso. Today those widgets are either stripped (`DOCUMENT` upload flattens via `form.flatten()` in `normalize-pdf.ts`) or preserved as static interactive controls (`TEMPLATE` upload), but never reused as Documenso fields. Users have to re-place every field in the editor.
Issue: https://github.com/documenso/documenso/issues/2697 (labels: `type: enhancement`, `apps: web`).
## Goal
On upload, detect AcroForm fields, map supported types to Documenso fields, persist their page geometry, then flatten the PDF so no duplicate interactive controls remain. Imported fields should be ordinary `Field` rows — visible in the editor, assignable to recipients, signable like any other field.
## Background
The placeholder pipeline (`{{signature, r1}}` style) already does almost everything we need:
- `packages/lib/server-only/pdf/auto-place-fields.ts` extracts `PlaceholderInfo[]` (with `fieldAndMeta: TFieldAndMeta`, top-left percentages, page index), then `convertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)` returns `tx.field.createMany` payloads.
- `packages/trpc/server/envelope-router/create-envelope.ts` per file: `convertToPdf` → optional `insertFormValuesInPdf``normalizePdf({ flattenForm: type !== 'TEMPLATE' })``extractPdfPlaceholders(normalized)``putPdfFileServerSide(cleanedPdf)` → forwards `{ title, documentDataId, placeholders }`.
- `packages/lib/server-only/envelope-item/create-envelope-items.ts` runs the same per-file pipeline when files are appended to an existing envelope.
- `packages/lib/server-only/envelope/create-envelope.ts` consumes `envelopeItems[].placeholders`, creates placeholder `SIGNER` recipients (`recipient.${i}@documenso.com`) when `data.recipients` is empty, then calls `convertPlaceholdersToFieldInputs` + `tx.field.createMany` inside its existing transaction.
`@libpdf/core` exposes the AcroForm primitives we need (verified in `node_modules/@libpdf/core/dist/index.d.mts`):
- `PDF.isEncrypted: boolean`, `PDF.getForm(): PDFForm | null`, `PDF.getPages()[i].ref` + `.getRotation()`.
- `PDFForm.getFields(): FormField[]`.
- `FormField`: `name`, `partialName`, `alternateName` (/TU), `isRequired()`, `isReadOnly()`, `acroField(): PdfDict` (raw dict access), `type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'listbox' | 'signature' | 'button' | 'unknown' | 'non-terminal'`.
- `WidgetAnnotation`: `rect`, `width`, `height`, `pageRef: PdfRef | null`, `isHidden()`, `isPrintable()`, `getOnValue()`.
- Typed subclasses: `CheckboxField.isChecked()` / `getOnValues()` / `getOnValue()`, `DropdownField.getOptions()`, `RadioField.getOptions()`, `SignatureField`, `TextField.getText()`.
`ZBaseFieldMeta` (in `packages/lib/types/field-meta.ts`) already carries `label`, `required`, `readOnly`. We extend it once with `source?: 'acroform'` so imported fields are introspectable without UI changes.
## Scope
In scope: server-side extraction at upload time for v2 envelopes (both new envelopes and items appended to existing envelopes), per-field `FIELD_CREATED` audit log entries for imported fields, and the one-line schema extension to record provenance. Out of scope: editor UI changes (badges, banners, review modals), signing surface changes, v1 path, listbox, button/unknown/non-terminal field types, true radio-group consolidation, recipient inference beyond first-signer selection.
## Field Mapping
### Type resolution
| AcroForm type | Documenso field | Rule |
| --- | --- | --- |
| `signature` (unsigned) | `SIGNATURE` | Import. |
| `signature` (signed — `SignatureField.isSigned()` returns true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). |
| `text` | resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
| `checkbox` | `CHECKBOX` | One Documenso field per widget. |
| `radio` | `RADIO` | One Documenso field per widget. Store group's `getOptions()` in each `fieldMeta.values`. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
| `dropdown` | `DROPDOWN` | Preserve `getOptions()` in `fieldMeta.values`, current selection in `fieldMeta.defaultValue`. |
| `listbox`, `button`, `unknown`, `non-terminal` | — | Skip, return as `AcroFormUnsupportedFieldInfo`. Never block upload. |
### Text-field heuristic (resolved in order — first match wins)
AcroForm format actions take precedence over every name token. If `/AA → /F → /JS` references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
1. **DATE** if `acroField()` carries an additional-actions date format (`/AA``/F``JS` containing `AFDate_FormatEx` or `AFDate_Format`).
2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action.
3. **DATE** if name/alternateName matches `/date|dob|birth/i`.
4. **NUMBER** if name/alternateName matches `/amount|qty|count|number/i` AND `/MaxLen <= 10`.
5. **EMAIL** if name/alternateName matches `/email|e[-_]?mail/i`.
6. **NAME** if name/alternateName matches `/name/i`.
7. **INITIALS** if name/alternateName matches `/initial/i`.
8. Else **TEXT**.
All regexes are case-insensitive and run against `partialName` then `alternateName`.
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. `CustomerName`, `BirthDate`) that strict word-boundary patterns miss. Expected false positives — `username` → NAME, `birth_name` → DATE, `initialize` → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
### Metadata mapping
For every imported field:
- `fieldMeta.required = field.isRequired()` (boolean; omit when false).
- `fieldMeta.readOnly = field.isReadOnly()` (boolean; omit when false). Read-only fields **are** imported (rendered for reference in the editor); the renderer already honours `readOnly`.
- `fieldMeta.label`: `alternateName ?? partialName` for label-supporting types (TEXT, NUMBER, DATE, INITIALS, NAME, EMAIL, DROPDOWN, RADIO, CHECKBOX). SIGNATURE has no label slot — drop it.
- `fieldMeta.source = 'acroform'` on every imported field (see Schema Extension).
### CHECKBOX required semantics
AcroForm "required" on a checkbox means "must be checked". Documenso CHECKBOX has both `required` (field must be present) and `validationRule`/`validationLength` (e.g. "at least N of M"). Mapping:
```ts
if (field.isRequired()) {
fieldMeta.required = true;
fieldMeta.validationRule = 'at-least';
fieldMeta.validationLength = 1;
}
```
This approximates "must be checked to submit" for a single-widget checkbox field.
### Default values (only when `formValues` was NOT provided on this upload)
`formValues` (via `insertFormValuesInPdf`) is authoritative when present — it bakes values into the flattened background, so the imported fields stay empty for the signer to fill. When `formValues` is absent, we copy PDF defaults into `fieldMeta` so the editor preview matches the source PDF:
| Source | Target |
| --- | --- |
| `TextField.getText()` (non-empty) | `fieldMeta.text` (TEXT/DATE/INITIALS/NAME/EMAIL) or `fieldMeta.value` (NUMBER) |
| `DropdownField` current selection | `fieldMeta.defaultValue` |
| `CheckboxField.isChecked()` | `fieldMeta.values[0].checked = true` |
| `RadioField` current selection | `fieldMeta.values[i].checked = true` on the matching option |
Always emit `inserted: false` and `customText: ''` — the signer still confirms each field, defaults are editor-only hints.
All metadata flows through `ZEnvelopeFieldAndMetaSchema.parse(...)` so imported fields match what the editor already expects.
## Pre-extraction Guards
Run in this order before any AcroForm work:
1. **Encrypted PDFs**: if `pdfDoc.isEncrypted` → log `{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }`, return `{ fields: [], unsupported: [] }`. Upload proceeds with zero imported fields.
2. **XFA hybrid**: detect via the catalog's `AcroForm` dict carrying an `XFA` key (best-effort via raw dict access; if @libpdf/core's public surface can't read it, fall through — mirrored AcroForm fields in XFA hybrids are fine to import). When detected, log `{ event: 'acroform-import.skip', reason: 'xfa-hybrid' }` and return empty results.
3. **`getForm()` is null** → return empty results silently.
4. **Top-level try/catch** around steps 16: any throw → `logger.error({ event: 'acroform-import.error', envelopeItemTitle, err })`, return `{ fields: [], unsupported: [] }`. Upload proceeds untouched. Never bubble.
## Coordinate Handling
Reuse the placeholder convention (top-left percentages, see `auto-place-fields.ts:121-138`):
1. Build a page lookup once: `pages = pdfDoc.getPages(); pageByRef = new Map(pages.map((p, i) => [p.ref, i]))`.
2. For each widget:
1. Read `widget.rect = [x1, y1, x2, y2]` (bottom-left, points).
2. Normalize: `left = min(x1, x2)`, `right = max`, `bottom = min(y1, y2)`, `top = max`.
3. Resolve `pageIndex` via `pageByRef.get(widget.pageRef)`. Skip if no match.
4. Read `page.width`, `page.height`, `rot = page.getRotation()` (degrees, normalized to `0|90|180|270`).
5. Apply inverse rotation transform so the field lands at the rendered top-left percentage:
- `rot === 0`: `x = left`, `y = pageH - top`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
- `rot === 90`: `x = bottom`, `y = left`, `w = top - bottom`, `h = right - left`. Page dims swap: `(pageH, pageW)`.
- `rot === 180`: `x = pageW - right`, `y = bottom`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
- `rot === 270`: `x = pageH - top`, `y = pageW - right`, `w = top - bottom`, `h = right - left`. Page dims swap.
6. Out-of-bounds policy: if the entire rect is outside the rotated page bounds, skip + emit `AcroFormUnsupportedFieldInfo` with `reason: 'off-page'`. Otherwise clamp to `[0, renderedW] × [0, renderedH]`.
7. Convert to percentages against the rendered page dimensions from step 5.
8. Apply the existing `MIN_HEIGHT_THRESHOLD` / `DEFAULT_FIELD_HEIGHT_PERCENT` fallback used by placeholders.
3. Skip widgets that are `isHidden()` or have zero/negative `width`/`height` after normalization.
## Ordering
Sort imported fields before `createMany` by `(pageIndex asc, top-to-bottom, left-to-right)`. Concretely: ascending `pageIndex`, then ascending `y` (top-of-page first), then ascending `x` within `±2%` y-buckets so a row of fields stays a row. This matches how a signer visually scans the page; it does not rely on AcroForm `/Tabs` metadata (often wrong).
## Audit Logging
Every imported field emits one `FIELD_CREATED` entry matching `create-envelope-fields.ts:264`'s shape:
```ts
await tx.documentAuditLog.createMany({
data: createdFields.map((f) => createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: { fieldId: f.secondaryId, fieldRecipientEmail, fieldRecipientId, fieldType: f.type },
})),
});
```
The placeholder branch in `create-envelope.ts` is silent today and stays silent — AcroForm import does not retroactively change that. Distinguishing imported vs. placeholder vs. user-placed fields is done via `fieldMeta.source`, not a new audit type.
## Schema Extension
One field added to `ZBaseFieldMeta` in `packages/lib/types/field-meta.ts`:
```ts
export const ZBaseFieldMeta = z.object({
// existing...
source: z.enum(['acroform']).optional(),
});
```
No DB migration (fieldMeta is JSON). No editor change. No API contract change beyond the optional field. Forwards-compatible: future sources (`'placeholder'`, `'figma'`, etc.) extend the enum.
## Plan
### 1. Add the AcroForm extractor
New file `packages/lib/server-only/pdf/acroform-fields.ts`:
```ts
export type AcroFormFieldImportInfo = {
source: 'acroform';
fieldName: string;
widgetIndex: number;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type AcroFormUnsupportedFieldInfo = {
fieldName: string;
acroFormType: string;
reason: 'unsupported-type' | 'hidden' | 'off-page' | 'zero-size' | 'no-page-match' | 'signed-signature';
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/** True when a signed signature widget was found — caller MUST set flattenForm: false for that item. */
hasSignedSignature: boolean;
/** True when extraction returned empty for a reason that should be surfaced in logs but not propagated. */
skipReason?: 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
};
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
): Promise<AcroFormExtractionResult>;
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[];
```
`extractAcroFormFieldsFromPDF`:
- Wraps everything in try/catch (top-level guard).
- Loads via `PDF.load(new Uint8Array(pdf))`.
- Runs pre-extraction guards (encrypted, XFA, null form) — returns early with `skipReason` set.
- Builds the page-ref → index + rotation lookup once.
- Iterates `form.getFields()`, applies the type-resolution heuristic, geometry pipeline, default-value mapping.
- Records signed-signature widgets in `unsupported` with `reason: 'signed-signature'` AND sets `hasSignedSignature = true`.
- Logger is module-scoped (no apiRequestMetadata in this file — pure function).
`convertAcroFormFieldsToFieldInputs` mirrors `convertPlaceholdersToFieldInputs` — pure point→percentage transform, no DB access. After mapping, sort by `(page, y, x)` as in Ordering.
Kept separate from `auto-place-fields.ts`: placeholders are text-driven and emit white rectangles via `whiteoutRegions`; AcroForm import is widget-driven and relies on the post-extraction `form.flatten()` to clean the PDF. Sharing types prematurely would couple both paths.
### 2. Extract AcroForm fields before flattening
In both upload entry points the order becomes:
```
convertToPdf (router only)
→ insertFormValuesInPdf if formValues
→ extractAcroFormFieldsFromPDF(pdf) // new — must run BEFORE normalizePdf
→ const shouldFlatten = type !== 'TEMPLATE' && !extraction.hasSignedSignature
→ normalizePdf({ flattenForm: shouldFlatten })
→ extractPdfPlaceholders(normalized)
→ putPdfFileServerSide(cleanedPdf)
→ forward { placeholders, acroFormFields, formValuesProvided }
```
Why before `normalizePdf`: for `DOCUMENT` uploads `normalizePdf` calls `form.flatten()` and destroys widget geometry. Extraction must read the unflattened buffer. `formValues` filling stays first so user-prefilled values still bake into the flattened background.
When `extraction.hasSignedSignature` is true, also `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`.
`formValuesProvided` (boolean) is forwarded to the converter so the default-value mapping can skip prefill when the user-supplied values pipeline already filled the PDF.
Template flattening policy is unchanged in this plan: templates continue to preserve AcroForm widgets (no `form.flatten()`), so imported fields will visually duplicate the still-interactive PDF widgets in the template preview. Flipping templates to flatten is a follow-up — it's a breaking change for API users relying on template `formValues`.
### 3. Thread `acroFormFields` through `createEnvelope`
Extend `CreateEnvelopeOptions.data.envelopeItems[number]` (`packages/lib/server-only/envelope/create-envelope.ts:70-75`):
```ts
envelopeItems: {
title?: string;
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
acroFormFields?: AcroFormFieldImportInfo[]; // new
formValuesProvided?: boolean; // new — already-applied prefill
}[];
```
Inside the existing transaction (alongside the `itemsWithPlaceholders` branch at `:431-538`), add an `itemsWithAcroFormFields` branch:
- Run AFTER the placeholder branch so `availableRecipients` reflects any placeholder signers it created.
- Recipient resolution:
- **First-signer rule**: pick `availableRecipients.filter(r => r.role === SIGNER || r.role === APPROVER).sort((a, b) => (a.signingOrder ?? Infinity) - (b.signingOrder ?? Infinity) || a.id - b.id)[0]`.
- If none: the placeholder branch may have created `Recipient 1` already — reuse. If still none (no recipients, no placeholders), create one placeholder `SIGNER` via the same `recipient.1@documenso.com` shape used by the placeholder branch.
- All imported fields → that one recipient. User reassigns in editor.
- Call `convertAcroFormFieldsToFieldInputs(item.acroFormFields, resolver, envelopeItem.id)` then `tx.field.createMany(...)` with the same `{ envelopeId, envelopeItemId, recipientId, type, page, positionX, positionY, width, height, customText: '', inserted: false, fieldMeta }` shape used by the placeholder branch.
- Immediately after, emit per-field `FIELD_CREATED` audit log entries (see Audit Logging).
### 4. Mirror in `UNSAFE_createEnvelopeItems`
`packages/lib/server-only/envelope-item/create-envelope-items.ts:47-77` — carry `acroFormFields` and `formValuesProvided` alongside `placeholders` in `envelopeItemsToCreate`. Inside the existing `if (envelope.recipients.length > 0)` block (`:111-160`), after the placeholder loop, run the AcroForm loop using the same first-signer rule (SIGNER|APPROVER, signingOrder asc, id asc). Emit per-field `FIELD_CREATED` entries with `apiRequestMetadata`. If `envelope.recipients.length === 0`, skip — appending widgets to a recipient-less envelope is the user's setup phase and is handled when they add recipients (matches current placeholder behavior on append).
### 5. Log unsupported fields, never block upload
In both entry points, after extraction:
```ts
if (extraction.unsupported.length > 0) {
logger.info({
event: 'acroform-import.unsupported',
envelopeItemTitle,
count: extraction.unsupported.length,
byReason: groupBy(extraction.unsupported, u => u.reason),
});
}
if (extraction.skipReason) {
logger.info({ event: 'acroform-import.skip', envelopeItemTitle, reason: extraction.skipReason });
}
```
No new error type, no upload rejection, no response-shape change. A UI surface for warnings comes later once the upload response has a stable warning shape.
### 6. Schema extension
`packages/lib/types/field-meta.ts`: add `source: z.enum(['acroform']).optional()` to `ZBaseFieldMeta`. Single-line change, no callers need updating because the field is optional.
## Files
| File | Change |
| --- | --- |
| `packages/lib/types/field-meta.ts` | Add `source?: 'acroform'` to `ZBaseFieldMeta`. |
| `packages/lib/server-only/pdf/acroform-fields.ts` | **new** — extractor, converter, types, pre-extraction guards, heuristics, geometry pipeline, ordering. |
| `packages/lib/server-only/envelope/create-envelope.ts` | Extend `CreateEnvelopeOptions.envelopeItems[]` with `acroFormFields` + `formValuesProvided`. Add AcroForm branch beside placeholder branch (~`:431-538`). Emit per-field `FIELD_CREATED` audit entries. |
| `packages/trpc/server/envelope-router/create-envelope.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` in the per-file loop (`:110-141`). Downgrade `flattenForm` when `hasSignedSignature`. Forward `acroFormFields` + `formValuesProvided` into the `envelopeItems` payload (`:135-140`). Log unsupported + skipReason. |
| `packages/lib/server-only/envelope-item/create-envelope-items.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` (`:48-77`). Same flatten downgrade. Carry `acroFormFields` + `formValuesProvided` in `envelopeItemsToCreate`. Add AcroForm loop inside `envelope.recipients.length > 0` (`:111-160`). Per-field audit entries. Log unsupported + skipReason. |
| `packages/lib/server-only/pdf/acroform-fields.test.ts` | **new** — unit suite (see Tests). |
| `packages/app-tests/e2e/scenarios/acroform-import.spec.ts` | **new** — e2e suite (see Tests). |
| `scripts/generate-acroform-test-pdf.mjs` | **new** — one-off generator (committed) producing `assets/acroform-import-test.pdf` + rotated variants. |
| `assets/acroform-import-test.pdf` | **new** — base fixture: one of each supported type. |
| `assets/acroform-import-rotated-90.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-rotated-180.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-rotated-270.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-signed.pdf` | **new** — fixture with one signed signature widget + supported widgets. |
No DB schema change. No new tRPC route. No public API surface change beyond the optional `fieldMeta.source`.
## Tests
### Unit (`packages/lib/server-only/pdf/acroform-fields.test.ts`)
Drive from the committed fixture set; synthesize edge-case PDFs inline via `@libpdf/core`'s form builder where a static file is overkill.
Type resolution:
- text / signature / checkbox / radio / dropdown widgets each produce the expected Documenso field type.
- Heuristic positives: `signed_date` / `dob` → DATE; `initial` / `initials` → INITIALS; `customer_email` → EMAIL; `full_name` / `fname` → NAME; field with `AFNumber_Format` action → NUMBER; field with `MaxLen: 5` + name `qty` → NUMBER; plain `customer_id` → TEXT.
- AcroForm format actions take precedence over name tokens (a field named `customer_name` with an `AFDate_FormatEx` action → DATE).
Metadata:
- `isRequired` / `isReadOnly` round-trip into `fieldMeta`.
- `alternateName` (or `partialName` fallback) → `fieldMeta.label` on label-supporting types; SIGNATURE has no label.
- Required CHECKBOX → `required: true` + `validationRule: 'at-least'` + `validationLength: 1`.
- Every imported field has `fieldMeta.source = 'acroform'`.
Default values:
- TextField with non-empty value AND `formValuesProvided = false``fieldMeta.text` set.
- TextField with non-empty value AND `formValuesProvided = true``fieldMeta.text` NOT set.
- DropdownField selection → `fieldMeta.defaultValue`.
- CheckboxField checked → `values[0].checked = true`.
- RadioField selected → matching `values[i].checked = true`.
Geometry:
- Bottom-left widget rect `[100, 600, 200, 620]` on a 612×792 page → top-left percentages within ±0.01% of expected.
- 90° rotated page: same widget rect → rotated coordinates as defined in Coordinate Handling step 5.
- 180° and 270° rotated pages: same.
- Hidden widgets (annotation flags hidden bit) → skipped.
- Widgets with zero/negative dimensions → skipped.
- Widgets with `pageRef` not in `pdfDoc.getPages()``unsupported` with `reason: 'no-page-match'`.
- Widget rect entirely off-page → `unsupported` with `reason: 'off-page'`.
- Widget rect partially off-page → clamped, imported.
Ordering:
- Two pages × four widgets in scrambled creation order → output sorted by `(page, y, x)`.
Skips and unsupported:
- listbox / button / unknown / non-terminal → `unsupported`, never thrown.
- Encrypted PDF → `skipReason: 'encrypted'`, `fields: []`, no throw.
- XFA hybrid PDF (best-effort detect) → `skipReason: 'xfa-hybrid'` when detectable; otherwise extraction proceeds normally.
- Signed signature widget (`SignatureField.isSigned()` returns true) → `unsupported` with `reason: 'signed-signature'` AND `hasSignedSignature: true`.
- Buffer corruption → top-level try/catch, returns empty + `skipReason: 'error'`, no throw.
### E2E (`packages/app-tests/e2e/scenarios/acroform-import.spec.ts`)
- Upload `assets/acroform-import-test.pdf` as a `DOCUMENT` via the v2 envelope router with one provided SIGNER recipient → assert envelope has one `Field` per supported widget, types match, `positionX/Y/width/height` within ±1% of expected, every field's recipient is that one SIGNER, stored PDF (`documentData`) loaded via `PDF.load` reports `getForm() === null` or `getFields().length === 0`. Audit log contains N `FIELD_CREATED` entries.
- Upload with `formValues` populated → `formValues` persists, imported fields exist but have no default values set in fieldMeta, the flattened PDF reflects the prefilled values.
- Upload with two recipients: one CC + one SIGNER → all imported fields assigned to the SIGNER (CC skipped).
- Upload with two recipients: one SIGNER (signingOrder=1) + one APPROVER (signingOrder=2) → all imported fields assigned to the SIGNER.
- Upload with zero recipients → placeholder `Recipient 1` created (shared with the placeholder branch's behavior; if both placeholders and AcroForm fields exist in the same file, only one `Recipient 1` exists).
- Upload `assets/acroform-import-signed.pdf` → signed signature widget skipped, other widgets imported, stored PDF is NOT flattened (`getForm() !== null`, widgets still present).
- Append `assets/acroform-import-test.pdf` to an existing envelope with one SIGNER via `UNSAFE_createEnvelopeItems` → new `envelopeItem.id` carries the imported fields, all assigned to that SIGNER.
- Append to a recipient-less envelope → AcroForm extraction runs, fields are NOT created (skipped, matching placeholder behavior).
- Upload `TEMPLATE` → template still preserves AcroForm widgets in the stored PDF (current behavior unchanged), imported fields ALSO exist (visual duplication acknowledged in Risks).
- Upload PDF with one `listbox` + one supported `text` field → upload succeeds, only the text field becomes a Documenso field, log line emitted for the listbox.
- Upload rotated PDFs (90/180/270 fixtures) → field geometry lands within ±1% of the expected rendered position on each page.
### Regression
```bash
npx tsc --noEmit -p apps/remix/tsconfig.json
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/form-flattening.spec.ts
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/acroform-import.spec.ts
```
## Behavior Matrix
| Upload | Has AcroForm | Signed sig? | `formValues`? | `recipients`? | Result |
| --- | --- | --- | --- | --- | --- |
| `DOCUMENT` | yes | no | none | 1 SIGNER/APPROVER | All imported fields → that recipient. Stored PDF flat. Per-field `FIELD_CREATED` audit. |
| `DOCUMENT` | yes | no | none | N≥2 mixed roles | All imported fields → first SIGNER|APPROVER by (signingOrder asc, id asc). CC/VIEWER skipped. User reassigns in editor. |
| `DOCUMENT` | yes | no | none | only CC/VIEWER | Treated as "no signable recipients" — placeholder `Recipient 1` SIGNER created. |
| `DOCUMENT` | yes | no | none | none | One placeholder `Recipient 1` SIGNER created (reused if placeholder branch already made one). |
| `DOCUMENT` | yes | no | provided | any | `formValues` filled → flattened values visible → empty supported fields imported with `source: 'acroform'`, no `fieldMeta.text`/`defaultValue` prefill. |
| `DOCUMENT` | yes | yes | any | any | Signed signature(s) skipped + logged. `flattenForm` downgraded to false → stored PDF retains widgets. Other supported widgets imported normally. |
| `DOCUMENT` | no | n/a | any | any | Unchanged. |
| `TEMPLATE` | yes | n/a | any | any | Imported fields created **and** PDF still contains interactive widgets (known artifact, follow-up). |
| Encrypted PDF | n/a | n/a | any | any | Extraction skipped + logged. Upload proceeds with zero AcroForm imports. |
| XFA hybrid (detected) | n/a | n/a | any | any | Extraction skipped + logged. Same as encrypted. |
| Append to existing envelope w/ recipients | yes | no | n/a | n/a | Imported fields → first SIGNER|APPROVER of the envelope. Per-field audit. |
| Append to existing envelope w/o recipients | yes | n/a | n/a | n/a | Skipped (matches current placeholder behavior on append). |
## Out of Scope / Follow-ups
- Flipping `TEMPLATE` uploads to flatten after import — breaking for API users relying on template AcroForm `formValues`.
- Editor UI surface: "Imported from PDF" badge (using `fieldMeta.source`), warning toast for skipped widgets, encrypted/XFA banner. Data is captured now; UI ships separately.
- A signed-AcroForm-signature → completed Documenso signature mapping.
- True radio-group consolidation (one Documenso field per AcroForm radio group instead of per widget) — needs `fieldMeta` schema extension for multi-position groups.
- Same-name multi-widget non-radio fields (one AcroForm text field rendered on N pages) — currently emit N independent Documenso fields; future work could sync values at signing time via a shared `groupId` in fieldMeta.
- Listbox support.
- Recipient inference from PDF authoring metadata (Adobe's role/recipient hints, tab order grouping).
- AcroForm `/Tabs` ordering as a signal — current spatial sort suffices.
## Risks
- **Rotated pages**: covered by inverse-rotation transform with 90/180/270 fixtures gating the unit suite. Skewed rotations (non-cardinal) are not supported; should be rejected as `off-page` if their normalized rect doesn't land within page bounds.
- **Radio groups**: emitting one Documenso field per widget will look right visually but signing semantics differ from a single PDF radio group (each option becomes independently checkable). Gating fixture in e2e covers visual placement; signing semantics divergence is documented and ships as a known limitation.
- **Template behavior**: leaving `TEMPLATE` uploads unflattened means imported fields and live widgets coexist. Acceptable for v1, but the template preview will show duplicated controls.
- **Signed signature + flattenForm downgrade**: a `DOCUMENT` upload containing a signed signature now stores an un-flattened PDF. Existing code paths that assume `DOCUMENT` PDFs are always flat (signing renderer, downstream conversion) MUST be re-verified — add an integration check in the e2e suite that signing still works on the signed-fixture envelope.
- **Recipient ambiguity**: AcroForm widgets don't encode Documenso recipients. Deterministic "all to first signer" + editor review is the safest first cut; smarter assignment is a follow-up.
- **XFA detection**: best-effort; if @libpdf/core's public surface doesn't expose the catalog AcroForm dict, we fall through and import any mirrored AcroForm fields. Acceptable — XFA-only PDFs with no mirror produce empty AcroForm extraction and the upload proceeds. Worst case is a noisy log line on a misclassified hybrid.
- **Heuristic false positives**: expanded heuristic (NAME/EMAIL/NUMBER/DATE/INITIALS) increases the chance of mis-typing a field. Mitigation: every imported field is editable in the editor before sending. False negatives fall through to TEXT (always safe).
@@ -65,3 +65,4 @@ When you exceed a resource limit:
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
- [API Versioning](/docs/developers/api/versioning) - API version management
- [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Admins: set per-organisation resource quotas and rate limits (the HTTP rate limit above is separate and not admin-settable)
@@ -76,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 |
@@ -510,4 +510,5 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Set per-organisation document, email, and API limits from the admin panel
- [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues
@@ -29,6 +29,11 @@ description: Configure your self-hosted Documenso instance with environment vari
description="Digital signature certificate setup."
href="/docs/self-hosting/configuration/signing-certificate"
/>
<Card
title="Organisation Limits"
description="Set per-organisation document, email, and API limits via the admin panel."
href="/docs/self-hosting/configuration/organisation-limits"
/>
</Cards>
## Required Configuration
@@ -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,12 +2,14 @@
"title": "Configuration",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
"background-jobs",
"signing-certificate",
"telemetry",
"organisation-limits",
"advanced"
]
}
@@ -0,0 +1,111 @@
---
title: Organisation Limits
description: View and set per-organisation document, email, and API limits on a self-hosted Documenso instance using the admin panel's subscription claims.
---
import { Callout } from 'fumadocs-ui/components/callout';
Per-organisation limits — document, email, and API usage, plus feature toggles and team/member caps — are controlled by **subscription claims**. You configure them in the admin panel, not through environment variables.
There are three distinct kinds of limit:
| Limit | Caps | Admin-settable |
| ---------------------- | ------------------------------------------------- | ----------------------- |
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) |
## Prerequisites
- A running self-hosted Documenso instance.
- An account with the **`ADMIN`** role — an account-level role, separate from organisation and team roles. New accounts are created with the `USER` role only. Grant the first admin by adding `ADMIN` to that user's `roles` directly in the database; after that, an existing admin can grant the role to others under **Admin Panel > Users > _(user)_ > Roles > Update user**.
Open the admin panel at `/admin`. The sidebar sections used below are **Claims**, **Organisations**, and **Organisation Stats**.
## Viewing usage
**One organisation:** open **Admin Panel > Organisations** and select it. The **Organisation usage** section shows the current period's document, email, and API usage against its quotas.
**All organisations:** open **Admin Panel > Organisation Stats** to sort and filter monthly usage. Filter by **claim** and by **period** (a UTC calendar month, shown as `YYYY-MM`), and switch between **Show usage**, **Show usage with quotas**, and **Show daily averages**.
<Callout type="warn">
Usage counts **attempts**, not only successful actions. A request that exceeds a quota is still counted before it is rejected, so displayed usage can read higher than the number of actions that succeeded.
</Callout>
## Subscription claims
A subscription claim is a named bundle of limits and feature flags (for example `Free`, `Individual`, `Teams`, `Platform`, or `Enterprise`). Claims are **templates**: when an organisation is created it receives a private copy of its claim and reads from that copy afterwards. Editing a claim template therefore affects organisations created later, not existing ones — to change an existing organisation, [edit it directly](#change-limits-for-one-organisation).
### Claim fields
Under **Admin Panel > Claims** (`/admin/claims`), each claim has:
| Field | Controls |
| ----------------------- | --------------------------------------------------------------------------------- |
| **Name** | The claim's display name. |
| **Team Count** | Teams allowed. `0` = unlimited. |
| **Member Count** | Members allowed. `0` = unlimited. |
| **Envelope Item Count** | Uploaded files allowed per envelope. Minimum `1`. |
| **Recipient Count** | Recipients allowed per document. `0` = unlimited. |
| **Feature Flags** | Feature toggles (see [Feature flags](#feature-flags)). |
| **Limits** | Monthly quota and rate-limit windows for Documents, Emails, and API. |
| **Email transport** | Transport the claim uses. *Default (system mailer)* uses the instance default. |
### Quotas and rate limits
The **Limits** section has a column for **Documents**, **Emails**, and **API**, each with two controls:
- **Monthly quota** — how many of that resource are allowed per calendar month. An **empty** field is unlimited; **`0`** blocks the resource entirely.
- **Rate limit windows** — optional short-window caps, each a duration and a maximum. A window is a number and a unit (`s`, `m`, `h`, `d`), such as `5m`, `1h`, or `24h`, and must be unique within the resource.
<Callout type="warn">
Quotas and counts use opposite conventions for "unlimited": an **empty** quota is unlimited (and `0` blocks the resource), whereas `0` in the **Team**, **Member**, and **Recipient Count** fields means unlimited.
</Callout>
### Feature flags
The **Feature Flags** section toggles capabilities such as Unlimited documents, Branding, Hide Documenso branding, Email domains, Embed authoring, Embed signing, White label for embed authoring/signing, 21 CFR, HIPAA, Authentication portal, Allow Legacy Envelopes, Signing reminders, QES signing, and Disable emails.
Some flags are Enterprise features. If your license does not include one, it is marked and cannot be enabled (you can still turn it off). See [Enterprise Edition](/docs/policies/enterprise-edition).
### Create or edit a claim template
1. Go to **Admin Panel > Claims**.
2. Select **New claim**, or select an existing claim to edit it.
3. Set the counts, feature flags, and the **Limits** section.
4. Save. Changes apply to organisations created afterwards, not existing ones.
### Change limits for one organisation
To change limits for an existing organisation, edit it directly rather than its claim template.
1. Go to **Admin Panel > Organisations** and open the organisation.
2. Adjust its quota, rate-limit, feature-flag, or email-transport fields.
3. Save. Changes take effect immediately.
The organisation also shows the **Inherited subscription claim** it was created from.
## Usage reset
Monthly quota usage is keyed to the **UTC calendar month**. There is no scheduled reset job — when the month rolls over, the new period's counter starts at `0`.
## Limitations
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
## Troubleshooting
| Symptom | Cause and fix |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| An organisation hit its limit unexpectedly | Usage counts rejected over-quota attempts. Compare usage against the quota under **Organisation Stats > Show usage with quotas**. |
| A resource is blocked entirely, not just capped | The **Monthly quota** is `0`, which blocks the resource. Leave it empty for unlimited. |
| Emails are not sending for an organisation | Check whether the **Disable emails** flag is enabled on the organisation's claim — it blocks all emails regardless of quota. |
| A claim template edit had no effect | Template edits are not retroactive. Edit the organisation directly under **Admin Panel > Organisations**. |
---
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - All configuration options
- [Rate Limits](/docs/developers/api/rate-limits) - The global HTTP API rate limit (separate from claims)
- [Enterprise Edition](/docs/policies/enterprise-edition) - Features unlocked by license flags
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
</Step>
<Step>
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
---
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
name: ZNameSchema,
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
toast({
description: t`Folder created successfully`,
});
} catch (err) {
} catch (_err) {
toast({
title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`,
@@ -1,5 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -23,8 +24,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
name: ZNameSchema,
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
@@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
@@ -1,5 +1,6 @@
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({
passkeyName: z.string().min(3),
passkeyName: ZNameSchema,
});
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,4 +1,5 @@
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
});
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
data: true,
}).shape.data;
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: {
name: teamEmail.name,
},
mode: 'onSubmit',
});
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,5 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils';
@@ -23,15 +28,15 @@ import { useCspNonce } from '~/utils/nonce';
import { FormStickySaveBar } from './form-sticky-save-bar';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
brandingLogo: z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.refine(
(file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
)
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
@@ -245,7 +250,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative">
<Input
type="file"
accept={ACCEPTED_FILE_TYPES.join(',')}
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import {
Form,
FormControl,
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
name: ZNameSchema,
fromName: ZNameSchema,
fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(),
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
+2 -2
View File
@@ -1,8 +1,8 @@
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onBlur',
mode: 'onChange',
resolver: zodResolver(ZSignUpFormSchema),
});
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean;
/** When viewing a team, the parent organisation settings the team inherits from. */
inheritedSettings?: OrganisationGlobalSettings | null;
};
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
const notSet = <Trans>Not set</Trans>;
const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
}
return value;
return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
};
const brandingTextValue = (value: string | null | undefined) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value && value.trim() !== '') {
return value;
}
return value;
if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
};
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
if (value !== null && value !== undefined) {
return booleanLabel(value);
}
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
};
const visibilityLabel = (value: string | null | undefined) => {
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
};
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
const label = visibilityLabel(value);
if (label !== null) {
return label;
}
return inheritedValue(visibilityLabel(inherited));
};
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
<DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -19,7 +20,6 @@ import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z
.object({
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
name: ZNameSchema,
email: zEmail().min(1),
password: ZPasswordSchema,
})
@@ -1,11 +1,4 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react';
@@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input';
/**
* The rate-limit editor renders its own per-row inline errors, but a submit
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
* window). Rendering the field's message here guarantees the form never fails
* silently when those errors are not tied to a row the editor is showing.
*/
type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean;
};
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>;
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
const limitGroups: LimitGroup[] = [
{
title: <Trans>Documents</Trans>,
quotaKey: 'documentQuota',
rateLimitKey: 'documentRateLimits',
},
{
title: <Trans>Emails</Trans>,
quotaKey: 'emailQuota',
rateLimitKey: 'emailRateLimits',
},
{
title: <Trans>API</Trans>,
quotaKey: 'apiQuota',
rateLimitKey: 'apiRateLimits',
},
];
const renderQuotaField = (group: LimitGroup) => (
<FormField
control={control}
name={name(key)}
name={name(group.quotaKey)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
@@ -47,20 +73,18 @@ export const ClaimLimitFields = <T extends FieldValues>({
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
const renderRateLimitField = (key: string, label: ReactNode) => (
const renderRateLimitField = (group: LimitGroup) => (
<FormField
control={control}
name={name(key)}
name={name(group.rateLimitKey)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
);
return (
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
<div className="space-y-3">
<div>
<h3 className="font-semibold text-base">
<Trans>Limits</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
</Trans>
</p>
</div>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
<div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
{limitGroups.map((group) => (
<div key={group.quotaKey} className="space-y-4 p-4">
<h4 className="font-semibold text-sm">{group.title}</h4>
{renderQuotaField(
'emailQuota',
<Trans>Monthly email quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
{renderQuotaField(group)}
{renderRateLimitField(group)}
</div>
))}
</div>
</div>
</div>
);
};
@@ -20,20 +20,18 @@ import {
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, FormInputIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -80,8 +78,7 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig, flushAutosave, syncEnvelope } =
useCurrentEnvelopeEditor();
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -89,32 +86,7 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const [acroFormHasFieldsByItemRevision, setAcroFormHasFieldsByItemRevision] = useState<Record<string, boolean>>({});
const { revalidate } = useRevalidator();
const { toast } = useToast();
const { mutateAsync: importFieldsFromPdf, isPending: isImportingFieldsFromPdf } =
trpc.envelope.field.importFromPdf.useMutation();
const currentEnvelopeItemRevision = currentEnvelopeItem
? `${currentEnvelopeItem.id}:${currentEnvelopeItem.documentDataId}`
: null;
const currentItemHasAcroForm =
currentEnvelopeItemRevision !== null && acroFormHasFieldsByItemRevision[currentEnvelopeItemRevision] === true;
const onAcroFormDetected = useCallback(
(hasFields: boolean) => {
if (!currentEnvelopeItemRevision) {
return;
}
setAcroFormHasFieldsByItemRevision((prev) =>
prev[currentEnvelopeItemRevision] === hasFields ? prev : { ...prev, [currentEnvelopeItemRevision]: hasFields },
);
},
[currentEnvelopeItemRevision],
);
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
@@ -229,40 +201,6 @@ export const EnvelopeEditorFieldsPage = () => {
});
};
const onImportFromPdfClick = async () => {
try {
await flushAutosave();
const result = await importFieldsFromPdf({ envelopeId: envelope.id });
if (result.fieldsCreated === 0) {
toast({
title: _(msg`No form fields found`),
description: _(msg`This PDF does not contain any importable form fields.`),
duration: 5000,
});
return;
}
await syncEnvelope();
toast({
title: _(msg`Fields imported`),
description: _(
msg`Imported ${result.fieldsCreated} field${result.fieldsCreated === 1 ? '' : 's'} from the PDF form. Review and reassign in the editor.`,
),
duration: 5000,
});
} catch {
toast({
title: _(msg`Could not import fields`),
description: _(msg`Something went wrong while importing fields from the PDF.`),
variant: 'destructive',
duration: 5000,
});
}
};
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
@@ -350,7 +288,6 @@ export const EnvelopeEditorFieldsPage = () => {
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
onAcroFormDetected={onAcroFormDetected}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -443,20 +380,6 @@ export const EnvelopeEditorFieldsPage = () => {
/>
</>
)}
{currentItemHasAcroForm && envelope.status === DocumentStatus.DRAFT && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={() => void onImportFromPdfClick()}
disabled={isImportingFieldsFromPdf}
>
<FormInputIcon className="mr-2 -ml-1 h-4 w-4" />
{isImportingFieldsFromPdf ? <Trans>Importing...</Trans> : <Trans>Import from PDF form</Trans>}
</Button>
)}
</section>
{/* Field details section. */}
@@ -1,13 +1,38 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import {
getQuotaUsagePercent,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from '@documenso/lib/universal/quota-usage';
import { cn } from '@documenso/ui/lib/utils';
import type { BadgeProps } from '@documenso/ui/primitives/badge';
import { Badge } from '@documenso/ui/primitives/badge';
import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import { useState } from 'react';
import { match } from 'ts-pattern';
import type { LucideIcon } from 'lucide-react';
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useId, useState } from 'react';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type CapacityUsage = {
members: number;
teams: number;
};
type UsageRow = {
counter: 'document' | 'email' | 'api';
label: ReactNode;
icon: LucideIcon;
used: number;
effectiveLimit: number | null;
};
type OrganisationUsagePanelProps = {
organisationId: string;
monthlyStats: Pick<
@@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
organisationClaim: OrganisationClaim;
capacityUsage?: CapacityUsage;
};
type UsageCardState = {
status: {
label: ReactNode;
variant: NonNullable<BadgeProps['variant']>;
};
percent: number;
hasFiniteLimit: boolean;
progressClassName: string;
subtext: ReactNode;
};
type UsageCardStateOptions = {
used: number;
limit: number | null | undefined;
footnote?: ReactNode;
};
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
const percent = getQuotaUsagePercent(used, limit ?? null);
const hasFiniteLimit = Boolean(limit && limit > 0);
if (limit === null || limit === undefined) {
return {
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
}
if (limit === 0) {
return {
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? <Trans>Resource blocked</Trans>,
};
}
if (used > limit) {
return {
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-destructive',
subtext: footnote ?? null,
};
}
if (isQuotaExceeded(limit, used)) {
return {
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
subtext: footnote ?? null,
};
}
if (isQuotaNearing(limit, used)) {
return {
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
subtext: footnote ?? null,
};
}
return {
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
};
type UsageStatCardProps = {
label: ReactNode;
icon: LucideIcon;
used: number;
limit: number | null | undefined;
/** When true the card is a plain counter with no limit, status or progress. */
countOnly?: boolean;
footnote?: ReactNode;
action?: ReactNode;
};
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
return (
<div className="flex flex-col rounded-lg border bg-background p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
<Icon className="h-4 w-4 text-muted-foreground" />
<span>{label}</span>
</div>
{!countOnly && (
<Badge variant={status.variant} size="small">
{status.label}
</Badge>
)}
</div>
<div className="mt-4 flex flex-1 flex-col">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5">
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
{used.toLocaleString()}
</span>
{hasFiniteLimit ? (
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
) : null}
</div>
{hasFiniteLimit ? (
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
) : null}
</div>
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
</div>
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
</div>
);
};
export const OrganisationUsagePanel = ({
organisationId,
monthlyStats,
organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
@@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [
const capacityRows = capacityUsage
? [
{
key: 'members',
label: <Trans>Members</Trans>,
icon: UsersIcon,
used: capacityUsage.members,
limit: normalizeCapacityLimit(organisationClaim.memberCount),
},
{
key: 'teams',
label: <Trans>Teams</Trans>,
icon: UsersRoundIcon,
used: capacityUsage.teams,
limit: normalizeCapacityLimit(organisationClaim.teamCount),
},
]
: [];
const monthlyRows: UsageRow[] = [
{
counter: 'document' as const,
counter: 'document',
label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email' as const,
counter: 'email',
label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api' as const,
counter: 'api',
label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3>
<div className="mt-4 space-y-6">
{capacityRows.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{capacityRows.map((row) => (
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<Trans>Monthly usage</Trans>
</h3>
{rows.map((row) => {
const percent =
row.effectiveLimit && row.effectiveLimit > 0
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
: 0;
{monthlyStats.length > 0 ? (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
return (
<div key={row.counter} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{row.label}</span>
<span className="text-muted-foreground">
{row.used} /{' '}
{match(row.effectiveLimit)
.with(null, () => <Trans>Unlimited</Trans>)
.with(0, () => <Trans>Blocked</Trans>)
.otherwise(String)}
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{monthlyRows.map((row) => (
<UsageStatCard
key={row.counter}
label={row.label}
icon={row.icon}
used={row.used}
limit={row.effectiveLimit}
action={
selectedStat && isCurrentPeriod ? (
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
) : undefined
}
/>
))}
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div>
)}
</div>
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
<UsageStatCard
label={<Trans>Reports</Trans>}
icon={MailOpenIcon}
used={selectedStat?.emailReports ?? 0}
limit={null}
countOnly
footnote={<Trans>Sent this period</Trans>}
/>
</div>
</div>
</div>
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = {
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending}
onClick={() => reset({ organisationId, counter })}
>
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans>
</Button>
);
@@ -46,7 +46,7 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
return (
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}-${currentEnvelopeItem.documentDataId}`}
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
@@ -50,7 +50,6 @@ export type PDFViewerProps = {
scrollParentRef: ScrollTarget;
onDocumentLoad?: () => void;
onAcroFormDetected?: (hasFields: boolean) => void;
/**
* Additional component to render next to the image, such as a Konva canvas
@@ -64,7 +63,6 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
onAcroFormDetected,
customPageRenderer,
...props
}: PDFViewerProps) {
@@ -126,20 +124,6 @@ export default function PDFViewer({
// eslint-disable-next-line require-atomic-updates
pdfRef.current = loadedPdf;
if (onAcroFormDetected) {
try {
const fieldObjects = await loadedPdf.getFieldObjects();
if (!isCancelled) {
onAcroFormDetected(fieldObjects !== null && Object.keys(fieldObjects).length > 0);
}
} catch {
if (!isCancelled) {
onAcroFormDetected(false);
}
}
}
// Fetch the pages
const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => {
const page = await loadedPdf.getPage(pageIndex + 1);
@@ -184,7 +168,7 @@ export default function PDFViewer({
pdfRef.current = null;
}
};
}, [data, onAcroFormDetected]);
}, [data]);
// Notify when document is loaded
useEffect(() => {
@@ -1,7 +1,9 @@
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number };
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
disabled?: boolean;
};
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
/** Keep in-progress rows; drop rows that are completely empty. */
const persistEntries = (entries: RateLimitEntryValue[]) => {
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
};
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const entries = value ?? [];
const { t } = useLingui();
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
const window = entry.window.trim();
if (!hasEntryInput(entry)) {
return null;
}
if (window === '') {
return t`Enter a window, e.g. 5m`;
}
if (!RATE_LIMIT_WINDOW_REGEX.test(window)) {
return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`;
}
const isDuplicateWindow = entries.some((otherEntry, otherIndex) => {
return otherIndex !== index && otherEntry.window.trim() === window;
});
return isDuplicateWindow ? t`Use a unique window for each rate limit` : null;
};
const getMaxError = (entry: RateLimitEntryValue) => {
if (!hasEntryInput(entry)) {
return null;
}
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
};
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(next);
if (index >= value.length) {
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
if (hasEntryInput(nextDraftEntry)) {
onChange(persistEntries([...value, nextDraftEntry]));
setDraftEntry(null);
return;
}
setDraftEntry(nextDraftEntry);
return;
}
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(persistEntries(next));
};
const removeEntry = (index: number) => {
onChange(entries.filter((_, i) => i !== index));
if (index >= value.length) {
setDraftEntry(null);
return;
}
const next = value.filter((_, i) => i !== index);
onChange(persistEntries(next));
};
const addEntry = () => {
onChange([...entries, { window: '5m', max: 100 }]);
setDraftEntry(EMPTY_ENTRY);
};
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
return (
<div className="space-y-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="w-24"
placeholder="5m"
value={entry.window}
disabled={disabled}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="w-32"
type="number"
min={1}
value={entry.max}
disabled={disabled}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span className="w-20 shrink-0">
<Trans>Window</Trans>
</span>
<span className="flex-1">
<Trans>Max requests</Trans>
</span>
<span className="w-9 shrink-0" aria-hidden="true" />
</div>
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
{entries.map((entry, index) => {
const windowError = getWindowError(entry, index);
const maxError = getMaxError(entry);
return (
<div key={index} className="space-y-1">
<div className="flex items-center gap-2">
<Input
className="w-20 shrink-0"
placeholder="5m"
value={entry.window}
disabled={disabled}
aria-invalid={Boolean(windowError)}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="flex-1"
type="number"
min={1}
placeholder="100"
value={entry.max || ''}
disabled={disabled}
aria-invalid={Boolean(maxError)}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
disabled={disabled}
aria-label={t`Remove rate limit`}
onClick={() => removeEntry(index)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-dashed"
disabled={isAddDisabled}
onClick={addEntry}
>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit</Trans>
<Trans>Add rate limit window</Trans>
</Button>
</div>
);
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
};
const ZUpdatePasskeySchema = z.object({
name: z.string(),
name: ZNameSchema,
});
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -30,7 +31,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
@@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<div className="mt-6 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-sm">
<Trans>Organisation usage</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<SettingsHeader
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<DetailsCard label={<Trans>Members</Trans>}>
<DetailsValue>
{organisation.members.length} /{' '}
{organisation.organisationClaim.memberCount === 0
? t`Unlimited`
: organisation.organisationClaim.memberCount}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Teams</Trans>}>
<DetailsValue>
{organisation.teams.length} /{' '}
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
</DetailsValue>
</DetailsCard>
</div>
<div className="mt-4">
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
/>
</div>
</div>
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
capacityUsage={{
members: organisation.members.length,
teams: organisation.teams.length,
}}
/>
<div className="mt-6 rounded-lg border p-4">
<Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<p className="font-medium text-sm">
<p className="font-semibold text-base">
<Trans>Global Settings</Trans>
</p>
<p className="mt-1 font-normal text-muted-foreground text-sm">
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans>
</p>
</div>
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16"
/>
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<Alert
className={cn(
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
)}
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Subscription</Trans>
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2">
{organisation.subscription ? (
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
<span className="flex items-center gap-2">
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
)}
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
</span>
) : (
<span>
<Trans>No subscription found</Trans>
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<Button
variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline">
<Button variant="outline" className="bg-background">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10">
<div>
<label className="font-medium text-sm leading-none">
<h3 className="font-semibold text-base">
<Trans>Organisation Members</Trans>
</label>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
<div className="my-2">
<div className="mt-3">
<DataTable columns={organisationMembersColumns} data={organisation.members} />
</div>
</div>
<div>
<label className="font-medium text-sm leading-none">
<h3 className="font-semibold text-base">
<Trans>Organisation Teams</Trans>
</label>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
<div className="my-2">
<div className="mt-3">
<DataTable columns={teamsColumns} data={organisation.teams} />
</div>
</div>
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
{field.value ? (
<span className="font-mono text-foreground">{field.value}</span>
) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
</FormItem>
)}
/>
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<FormLabel>
<h3 className="font-semibold text-base">
<Trans>Feature Flags</Trans>
</FormLabel>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
<div className="mt-2 space-y-2 rounded-md border p-4">
<div className="mt-3 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
<AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateOrganisationBrandingLogo(formData);
}
const result = await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -3,6 +3,7 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -28,7 +29,6 @@ import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { z } from 'zod';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import {
@@ -36,7 +36,6 @@ import {
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}
const ZUpdateOrganisationGroupFormSchema = z.object({
name: z.string().min(1, msg`Name is required`.id),
name: ZNameSchema,
organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()),
});
@@ -1,6 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -38,6 +37,7 @@ export default function TeamsSettingsPage() {
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
@@ -48,22 +48,23 @@ export default function TeamsSettingsPage() {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
formData.append('payload', JSON.stringify({ teamId: team.id }));
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateTeamBrandingLogo(formData);
}
const result = await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
+5 -5
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.14.0"
"version": "2.15.0"
}
+1 -28
View File
@@ -1,9 +1,8 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
-1
View File
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
// Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute);
// AI route.
-715
View File
@@ -1,715 +0,0 @@
%PDF-1.7
%âãÏÓ
1 0 obj
<<
/Type /Pages
/Kids [4 0 R 5 0 R]
/Count 2
>>
endobj
2 0 obj
<<
/Type /Catalog
/Pages 1 0 R
/AcroForm 6 0 R
>>
endobj
3 0 obj
<<
/Title (Untitled)
/Author (Unknown)
/Creator (@libpdf/core)
/Producer (@libpdf/core)
/CreationDate (D:20260521033345Z)
/ModDate (D:20260521033345Z)
>>
endobj
4 0 obj
<<
/Type /Page
/MediaBox [0 0 612 792]
/Resources <<
>>
/Parent 1 0 R
/Annots [8 0 R 11 0 R 14 0 R]
>>
endobj
5 0 obj
<<
/Type /Page
/MediaBox [0 0 612 792]
/Resources <<
>>
/Parent 1 0 R
/Annots [18 0 R 21 0 R 24 0 R 27 0 R 31 0 R 34 0 R 37 0 R]
>>
endobj
6 0 obj
<<
/Fields [7 0 R 10 0 R 13 0 R 17 0 R 20 0 R 30 0 R 33 0 R 36 0 R]
/DR <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
/DA (/Helv 0 Tf 0 g)
/NeedAppearances false
/SigFlags 3
>>
endobj
7 0 obj
<<
/FT /Tx
/T (CustomerName)
/Kids [8 0 R]
/DA (0 g)
>>
endobj
8 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 620 280 644]
/P 4 0 R
/Parent 7 0 R
/F 4
/AP <<
/N 9 0 R
>>
>>
endobj
9 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 198 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
10 0 obj
<<
/FT /Tx
/T (signed_date)
/Kids [11 0 R]
/DA (0 g)
>>
endobj
11 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 560 280 584]
/P 4 0 R
/Parent 10 0 R
/F 4
/AP <<
/N 12 0 R
>>
>>
endobj
12 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 198 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
13 0 obj
<<
/FT /Btn
/T (accept_terms)
/Kids [14 0 R]
/V /Off
>>
endobj
14 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 500 98 518]
/P 4 0 R
/Parent 13 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Yes 15 0 R
/Off 16 0 R
>>
>>
>>
endobj
15 0 obj
<<
/Length 47/Type /XObject
/Subtype /Form
/BBox [0 0 18 18]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 12.6 Tf
0 g
2.7 4.59 Td
(4) Tj
ET
Q
endstream
endobj
16 0 obj
<<
/Length 0/Type /XObject
/Subtype /Form
/BBox [0 0 18 18]
/Resources <<
>>
>>
stream
endstream
endobj
17 0 obj
<<
/FT /Ch
/T (country)
/Ff 131072
/Kids [18 0 R]
/Opt [(USA) (Canada) (Germany)]
/DA (0 g)
/V (USA)
/DV (USA)
>>
endobj
18 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 700 280 724]
/P 5 0 R
/Parent 17 0 R
/F 4
/AP <<
/N 19 0 R
>>
>>
endobj
19 0 obj
<<
/Length 76/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 178 22 re
W
n
BT
/Helv 14 Tf
0 g
2 9.872 Td
(USA) Tj
ET
Q
EMC
endstream
endobj
20 0 obj
<<
/FT /Btn
/T (payment_method)
/Ff 32768
/Kids [21 0 R 24 0 R 27 0 R]
/V /PayPal
/DV /PayPal
/Opt [(Credit Card) (PayPal) (Bank Transfer)]
>>
endobj
21 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 640 96 656]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Credit#20Card 22 0 R
/Off 23 0 R
>>
>>
>>
endobj
22 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
23 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
24 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 615 96 631]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /PayPal
/AP <<
/N <<
/PayPal 25 0 R
/Off 26 0 R
>>
>>
>>
endobj
25 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
26 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
27 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 590 96 606]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Bank#20Transfer 28 0 R
/Off 29 0 R
>>
>>
>>
endobj
28 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
29 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
30 0 obj
<<
/FT /Tx
/T (initials)
/Kids [31 0 R]
/DA (0 g)
>>
endobj
31 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 540 140 564]
/P 5 0 R
/Parent 30 0 R
/F 4
/AP <<
/N 32 0 R
>>
>>
endobj
32 0 obj
<<
/Length 72/Type /XObject
/Subtype /Form
/BBox [0 0 60 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 58 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
33 0 obj
<<
/FT /Tx
/T (contact_email)
/Kids [34 0 R]
/DA (0 g)
>>
endobj
34 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [160 540 380 564]
/P 5 0 R
/Parent 33 0 R
/F 4
/AP <<
/N 35 0 R
>>
>>
endobj
35 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 220 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 218 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
36 0 obj
<<
/FT /Tx
/T (item_qty)
/Kids [37 0 R]
/DA (0 g)
/MaxLen 4
>>
endobj
37 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [400 540 460 564]
/P 5 0 R
/Parent 36 0 R
/F 4
/AP <<
/N 38 0 R
>>
>>
endobj
38 0 obj
<<
/Length 72/Type /XObject
/Subtype /Form
/BBox [0 0 60 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 58 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
xref
0 39
0000000000 65535 f
0000000015 00000 n
0000000078 00000 n
0000000143 00000 n
0000000312 00000 n
0000000430 00000 n
0000000577 00000 n
0000000866 00000 n
0000000937 00000 n
0000001058 00000 n
0000001319 00000 n
0000001391 00000 n
0000001515 00000 n
0000001777 00000 n
0000001849 00000 n
0000002004 00000 n
0000002242 00000 n
0000002356 00000 n
0000002486 00000 n
0000002610 00000 n
0000002875 00000 n
0000003034 00000 n
0000003199 00000 n
0000003436 00000 n
0000003711 00000 n
0000003872 00000 n
0000004109 00000 n
0000004384 00000 n
0000004551 00000 n
0000004788 00000 n
0000005063 00000 n
0000005132 00000 n
0000005256 00000 n
0000005516 00000 n
0000005590 00000 n
0000005715 00000 n
0000005977 00000 n
0000006056 00000 n
0000006181 00000 n
trailer
<<
/Size 39
/Root 2 0 R
/Info 3 0 R
/ID [<4798EA6ACCB2CE19827C36CD841F759A> <4798EA6ACCB2CE19827C36CD841F759A>]
>>
startxref
6441
%%EOF
+1931 -1267
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.14.0",
"version": "2.15.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
expect(res.status()).toBe(401);
});
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -0,0 +1,37 @@
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
import { expect, test } from '@playwright/test';
import sharp from 'sharp';
const makePng = async (width = 1200, height = 1200) =>
sharp({
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
test.describe('optimiseBrandingLogo', () => {
test('re-encodes a valid image to a PNG buffer', async () => {
const input = await makePng();
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.format).toBe('png');
});
test('bounds the image to a maximum of 512px on its largest side', async () => {
const input = await makePng(2000, 1000);
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.width).toBeLessThanOrEqual(512);
expect(metadata.height).toBeLessThanOrEqual(512);
});
test('rejects input that is not a valid image', async () => {
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
});
});
@@ -0,0 +1,225 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from './fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
const enableBrandingAndUpload = async (page: Page) => {
// Enable custom branding so the file input is no longer disabled.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
// Upload the logo file through the real multipart route.
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
};
/**
* POST a logo straight to the dedicated multipart tRPC route using the
* authenticated browser cookies. This bypasses the client-side form validation,
* which is the only way to exercise the server-side image validation /
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
*/
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
const multipart: Record<string, string | MultipartFile> = {
payload: JSON.stringify({ organisationId }),
};
if (file) {
multipart.brandingLogo = file;
}
return await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
};
/**
* Grant the organisation the custom-branding entitlement. The positive branding
* flows require it whenever billing is enabled; with billing disabled the gate is
* bypassed, so this keeps these tests valid in both modes.
*/
const grantCustomBranding = async (organisationClaimId: string) => {
await prisma.organisationClaim.update({
where: { id: organisationClaimId },
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
});
};
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
const parsed = JSON.parse(settings.brandingLogo);
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
// through the team relation.
const teamWithSettings = await prisma.team.findUniqueOrThrow({
where: { id: team.id },
include: { teamGlobalSettings: true },
});
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// Confirm the logo was stored before we clear it.
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
// Remove the logo and save again.
await page.getByRole('button', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
// Clearing the logo persists an empty string via the dedicated route.
await expect
.poll(async () => {
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
return updated.brandingLogo;
})
.toBe('');
});
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Positive control: a genuine PNG is accepted and stored. This also proves the
// direct multipart request shape matches what the route expects.
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(validResponse.ok()).toBeTruthy();
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(afterValid.brandingLogo).toBeTruthy();
// Bytes that pass the MIME/size allowlist but are not a real image must be
// rejected by the server (the `sharp` re-encode) without changing stored state.
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'fake.png',
mimeType: 'image/png',
buffer: Buffer.from('this is definitely not a valid png'),
});
expect(invalidResponse.ok()).toBeFalsy();
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
expect(invalidResponse.status()).toBeLessThan(500);
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
// The previously stored, valid logo is left untouched by the rejected upload.
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
});
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
// The entitlement is only enforced when billing is enabled; with billing off
// the check is intentionally skipped server-side, so this can't be exercised.
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
'Entitlement is only enforced when billing is enabled.',
);
// Seeded organisations have no `allowCustomBranding` claim flag.
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
const response = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(response.ok()).toBeFalsy();
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeFalsy();
});
@@ -1,368 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { PDF, PdfString } from '@libpdf/core';
import { type APIRequestContext, expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const ACROFORM_FIXTURE = fs.readFileSync(path.join(__dirname, '../../../../assets/acroform-import-test.pdf'));
const ACROFORM_DOCUMENT_PAYLOAD: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document',
recipients: [
{
email: 'signer@example.com',
name: 'Signer',
role: RecipientRole.SIGNER,
},
],
};
const API_REQUEST_METADATA: ApiRequestMetadata = {
requestMetadata: {},
source: 'apiV1',
auth: 'api',
};
type TestUser = Awaited<ReturnType<typeof seedUser>>['user'];
type TestTeam = Awaited<ReturnType<typeof seedUser>>['team'];
const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser; team: TestTeam }> => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
return { token, user, team };
};
const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const form = pdfDoc.getForm();
return (form?.fieldCount ?? 0) > 0;
};
const createSignedSignatureAcroFormPdf = (): Promise<Uint8Array> => {
const pdf = PDF.create();
const page = pdf.addPage({ size: 'letter' });
const form = pdf.getOrCreateForm();
const textField = form.createTextField('full_name');
const signatureField = form.createSignatureField('signed_signature');
page.drawField(textField, { x: 100, y: 700, width: 200, height: 24 });
signatureField.getDict().set('V', PdfString.fromString('fake-signature'));
return pdf.save();
};
const uploadAcroFormEnvelope = async ({
request,
token,
payload = ACROFORM_DOCUMENT_PAYLOAD,
file = ACROFORM_FIXTURE,
fileName = 'acroform-import-test.pdf',
}: {
request: APIRequestContext;
token: string;
payload?: TCreateEnvelopePayload;
file?: Uint8Array;
fileName?: string;
}): Promise<TCreateEnvelopeResponse> => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([file], fileName, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const importAcroFormFieldsWithSession = ({
page,
teamId,
envelopeId,
}: {
page: Page;
teamId: number;
envelopeId: string;
}) =>
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.importFromPdf`, {
headers: {
'content-type': 'application/json',
'x-team-id': String(teamId),
},
data: JSON.stringify({ json: { envelopeId } }),
});
const loadEnvelopeForImport = async (envelopeId: string) =>
prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
test.describe.configure({
mode: 'parallel',
});
test.describe('AcroForm Import', () => {
test('upload does not create fields and preserves widgets in the stored PDF', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(envelope.fields).toHaveLength(0);
const pdfBuffer = await getFileServerSide(envelope.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('replacement preserves widgets in the stored PDF for later import', async ({ request }) => {
const { token, user } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await loadEnvelopeForImport(response.id);
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
await UNSAFE_replaceEnvelopeItemPdf({
envelope,
recipients: envelope.recipients,
envelopeItemId: envelope.envelopeItems[0].id,
oldDocumentDataId,
data: {
title: 'Replacement AcroForm document',
file: new File([ACROFORM_FIXTURE], 'replacement-acroform.pdf', { type: 'application/pdf' }),
},
user,
apiRequestMetadata: API_REQUEST_METADATA,
});
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(after.fields).toHaveLength(0);
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('import creates fields assigned to the signer, flattens the PDF, and emits audit logs', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await loadEnvelopeForImport(response.id);
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
const result = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(result.fieldsCreated).toBeGreaterThan(0);
expect(result.itemsProcessed).toBe(1);
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
fields: true,
},
});
expect(after.fields.length).toBeGreaterThanOrEqual(8);
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
for (const field of after.fields) {
const meta = field.fieldMeta as { source?: string } | null;
expect(meta?.source).toBe('acroform');
}
const auditEntries = await prisma.documentAuditLog.findMany({
where: { envelopeId: after.id, type: 'FIELD_CREATED' },
});
expect(auditEntries.length).toBe(after.fields.length);
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
const flattenedPdf = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(flattenedPdf)).toBe(false);
const oldRecord = await prisma.documentData.findUnique({ where: { id: oldDocumentDataId } });
expect(oldRecord).toBeNull();
});
test('import creates a placeholder Recipient 1 SIGNER when no recipients exist', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document without recipients',
},
});
const envelope = await loadEnvelopeForImport(response.id);
expect(envelope.recipients).toHaveLength(0);
await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: { recipients: true, fields: true },
});
expect(after.recipients).toHaveLength(1);
expect(after.recipients[0].email).toBe('recipient.1@documenso.com');
expect(after.recipients[0].role).toBe(RecipientRole.SIGNER);
expect(after.fields.length).toBeGreaterThanOrEqual(8);
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
});
test('import endpoint rejects template envelopes without mutating stored widgets', async ({ page, request }) => {
const { token, user, team } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.TEMPLATE,
title: 'AcroForm template',
},
});
await apiSignin({ page, email: user.email });
const res = await importAcroFormFieldsWithSession({
page,
teamId: team.id,
envelopeId: response.id,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(after.fields).toHaveLength(0);
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('import does not duplicate fields when signed signatures prevent flattening', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const signedPdf = await createSignedSignatureAcroFormPdf();
const response = await uploadAcroFormEnvelope({
request,
token,
file: signedPdf,
fileName: 'signed-acroform.pdf',
});
const envelope = await loadEnvelopeForImport(response.id);
const firstResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(firstResult.fieldsCreated).toBeGreaterThan(0);
expect(firstResult.itemsProcessed).toBe(1);
expect(firstResult.signedSignatureCount).toBe(1);
const afterFirst = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
const firstFieldCount = afterFirst.fields.length;
const preservedPdf = await getFileServerSide(afterFirst.envelopeItems[0].documentData);
expect(await pdfHasFormFields(preservedPdf)).toBe(true);
const secondEnvelope = await loadEnvelopeForImport(response.id);
const secondResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope: secondEnvelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(secondResult.fieldsCreated).toBe(0);
expect(secondResult.itemsProcessed).toBe(0);
const afterSecond = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: { fields: true },
});
expect(afterSecond.fields).toHaveLength(firstFieldCount);
});
});
@@ -93,7 +93,7 @@ test.describe('Form Flattening', () => {
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
test.describe('Envelope Creation (DOCUMENT type)', () => {
test('should preserve form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -136,16 +136,16 @@ test.describe('Form Flattening', () => {
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should preserve form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -157,6 +157,7 @@ test.describe('Form Flattening', () => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document without Form Values',
// No formValues - but form should still be flattened for DOCUMENT type
};
const formData = new FormData();
@@ -183,10 +184,13 @@ test.describe('Form Flattening', () => {
},
});
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
});
@@ -743,10 +747,11 @@ test.describe('Form Flattening', () => {
},
});
// Form should still be flattened for DOCUMENT type
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
test('should handle partial formValues (only some fields)', async ({ request }) => {
@@ -793,11 +798,11 @@ test.describe('Form Flattening', () => {
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
});
// Form should still be flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe('Only this field');
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
});
});
@@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
});
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
page,
}) => {
const { user, team, organisation } = await seedUser();
await enableOrganisationBranding({
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
});
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
// Branding enabled → the custom logo is rendered on the signing page.
await page.goto(`/sign/${recipients[0].token}`);
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
// Disable branding while keeping the stored logo (the team inherits this).
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { brandingEnabled: false },
});
// Branding disabled → the custom logo is gone and the Documenso fallback
// (an internal link to "/") is shown instead.
await page.goto(`/sign/${recipients[0].token}`);
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
await expect(page.locator('a[href="/"]').first()).toBeVisible();
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
@@ -43,7 +43,6 @@ type EnvelopeRenderItem = {
title: string;
order: number;
envelopeId: string;
documentDataId: string;
/**
* The PDF data to render.
-15
View File
@@ -1,25 +1,10 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const SALT_ROUNDS = 12;
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering.
*/
export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.max(255, { message: 'Name cannot be more than 255 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso',
GOOGLE: 'Google',
+10
View File
@@ -9,3 +9,13 @@
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
/**
* Branding logo upload constraints. Enforced server-side at the TRPC request
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
*/
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
@@ -0,0 +1,26 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { putFileServerSide } from '../../universal/upload/put-file.server';
import { optimiseBrandingLogo } from '../../utils/images/logo';
/**
* Validate, sanitise and store an uploaded branding logo. Returns the
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
* column (the same format the serving endpoints already expect).
*/
export const buildBrandingLogoData = async (file: File): Promise<string> => {
const buffer = Buffer.from(await file.arrayBuffer());
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'The branding logo must be a valid image file.',
});
});
const documentData = await putFileServerSide({
name: 'branding-logo.png',
type: 'image/png',
arrayBuffer: async () => Promise.resolve(optimised),
});
return JSON.stringify(documentData);
};
@@ -54,7 +54,7 @@ export const UNSAFE_createEnvelopeItems = async ({
}
const normalized = await normalizePdf(buffer, {
flattenForm: false,
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -1,349 +0,0 @@
import { prisma } from '@documenso/prisma';
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { nanoid } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { logger } from '../../utils/logger';
import {
type AcroFormExtractionResult,
type AcroFormSkipReason,
convertAcroFormFieldsToFieldInputs,
extractAcroFormFieldsFromPDF,
} from '../pdf/acroform-fields';
import { normalizePdf } from '../pdf/normalize-pdf';
type UnsafeImportAcroFormFieldsOptions = {
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'> & {
envelopeItems: (Pick<EnvelopeItem, 'id' | 'title' | 'documentDataId'> & {
documentData: DocumentData;
})[];
recipients: Recipient[];
};
apiRequestMetadata: ApiRequestMetadata;
};
type PerItemSkip = {
envelopeItemId: string;
envelopeItemTitle: string;
reason: AcroFormSkipReason;
};
export type ImportAcroFormFieldsResult = {
itemsProcessed: number;
fieldsCreated: number;
unsupportedCount: number;
signedSignatureCount: number;
skippedItems: PerItemSkip[];
fields: Field[];
};
type PreparedItem = {
envelopeItemId: string;
envelopeItemTitle: string;
oldDocumentDataId: string;
extraction: AcroFormExtractionResult;
newDocumentData?: DocumentData;
};
export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
envelope,
apiRequestMetadata,
}: UnsafeImportAcroFormFieldsOptions): Promise<ImportAcroFormFieldsResult> => {
if (envelope.type !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only supported for document envelopes',
});
}
const prepared: PreparedItem[] = await Promise.all(
envelope.envelopeItems.map(async (item): Promise<PreparedItem> => {
const buffer = await getFileServerSide(item.documentData);
const extraction = await extractAcroFormFieldsFromPDF(Buffer.from(buffer), {
formValuesProvided: Boolean(envelope.formValues),
});
if (extraction.skipReason) {
logger.info(
{
event: 'acroform-import.skip',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
reason: extraction.skipReason,
},
'AcroForm extraction skipped',
);
}
if (extraction.unsupported.length > 0) {
const byReason: Record<string, number> = {};
for (const entry of extraction.unsupported) {
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
}
logger.info(
{
event: 'acroform-import.unsupported',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
count: extraction.unsupported.length,
byReason,
},
'AcroForm import skipped unsupported widgets',
);
}
if (extraction.hasSignedSignature) {
logger.warn(
{
event: 'acroform-import.signed-pdf-no-flatten',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
'Signed AcroForm signature detected — skipping flatten to preserve signature',
);
}
const base: PreparedItem = {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
oldDocumentDataId: item.documentDataId,
extraction,
};
if (extraction.fields.length === 0 || extraction.hasSignedSignature) {
return base;
}
const flattened = await normalizePdf(Buffer.from(buffer), {
flattenForm: true,
});
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: item.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(flattened),
});
return {
...base,
newDocumentData,
};
}),
);
const totalFieldsToCreate = prepared.reduce((sum, p) => sum + p.extraction.fields.length, 0);
const unsupportedCount = prepared.reduce((sum, p) => sum + p.extraction.unsupported.length, 0);
const signedSignatureCount = prepared.filter((p) => p.extraction.hasSignedSignature).length;
const skippedItems: PerItemSkip[] = [];
for (const p of prepared) {
const reason = p.extraction.skipReason;
if (!reason) {
continue;
}
skippedItems.push({
envelopeItemId: p.envelopeItemId,
envelopeItemTitle: p.envelopeItemTitle,
reason,
});
}
if (totalFieldsToCreate === 0) {
return {
itemsProcessed: 0,
fieldsCreated: 0,
unsupportedCount,
signedSignatureCount,
skippedItems,
fields: [],
};
}
const { createdFields, importedItemsCount } = await prisma.$transaction(async (tx) => {
const pickFirstSignableRecipient = (recipients: Pick<Recipient, 'id' | 'email' | 'role' | 'signingOrder'>[]) => {
const signable = recipients.filter((r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER);
if (signable.length === 0) {
return null;
}
return signable.sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
})[0];
};
const signedItemIds = prepared
.filter((item) => item.extraction.hasSignedSignature && item.extraction.fields.length > 0)
.map((item) => item.envelopeItemId);
const alreadyImportedSignedItemIds = new Set<string>();
if (signedItemIds.length > 0) {
const existingImportedFields = await tx.field.findMany({
where: {
envelopeId: envelope.id,
envelopeItemId: {
in: signedItemIds,
},
},
select: {
envelopeItemId: true,
fieldMeta: true,
},
});
for (const field of existingImportedFields) {
const fieldMeta = field.fieldMeta;
if (
fieldMeta &&
typeof fieldMeta === 'object' &&
!Array.isArray(fieldMeta) &&
(fieldMeta as { source?: unknown }).source === 'acroform'
) {
alreadyImportedSignedItemIds.add(field.envelopeItemId);
}
}
}
const itemsToImport = prepared.filter((item) => {
if (item.extraction.fields.length === 0) {
return false;
}
return !(item.extraction.hasSignedSignature && alreadyImportedSignedItemIds.has(item.envelopeItemId));
});
const createdFields: Field[] = [];
if (itemsToImport.length === 0) {
return { createdFields, importedItemsCount: 0 };
}
let recipient = pickFirstSignableRecipient(
await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
}),
);
if (!recipient) {
const placeholderEmail = 'recipient.1@documenso.com';
recipient = await tx.recipient.create({
data: {
envelopeId: envelope.id,
email: placeholderEmail,
name: 'Recipient 1',
role: RecipientRole.SIGNER,
signingOrder: 1,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
select: { id: true, email: true, role: true, signingOrder: true },
});
}
let importedItemsCount = 0;
for (const item of itemsToImport) {
if (item.newDocumentData) {
await tx.envelopeItem.update({
where: { id: item.envelopeItemId },
data: { documentDataId: item.newDocumentData.id },
});
}
const fieldsToCreate = convertAcroFormFieldsToFieldInputs(
item.extraction.fields,
() => recipient,
item.envelopeItemId,
);
const itemCreatedFields = await tx.field.createManyAndReturn({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: item.envelopeItemId,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
createdFields.push(...itemCreatedFields);
importedItemsCount += 1;
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.createMany({
data: itemCreatedFields.map((createdField) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: apiRequestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: recipient.email,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
),
});
}
}
return { createdFields, importedItemsCount };
});
await Promise.all(
prepared
.filter((p) => p.newDocumentData !== undefined)
.map((p) =>
prisma.documentData.delete({ where: { id: p.oldDocumentDataId } }).catch((err) => {
logger.error(
{
event: 'acroform-import.delete-old-document-data-failed',
envelopeItemId: p.envelopeItemId,
oldDocumentDataId: p.oldDocumentDataId,
err,
},
'Failed to delete orphaned DocumentData after AcroForm import',
);
}),
),
);
return {
itemsProcessed: importedItemsCount,
fieldsCreated: createdFields.length,
unsupportedCount,
signedSignatureCount,
skippedItems,
fields: createdFields,
};
};
@@ -82,7 +82,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
}
const normalized = await normalizePdf(buffer, {
flattenForm: false,
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -1,834 +0,0 @@
import {
PDF,
type PDFPage,
type PdfDict,
type PdfObject,
type PdfRef,
type PdfStream,
type PdfString,
} from '@libpdf/core';
import { FieldType, type Recipient } from '@prisma/client';
import {
FIELD_CHECKBOX_META_DEFAULT_VALUES,
FIELD_DATE_META_DEFAULT_VALUES,
FIELD_DROPDOWN_META_DEFAULT_VALUES,
FIELD_EMAIL_META_DEFAULT_VALUES,
FIELD_INITIALS_META_DEFAULT_VALUES,
FIELD_NAME_META_DEFAULT_VALUES,
FIELD_NUMBER_META_DEFAULT_VALUES,
FIELD_RADIO_META_DEFAULT_VALUES,
FIELD_SIGNATURE_META_DEFAULT_VALUES,
FIELD_TEXT_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
type TDropdownFieldMeta,
type TFieldAndMeta,
type TNumberFieldMeta,
type TRadioFieldMeta,
type TTextFieldMeta,
ZEnvelopeFieldAndMetaSchema,
} from '../../types/field-meta';
import { logger } from '../../utils/logger';
import type { FieldToCreate } from './auto-place-fields';
/**
* Local shape for the widget annotations returned by @libpdf/core.
*
* The library exposes WidgetAnnotation as a class but does not re-export the
* type from its public surface. We duck-type the subset we actually read.
*/
type WidgetAnnotation = {
readonly rect: [number, number, number, number];
readonly width: number;
readonly height: number;
readonly pageRef: PdfRef | null;
isHidden(): boolean;
getOnValue(): string | null;
};
/**
* Function shape that follows a {@link PdfRef} to the referenced object.
*
* `@libpdf/core` does not re-export the `RefResolver` type alias, so we redeclare
* it locally. Built via {@link makeResolver} from a loaded {@link PDF}.
*/
type RefResolver = (ref: PdfRef) => PdfObject | null;
const makeResolver =
(pdfDoc: PDF): RefResolver =>
(ref: PdfRef) =>
pdfDoc.context.resolve(ref);
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
const MIN_HEIGHT_THRESHOLD = 0.01;
const DATE_NAME_PATTERN = /date|dob|birth/i;
const NUMBER_NAME_PATTERN = /amount|qty|count|number/i;
const EMAIL_NAME_PATTERN = /email|e[-_]?mail/i;
const NAME_NAME_PATTERN = /name/i;
const INITIALS_NAME_PATTERN = /initial/i;
const ROW_TOLERANCE_PERCENT = 2;
const ACROFORM_FIELD_SOURCE = 'acroform';
export type AcroFormUnsupportedReason =
| 'unsupported-type'
| 'hidden'
| 'off-page'
| 'zero-size'
| 'no-page-match'
| 'signed-signature'
| 'rotated-out-of-bounds';
export type AcroFormSkipReason = 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
export type AcroFormFieldImportInfo = {
source: 'acroform';
fieldName: string;
widgetIndex: number;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type AcroFormUnsupportedFieldInfo = {
fieldName: string;
acroFormType: string;
reason: AcroFormUnsupportedReason;
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/**
* True when a signed signature widget was found.
*
* Callers MUST set `flattenForm: false` for that envelope item so the signed
* PDF is not re-flattened (which would invalidate the signature).
*/
hasSignedSignature: boolean;
/**
* Set when extraction returned empty for a reason that should be surfaced in
* logs but not propagated to the user. Absent when extraction ran normally.
*/
skipReason?: AcroFormSkipReason;
};
type ResolvedGeometry = {
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
const EMPTY_RESULT = (skipReason?: AcroFormSkipReason): AcroFormExtractionResult => ({
fields: [],
unsupported: [],
hasSignedSignature: false,
skipReason,
});
/**
* Detect XFA-hybrid PDFs by inspecting the catalog's `/AcroForm` dict for an
* `/XFA` key.
*
* Uses public accessors (`pdf.context.catalog.getDict()`) and a {@link RefResolver}
* so an indirect `/AcroForm` entry is followed. Returns `false` on any error
* (e.g. malformed catalog) so the caller can fall through to normal extraction.
*/
const hasXfa = (pdfDoc: PDF, resolver: RefResolver): boolean => {
try {
const catalogDict = pdfDoc.context.catalog.getDict();
const acroFormDict = catalogDict.getDict('AcroForm', resolver);
return Boolean(acroFormDict?.has('XFA'));
} catch {
return false;
}
};
const isDateFieldByName = (name: string | null | undefined): boolean => !!name && DATE_NAME_PATTERN.test(name);
const isNumberFieldByName = (name: string | null | undefined): boolean => !!name && NUMBER_NAME_PATTERN.test(name);
const isEmailFieldByName = (name: string | null | undefined): boolean => !!name && EMAIL_NAME_PATTERN.test(name);
const isNameFieldByName = (name: string | null | undefined): boolean => !!name && NAME_NAME_PATTERN.test(name);
const isInitialsFieldByName = (name: string | null | undefined): boolean => !!name && INITIALS_NAME_PATTERN.test(name);
/**
* Detect AcroForm format actions on a text field dictionary.
*
* Adobe attaches a JavaScript format action via `/AA` → `/F` → `/JS`. The script
* body references `AFDate_FormatEx` or `AFNumber_Format` depending on the
* intended format. The action dict and its `/F` entry are frequently stored
* as indirect refs in real-world PDFs, so a {@link RefResolver} MUST be
* threaded through the lookups. We do a string-contains check on the script
* to avoid pulling in a JS parser.
*/
const getTextFieldFormatHint = (fieldDict: PdfDict, resolver: RefResolver): 'date' | 'number' | null => {
try {
const formatDict = fieldDict.getDict('AA', resolver)?.getDict('F', resolver);
if (!formatDict) {
return null;
}
const js = formatDict.get('JS', resolver);
if (!js || typeof js !== 'object') {
return null;
}
let script: string;
if (js.type === 'string') {
script = (js as PdfString).asString();
} else if (js.type === 'stream') {
script = new TextDecoder().decode((js as PdfStream).getDecodedData());
} else {
return null;
}
if (script.includes('AFDate_FormatEx') || script.includes('AFDate_Format')) {
return 'date';
}
if (script.includes('AFNumber_Format')) {
return 'number';
}
return null;
} catch {
return null;
}
};
type FormFieldWithDict = {
name: string;
partialName: string;
alternateName: string | null;
isReadOnly(): boolean;
isRequired(): boolean;
acroField(): PdfDict;
getWidgets(): WidgetAnnotation[];
};
type ResolvedTextDocumensoType =
| typeof FieldType.TEXT
| typeof FieldType.DATE
| typeof FieldType.NUMBER
| typeof FieldType.EMAIL
| typeof FieldType.NAME
| typeof FieldType.INITIALS;
const resolveTextSubtype = (
field: FormFieldWithDict,
resolver: RefResolver,
): {
documensoType: ResolvedTextDocumensoType;
} => {
const candidateNames = [field.partialName, field.alternateName];
const formatHint = getTextFieldFormatHint(field.acroField(), resolver);
// AcroForm format actions take precedence over name tokens — Adobe set them
// explicitly, so they're a stronger signal than a heuristic regex hit.
if (formatHint === 'date') {
return { documensoType: FieldType.DATE };
}
if (formatHint === 'number') {
return { documensoType: FieldType.NUMBER };
}
const maxLen = field.acroField().getNumber('MaxLen', resolver)?.value ?? Number.POSITIVE_INFINITY;
if (candidateNames.some(isDateFieldByName)) {
return { documensoType: FieldType.DATE };
}
if (maxLen <= 10 && candidateNames.some(isNumberFieldByName)) {
return { documensoType: FieldType.NUMBER };
}
if (candidateNames.some(isEmailFieldByName)) {
return { documensoType: FieldType.EMAIL };
}
if (candidateNames.some(isNameFieldByName)) {
return { documensoType: FieldType.NAME };
}
if (candidateNames.some(isInitialsFieldByName)) {
return { documensoType: FieldType.INITIALS };
}
return { documensoType: FieldType.TEXT };
};
const pickLabel = (field: FormFieldWithDict): string | undefined => {
// /TU is the human-facing tooltip/label; /T is the internal field identifier.
return field.alternateName || undefined;
};
type RotationDegrees = 0 | 90 | 180 | 270;
type RawRect = { x1: number; y1: number; x2: number; y2: number };
const getRectFromWidget = (widget: WidgetAnnotation): RawRect | null => {
const rect = widget.rect;
if (!rect || rect.length !== 4) {
return null;
}
const [x1, y1, x2, y2] = rect;
if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) {
return null;
}
return { x1, y1, x2, y2 };
};
const resolveGeometry = (
widget: WidgetAnnotation,
pageIndex: number,
page: PDFPage,
): { geometry: ResolvedGeometry | null; reason: AcroFormUnsupportedReason | null } => {
const rect = getRectFromWidget(widget);
if (!rect) {
return { geometry: null, reason: 'zero-size' };
}
const xL = Math.min(rect.x1, rect.x2);
const xR = Math.max(rect.x1, rect.x2);
const yB = Math.min(rect.y1, rect.y2);
const yT = Math.max(rect.y1, rect.y2);
if (xR - xL <= 0 || yT - yB <= 0) {
return { geometry: null, reason: 'zero-size' };
}
const mediaBox = page.getMediaBox();
const mediaW = mediaBox.width;
const mediaH = mediaBox.height;
const rotation = page.rotation as RotationDegrees;
// PDFPage.width / .height return rotation-adjusted dimensions, which is what
// we want for percent-based positioning relative to the rendered page.
const renderedW = page.width;
const renderedH = page.height;
let renderedX: number;
let renderedY: number;
let renderedFieldW: number;
let renderedFieldH: number;
if (rotation === 90) {
renderedX = yB;
renderedY = xL;
renderedFieldW = yT - yB;
renderedFieldH = xR - xL;
} else if (rotation === 180) {
renderedX = mediaW - xR;
renderedY = yB;
renderedFieldW = xR - xL;
renderedFieldH = yT - yB;
} else if (rotation === 270) {
renderedX = mediaH - yT;
renderedY = mediaW - xR;
renderedFieldW = yT - yB;
renderedFieldH = xR - xL;
} else {
renderedX = xL;
renderedY = mediaH - yT;
renderedFieldW = xR - xL;
renderedFieldH = yT - yB;
}
// Out-of-bounds: skip if the entire rect is outside the rendered page bounds.
const left = renderedX;
const right = renderedX + renderedFieldW;
const top = renderedY;
const bottom = renderedY + renderedFieldH;
if (right <= 0 || left >= renderedW || bottom <= 0 || top >= renderedH) {
return { geometry: null, reason: 'off-page' };
}
// Partial out-of-bounds: clamp.
const clampedLeft = Math.max(0, Math.min(left, renderedW));
const clampedRight = Math.max(0, Math.min(right, renderedW));
const clampedTop = Math.max(0, Math.min(top, renderedH));
const clampedBottom = Math.max(0, Math.min(bottom, renderedH));
const clampedW = clampedRight - clampedLeft;
const clampedH = clampedBottom - clampedTop;
if (clampedW <= 0 || clampedH <= 0) {
return { geometry: null, reason: 'off-page' };
}
return {
geometry: {
page: pageIndex + 1,
x: clampedLeft,
y: clampedTop,
width: clampedW,
height: clampedH,
pageWidth: renderedW,
pageHeight: renderedH,
},
reason: null,
};
};
const buildSignatureFieldAndMeta = (field: FormFieldWithDict): TFieldAndMeta => {
return ZEnvelopeFieldAndMetaSchema.parse({
type: FieldType.SIGNATURE,
fieldMeta: {
...FIELD_SIGNATURE_META_DEFAULT_VALUES,
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
},
});
};
const buildTextFieldAndMeta = (
field: FormFieldWithDict,
documensoType: ResolvedTextDocumensoType,
defaultText: string | undefined,
): TFieldAndMeta => {
const label = pickLabel(field);
const required = field.isRequired() || undefined;
const readOnly = field.isReadOnly() || undefined;
const defaultValue = defaultText && defaultText.length > 0 ? defaultText : undefined;
if (documensoType === FieldType.NUMBER) {
const fieldMeta: TNumberFieldMeta = {
...FIELD_NUMBER_META_DEFAULT_VALUES,
label: label ?? FIELD_NUMBER_META_DEFAULT_VALUES.label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
value: defaultValue,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
}
if (documensoType === FieldType.TEXT) {
const fieldMeta: TTextFieldMeta = {
...FIELD_TEXT_META_DEFAULT_VALUES,
label: label ?? FIELD_TEXT_META_DEFAULT_VALUES.label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
text: defaultValue ?? FIELD_TEXT_META_DEFAULT_VALUES.text,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
}
if (documensoType === FieldType.DATE) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_DATE_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
if (documensoType === FieldType.EMAIL) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_EMAIL_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
if (documensoType === FieldType.NAME) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_NAME_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_INITIALS_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
};
const buildCheckboxFieldAndMeta = (
field: FormFieldWithDict,
onValue: string | undefined,
isChecked: boolean,
): TFieldAndMeta => {
const required = field.isRequired();
const value = onValue && onValue.length > 0 ? onValue : 'Yes';
const fieldMeta: TCheckboxFieldMeta = {
...FIELD_CHECKBOX_META_DEFAULT_VALUES,
label: pickLabel(field) ?? FIELD_CHECKBOX_META_DEFAULT_VALUES.label,
required: required || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values: [{ id: 1, checked: isChecked, value }],
validationRule: required ? 'at-least' : '',
validationLength: required ? 1 : 0,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.CHECKBOX, fieldMeta });
};
const buildRadioFieldAndMeta = (
field: FormFieldWithDict,
options: string[],
selectedValue: string | null,
widgetOnValue: string | null,
): TFieldAndMeta => {
const values =
options.length > 0
? options.map((value, index) => ({
id: index + 1,
checked: selectedValue !== null && value === selectedValue,
value,
}))
: [
{
id: 1,
checked: widgetOnValue !== null && widgetOnValue === selectedValue,
value: widgetOnValue ?? '',
},
];
const fieldMeta: TRadioFieldMeta = {
...FIELD_RADIO_META_DEFAULT_VALUES,
label: pickLabel(field) ?? '',
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.RADIO, fieldMeta });
};
const buildDropdownFieldAndMeta = (
field: FormFieldWithDict,
options: string[],
defaultValue: string | undefined,
): TFieldAndMeta => {
const fieldMeta: TDropdownFieldMeta = {
...FIELD_DROPDOWN_META_DEFAULT_VALUES,
label: pickLabel(field) ?? '',
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values: options.length > 0 ? options.map((value) => ({ value })) : FIELD_DROPDOWN_META_DEFAULT_VALUES.values,
defaultValue: defaultValue ?? '',
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.DROPDOWN, fieldMeta });
};
type WidgetWithPage = { widget: WidgetAnnotation; pageIndex: number; page: PDFPage };
const resolveWidgetPages = (
widgets: WidgetAnnotation[],
pageByRef: Map<PdfRef, { index: number; page: PDFPage }>,
): {
matched: WidgetWithPage[];
unmatched: WidgetAnnotation[];
} => {
const matched: WidgetWithPage[] = [];
const unmatched: WidgetAnnotation[] = [];
for (const widget of widgets) {
const pageRef = widget.pageRef;
const resolved = pageRef ? pageByRef.get(pageRef) : null;
if (!resolved) {
unmatched.push(widget);
continue;
}
matched.push({ widget, pageIndex: resolved.index, page: resolved.page });
}
return { matched, unmatched };
};
export type ExtractAcroFormOptions = {
/**
* When true, `insertFormValuesInPdf` already ran for this buffer. The
* extractor will not copy AcroForm default values into `fieldMeta` to
* avoid duplicating values that are already baked into the flattened PDF.
*/
formValuesProvided?: boolean;
};
/**
* Extract AcroForm fields from a PDF and convert them to Documenso field
* imports.
*
* Runs before flattening so widget geometry is still present in the buffer.
* Returns an empty result for non-AcroForm PDFs, encrypted PDFs, pure XFA forms
* with no AcroForm widgets, and on any internal error (with `skipReason` set so
* callers can log).
*/
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
options: ExtractAcroFormOptions = {},
): Promise<AcroFormExtractionResult> => {
try {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
if (pdfDoc.isEncrypted) {
return EMPTY_RESULT('encrypted');
}
const resolver = makeResolver(pdfDoc);
const hasXfaForm = hasXfa(pdfDoc, resolver);
const form = pdfDoc.getForm();
if (!form) {
return EMPTY_RESULT(hasXfaForm ? 'xfa-hybrid' : 'no-form');
}
const formFields = form.getFields();
if (hasXfaForm && formFields.length === 0) {
return EMPTY_RESULT('xfa-hybrid');
}
const pages = pdfDoc.getPages();
const pageByRef = new Map<PdfRef, { index: number; page: PDFPage }>();
pages.forEach((page, index) => {
pageByRef.set(page.ref, { index, page });
});
const fields: AcroFormFieldImportInfo[] = [];
const unsupported: AcroFormUnsupportedFieldInfo[] = [];
let hasSignedSignature = false;
const usePdfDefaults = !options.formValuesProvided;
const addUnsupported = (fieldName: string, acroFormType: string, reason: AcroFormUnsupportedReason): void => {
unsupported.push({ fieldName, acroFormType, reason });
};
for (const field of formFields) {
const acroFormType = field.type;
if (
acroFormType === 'listbox' ||
acroFormType === 'button' ||
acroFormType === 'unknown' ||
acroFormType === 'non-terminal'
) {
addUnsupported(field.name, acroFormType, 'unsupported-type');
continue;
}
// Signed signature widgets are skipped entirely and the caller is asked
// to keep the form intact (no flatten) so the signature stays valid.
if (acroFormType === 'signature') {
type SignatureFieldDuck = FormFieldWithDict & { isSigned(): boolean };
const sigField = field as unknown as SignatureFieldDuck;
if (typeof sigField.isSigned === 'function' && sigField.isSigned()) {
hasSignedSignature = true;
addUnsupported(field.name, acroFormType, 'signed-signature');
continue;
}
}
const formField = field as unknown as FormFieldWithDict;
const widgets = formField.getWidgets();
const { matched, unmatched } = resolveWidgetPages(widgets, pageByRef);
for (let i = 0; i < unmatched.length; i += 1) {
addUnsupported(field.name, acroFormType, 'no-page-match');
}
let widgetCounter = 0;
for (const { widget, pageIndex, page } of matched) {
if (widget.isHidden()) {
addUnsupported(field.name, acroFormType, 'hidden');
continue;
}
const { geometry, reason } = resolveGeometry(widget, pageIndex, page);
if (!geometry) {
addUnsupported(field.name, acroFormType, reason ?? 'zero-size');
continue;
}
let fieldAndMeta: TFieldAndMeta;
if (acroFormType === 'signature') {
fieldAndMeta = buildSignatureFieldAndMeta(formField);
} else if (acroFormType === 'text') {
type TextFieldDuck = FormFieldWithDict & {
getValue(): string;
getDefaultValue(): string;
};
const textField = field as unknown as TextFieldDuck;
const { documensoType } = resolveTextSubtype(formField, resolver);
const defaultText = usePdfDefaults ? textField.getValue?.() || textField.getDefaultValue?.() || '' : '';
fieldAndMeta = buildTextFieldAndMeta(formField, documensoType, defaultText);
} else if (acroFormType === 'checkbox') {
type CheckboxFieldDuck = FormFieldWithDict & {
isChecked(): boolean;
getOnValue(): string;
};
const checkbox = field as unknown as CheckboxFieldDuck;
const onValue = widget.getOnValue() ?? checkbox.getOnValue?.();
const checked = usePdfDefaults ? (checkbox.isChecked?.() ?? false) : false;
fieldAndMeta = buildCheckboxFieldAndMeta(formField, onValue ?? undefined, checked);
} else if (acroFormType === 'radio') {
type RadioFieldDuck = FormFieldWithDict & {
getOptions(): string[];
getValue(): string | null;
};
const radio = field as unknown as RadioFieldDuck;
const selectedValue = usePdfDefaults ? (radio.getValue?.() ?? null) : null;
fieldAndMeta = buildRadioFieldAndMeta(
formField,
radio.getOptions?.() ?? [],
selectedValue,
widget.getOnValue(),
);
} else if (acroFormType === 'dropdown') {
type DropdownFieldDuck = FormFieldWithDict & {
getOptions(): Array<{ value: string; display: string }>;
getValue(): string;
getDefaultValue(): string;
};
const dropdown = field as unknown as DropdownFieldDuck;
const rawOptions = dropdown.getOptions?.() ?? [];
const optionValues = rawOptions.map((opt) => opt.value);
const currentSelection = usePdfDefaults ? dropdown.getValue?.() || dropdown.getDefaultValue?.() || '' : '';
fieldAndMeta = buildDropdownFieldAndMeta(formField, optionValues, currentSelection || undefined);
} else {
addUnsupported(field.name, acroFormType, 'unsupported-type');
continue;
}
fields.push({
source: ACROFORM_FIELD_SOURCE,
fieldName: field.name,
widgetIndex: widgetCounter,
fieldAndMeta,
page: geometry.page,
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
pageWidth: geometry.pageWidth,
pageHeight: geometry.pageHeight,
});
widgetCounter += 1;
}
}
return {
fields,
unsupported,
hasSignedSignature,
};
} catch (err) {
logger.error({ event: 'acroform-import.error', err }, 'AcroForm extraction threw');
return EMPTY_RESULT('error');
}
};
const sortFieldsForCreate = (fields: AcroFormFieldImportInfo[]): AcroFormFieldImportInfo[] => {
return [...fields].sort((a, b) => {
if (a.page !== b.page) {
return a.page - b.page;
}
const aRowPercent = (a.y / a.pageHeight) * 100;
const bRowPercent = (b.y / b.pageHeight) * 100;
if (Math.abs(aRowPercent - bRowPercent) > ROW_TOLERANCE_PERCENT) {
return aRowPercent - bRowPercent;
}
return a.x - b.x;
});
};
/**
* Convert pre-extracted AcroForm fields to field creation inputs.
*
* Pure data transform — converts points to percentages and resolves the
* recipient via the provided callback. No DB calls.
*/
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[] => {
return sortFieldsForCreate(fields).map((f) => {
const xPercent = (f.x / f.pageWidth) * 100;
const yPercent = (f.y / f.pageHeight) * 100;
const widthPercent = (f.width / f.pageWidth) * 100;
const heightPercent = (f.height / f.pageHeight) * 100;
const finalHeightPercent = heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
const recipient = recipientResolver(f.fieldName);
return {
...f.fieldAndMeta,
envelopeItemId,
recipientId: recipient.id,
page: f.page,
positionX: xPercent,
positionY: yPercent,
width: widthPercent,
height: finalHeightPercent,
};
});
};
@@ -1,4 +1,4 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
};
};
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
import { getQuotaWarningCount } from '../../universal/quota-usage';
export type QuotaAlertKind = 'quota' | 'quotaNearing';
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const warningCount = getQuotaWarningCount(quota);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
+84 -43
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Platzhalter hinzufügen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Rate-Limit hinzufügen"
msgid "Add rate limit window"
msgstr ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Keine Lizenz konfiguriert"
@@ -8153,6 +8169,10 @@ msgstr "Ausstehende Organisations­einladungen"
msgid "Pending since"
msgstr "Ausstehend seit"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: 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."
@@ -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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sitzung widerrufen"
@@ -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 ""
#: 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"
@@ -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 ""
#: 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 "Whitelabeling, unbegrenzte Mitglieder und mehr"
msgid "Width:"
msgstr "Breite:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Zustimmung widerrufen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Schreiben Sie eine Beschreibung, die in Ihrem öffentlichen Profil angezeigt wird"
+84 -43
View File
@@ -1411,8 +1411,8 @@ msgid "Add Placeholders"
msgstr "Add Placeholders"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Add rate limit"
msgid "Add rate limit window"
msgstr "Add rate limit window"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2003,6 +2003,7 @@ msgstr "Any Source"
msgid "Any Status"
msgstr "Any Status"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2012,10 +2013,6 @@ msgstr "API"
msgid "API key"
msgstr "API key"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "API rate limits"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "API requests"
@@ -2676,6 +2673,10 @@ msgstr "Cannot remove signer"
msgid "Cannot upload items after the document has been sent"
msgstr "Cannot upload items after the document has been sent"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr "Capabilities enabled for this organisation."
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4402,10 +4403,6 @@ msgstr "Document Preferences"
msgid "Document preferences updated"
msgstr "Document preferences updated"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Document rate limits"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4541,6 +4538,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
@@ -4990,10 +4988,6 @@ msgstr "Email Preferences"
msgid "Email preferences updated"
msgstr "Email preferences updated"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "Email rate limits"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Email recipients when a pending document is deleted"
@@ -5089,6 +5083,7 @@ msgstr "Email verification has been removed"
msgid "Email verification has been resent"
msgstr "Email verification has been resent"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,16 +5097,14 @@ msgstr "Emails"
msgid "Embedding, 5 members included and more"
msgstr "Embedding, 5 members included and more"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Empty = Unlimited, 0 = Blocked"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Empty field"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Enable"
@@ -5231,6 +5224,10 @@ msgstr "Ensure that you are using the embedding token, not the API token"
msgid "Enter"
msgstr "Enter"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr "Enter a max request count greater than 0"
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Enter a name for your new folder. Folders help you organise your items."
@@ -5239,6 +5236,10 @@ msgstr "Enter a name for your new folder. Folders help you organise your items."
msgid "Enter a new title"
msgstr "Enter a new title"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr "Enter a window, e.g. 5m"
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Enter claim name"
@@ -5497,6 +5498,10 @@ msgstr "Everyone has signed"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Everyone has signed! You will receive an email copy of the signed document."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr "Exceeded"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Exceeded timeout"
@@ -6760,6 +6765,10 @@ msgstr "Light Mode"
msgid "Like to have your own public profile with agreements?"
msgstr "Like to have your own public profile with agreements?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr "Limit reached"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limits"
@@ -7065,6 +7074,10 @@ msgstr "MAU (signed in)"
msgid "Max"
msgstr "Max"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr "Max requests"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
@@ -7111,12 +7124,12 @@ msgstr "Member Since"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7185,16 +7198,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Monthly Active Users: Users that had at least one of their documents completed"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly API quota"
msgstr "Monthly API quota"
msgid "Monthly quota"
msgstr "Monthly quota"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Monthly document quota"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Monthly email quota"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr "Monthly usage"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7285,7 +7294,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 is required"
@@ -7293,6 +7301,10 @@ msgstr "Name is required"
msgid "Name Settings"
msgstr "Name Settings"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr "Near limit"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Need to sign documents?"
@@ -7432,6 +7444,10 @@ msgstr "No further action is required from you at this time."
msgid "No groups found"
msgstr "No groups found"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr "No inherited claim"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "No License Configured"
@@ -8148,6 +8164,10 @@ msgstr "Pending Organisation Invites"
msgid "Pending since"
msgstr "Pending since"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr "People with access to this organisation."
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8310,10 +8330,6 @@ msgstr "Please enter a meaningful name for your token. This will help you identi
msgid "Please enter a number"
msgstr "Please enter a number"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Please enter a valid name."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Please enter a valid number"
@@ -9052,6 +9068,10 @@ msgstr "Remove organisation member"
msgid "Remove Organisation Member"
msgstr "Remove Organisation Member"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr "Remove rate limit"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Remove recipient"
@@ -9242,6 +9262,10 @@ msgstr "Resolve"
msgid "Resolve payment"
msgstr "Resolve payment"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr "Resource blocked"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Response"
@@ -9818,6 +9842,10 @@ msgstr "Sending..."
msgid "Sent"
msgstr "Sent"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr "Sent this period"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Session revoked"
@@ -10800,10 +10828,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
@@ -10814,6 +10842,10 @@ msgstr "Teams"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Teams help you organise your work and collaborate with others. Create your first team to get started."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr "Teams that belong to this organisation."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Teams that this organisation group is currently assigned to"
@@ -12292,8 +12324,6 @@ msgstr "Unknown name"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Unlimited"
@@ -12586,15 +12616,18 @@ msgstr "Uploading"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Usage for period: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Use"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr "Use a duration with a unit, e.g. 5m, 1h, or 24h"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr "Use a unique window for each rate limit"
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13496,10 +13529,18 @@ msgstr "Whitelabeling, unlimited members and more"
msgid "Width:"
msgstr "Width:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr "Window"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Withdrawing Consent"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr "Within limit"
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Write a description to display on your public profile"
+84 -43
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Agregar Marcadores de posición"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Agregar límite de velocidad"
msgid "Add rate limit window"
msgstr ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Licencia no configurada"
@@ -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 ""
#: 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"
@@ -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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sesión revocada"
@@ -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 ""
#: 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"
@@ -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 ""
#: 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 "Etiqueta blanca, miembros ilimitados y más"
msgid "Width:"
msgstr "Ancho:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Retirar Consentimiento"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Escribe una descripción para mostrar en tu perfil público"
+84 -43
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Ajouter des espaces réservés"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Ajouter une limite de fréquence"
msgid "Add rate limit window"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ msgstr "Toute source"
msgid "Any Status"
msgstr "Tout statut"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2017,10 +2018,6 @@ msgstr "API"
msgid "API key"
msgstr "Clé dAPI"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "Limites de fréquence de lAPI"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "Requêtes API"
@@ -2681,6 +2678,10 @@ msgstr "Impossible de supprimer le signataire"
msgid "Cannot upload items after the document has been sent"
msgstr "Impossible de télécharger des éléments après l'envoi du document"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4407,10 +4408,6 @@ msgstr "Préférences de document"
msgid "Document preferences updated"
msgstr "Préférences de document mises à jour"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Limites de fréquence des documents"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4546,6 +4543,7 @@ msgstr "Documentation"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4995,10 +4993,6 @@ msgstr "Préférences de messagerie"
msgid "Email preferences updated"
msgstr "Préférences de messagerie mises à jour"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "Limites de fréquence des e-mails"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Envoyer un e-mail aux destinataires lorsquun document en attente est supprimé"
@@ -5094,6 +5088,7 @@ msgstr "La vérification par email a été supprimée"
msgid "Email verification has been resent"
msgstr "La vérification par email a été renvoyée"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5107,16 +5102,14 @@ msgstr "E-mails"
msgid "Embedding, 5 members included and more"
msgstr "Intégration, 5 membres inclus et plus"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Vide = Illimité, 0 = Bloqué"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Champ vide"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Activer"
@@ -5236,6 +5229,10 @@ msgstr "Assurez-vous dutiliser le jeton dintégration, et non le jeton d
msgid "Enter"
msgstr "Entrer"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Taille maximale du fichier : 4 Mo. Maximum de 100 lignes par importation. Les valeurs vides utiliseront les valeurs par défaut du modèle."
@@ -7116,12 +7129,12 @@ msgstr "Membre depuis"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7190,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Utilisateurs actifs mensuels : utilisateurs ayant terminé au moins un de leurs documents"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly API quota"
msgstr "Quota dAPI mensuel"
msgid "Monthly quota"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Quota de documents mensuel"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Quota de-mails mensuel"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: 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 ""
#: 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 ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Aucune licence configurée"
@@ -8153,6 +8169,10 @@ msgstr "Invitations à lorganisation en attente"
msgid "Pending since"
msgstr "En attente depuis"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: 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"
@@ -9057,6 +9073,10 @@ msgstr "Supprimer le membre de l'organisation"
msgid "Remove Organisation Member"
msgstr "Supprimer un membre de lorganisation"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: 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 ""
#: 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 ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Session révoquée"
@@ -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 ""
#: 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é"
@@ -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 ""
#: 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 "Marque blanche, membres illimités et plus"
msgid "Width:"
msgstr "Largeur :"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Retrait du consentement"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Écrivez une description à afficher sur votre profil public"
+84 -43
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Aggiungi segnaposto"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Aggiungi limite di velocità"
msgid "Add rate limit window"
msgstr ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Nessuna licenza configurata"
@@ -8153,6 +8169,10 @@ msgstr "Inviti allorganizzazione in sospeso"
msgid "Pending since"
msgstr "In sospeso dal"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: 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"
@@ -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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sessione revocata"
@@ -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 ""
#: 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"
@@ -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 ""
#: 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 "White label, membri illimitati e altro"
msgid "Width:"
msgstr "Larghezza:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Ritiro del consenso"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Scrivi una descrizione da mostrare sul tuo profilo pubblico"
+84 -43
View File
@@ -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 ""
#: 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 ""
#: 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 ""
#: 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 "ライセンスが設定されていません"
@@ -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 "有効な数値を入力してください"
@@ -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 "セッションを取り消しました"
@@ -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 "無制限"
@@ -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 ""
#: 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 "公開プロフィールに表示する説明文を入力してください"
+84 -43
View File
@@ -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 ""
#: 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 ""
#: 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 ""
#: 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 "라이선스가 구성되지 않았습니다"
@@ -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 "올바른 숫자를 입력하세요"
@@ -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 "세션이 해지되었습니다."
@@ -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 "무제한"
@@ -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 ""
#: 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 "공개 프로필에 표시될 설명을 작성하세요."
+84 -43
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Placeholders toevoegen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Snelheidslimiet toevoegen"
msgid "Add rate limit window"
msgstr ""
#: 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 ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4407,10 +4408,6 @@ msgstr "Documentvoorkeuren"
msgid "Document preferences updated"
msgstr "Documentvoorkeuren bijgewerkt"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Documentsnelheidslimieten"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4546,6 +4543,7 @@ msgstr "Documentatie"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4995,10 +4993,6 @@ msgstr "E-mailvoorkeuren"
msgid "Email preferences updated"
msgstr "E-mailvoorkeuren bijgewerkt"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "E-mailsnelheidslimieten"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "E-mail ontvangers wanneer een in behandeling zijnd document wordt verwijderd"
@@ -5094,6 +5088,7 @@ msgstr "Emailverificatie is verwijderd"
msgid "Email verification has been resent"
msgstr "Emailverificatie is opnieuw verzonden"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5107,16 +5102,14 @@ msgstr "E-mails"
msgid "Embedding, 5 members included and more"
msgstr "Inbedding, 5 leden inbegrepen en meer"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Leeg = Onbeperkt, 0 = Geblokkeerd"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Leeg veld"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: 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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Timeout overschreden"
@@ -6765,6 +6770,10 @@ msgstr "Lichte modus"
msgid "Like to have your own public profile with agreements?"
msgstr "Wil je een eigen openbaar profiel met overeenkomsten?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Geen licentie geconfigureerd"
@@ -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 ""
#: 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"
@@ -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 ""
#: 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 ""
#: 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 ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sessie ingetrokken"
@@ -10805,10 +10833,10 @@ msgid "Team URL"
msgstr "TeamURL"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10819,6 +10847,10 @@ msgstr "Teams"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Teams helpen je je werk te organiseren en samen te werken met anderen. Maak je eerste team aan om te beginnen."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: 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"
@@ -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 ""
#: 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 "Whitelabeling, onbeperkte leden en meer"
msgid "Width:"
msgstr "Breedte:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Toestemming intrekken"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Schrijf een beschrijving die op je openbare profiel wordt weergegeven"
+84 -43
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Dodaj domyślny tekst"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgstr "Dodaj limit przepustowości"
msgid "Add rate limit window"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,6 +2008,7 @@ msgstr "Dowolne źródło"
msgid "Any Status"
msgstr "Dowolny status"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2017,10 +2018,6 @@ msgstr "API"
msgid "API key"
msgstr "Klucz API"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "Limit przepustowości API"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "Żądania API"
@@ -2681,6 +2678,10 @@ msgstr "Nie można usunąć podpisującego"
msgid "Cannot upload items after the document has been sent"
msgstr "Nie możesz przesłać elementów po wysłaniu dokumentu"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4407,10 +4408,6 @@ msgstr "Ustawienia dokumentu"
msgid "Document preferences updated"
msgstr "Ustawienia dokumentu zostały zaktualizowane"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Limit przepustowości dokumentów"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4546,6 +4543,7 @@ msgstr "Dokumentacja"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4995,10 +4993,6 @@ msgstr "Ustawienia adresu e-mail"
msgid "Email preferences updated"
msgstr "Ustawienia adresu e-mail zostały zaktualizowane"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "Limit przepustowości wiadomości"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Wyślij odbiorcom wiadomość, gdy oczekujący dokument zostanie usunięty"
@@ -5094,6 +5088,7 @@ msgstr "Weryfikacja adresu e-mail została usunięta"
msgid "Email verification has been resent"
msgstr "Weryfikacja adresu e-mail została ponownie wysłana"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5107,16 +5102,14 @@ msgstr "Adresy e-mail"
msgid "Embedding, 5 members included and more"
msgstr "Osadzanie dokumentów, 5 użytkowników i więcej"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Puste = bez ograniczeń, 0 = zablokowane"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Puste pole"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Włącz"
@@ -5236,6 +5229,10 @@ msgstr "Upewnij się, że używasz tokena osadzania, a nie tokenu API."
msgid "Enter"
msgstr "Wpisz"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Wpisz nazwę nowego folderu. Foldery pomagają uporządkować elementy."
@@ -5244,6 +5241,10 @@ msgstr "Wpisz nazwę nowego folderu. Foldery pomagają uporządkować elementy."
msgid "Enter a new title"
msgstr "Wpisz nowy tytuł"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Wpisz nazwę"
@@ -5502,6 +5503,10 @@ msgstr "Wszyscy podpisali"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Wszyscy podpisali! Otrzymasz wiadomość z podpisanym dokumentem."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Przekroczono limit czasu"
@@ -6765,6 +6770,10 @@ msgstr "Tryb jasny"
msgid "Like to have your own public profile with agreements?"
msgstr "Czy chcesz mieć własny profil publiczny z umowami?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limity"
@@ -7070,6 +7079,10 @@ msgstr "MAU (zalogowani)"
msgid "Max"
msgstr "Maks."
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Maksymalny rozmiar pliku to 4 MB. Możesz przesłać maksymalnie 100 wierszy na raz. Puste wartości zostaną zastąpione domyślnymi z szablonu."
@@ -7116,12 +7129,12 @@ msgstr "Data dołączenia"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7190,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Miesięczna liczba aktywnych użytkowników: Użytkownicy, którzy zakończyli co najmniej jeden dokument"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly API quota"
msgstr "Miesięczny limit API"
msgid "Monthly quota"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Miesięczny limit dokumentów"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Miesięczny limit wiadomości"
#: apps/remix/app/components/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 "Nazwa"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Nazwa jest wymagana"
@@ -7298,6 +7306,10 @@ msgstr "Nazwa jest wymagana"
msgid "Name Settings"
msgstr "Ustawienia nazwy"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Potrzebujesz podpisywać dokumenty?"
@@ -7437,6 +7449,10 @@ msgstr "Nie musisz nic więcej robić."
msgid "No groups found"
msgstr "Nie znaleziono grup"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Brak skonfigurowanej licencji"
@@ -8153,6 +8169,10 @@ msgstr "Oczekujące zaproszenia do organizacji"
msgid "Pending since"
msgstr "Oczekuje od"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8315,10 +8335,6 @@ msgstr "Wpisz nazwę tokena. Pomoże to później w jego identyfikacji."
msgid "Please enter a number"
msgstr "Wpisz liczbę"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Wpisz prawidłową nazwę."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Wpisz prawidłową liczbę"
@@ -9057,6 +9073,10 @@ msgstr "Usuń użytkownika organizacji"
msgid "Remove Organisation Member"
msgstr "Usuń użytkownika organizacji"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Usuń odbiorcę"
@@ -9247,6 +9267,10 @@ msgstr "Rozwiąż"
msgid "Resolve payment"
msgstr "Rozwiąż płatność"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Odpowiedź"
@@ -9823,6 +9847,10 @@ msgstr "Wysyłanie..."
msgid "Sent"
msgstr "Wysłano"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sesja została unieważniona"
@@ -10805,10 +10833,10 @@ msgid "Team URL"
msgstr "Adres URL zespołu"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10819,6 +10847,10 @@ msgstr "Zespoły"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Zespoły pomagają organizować pracę i współpracować z innymi. Utwórz swój pierwszy zespół, aby rozpocząć."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Zespoły, do których przypisana jest grupa organizacji"
@@ -12297,8 +12329,6 @@ msgstr "Nieznana nazwa"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Bez ograniczeń"
@@ -12591,15 +12621,18 @@ msgstr "Przesyłanie"
msgid "URL"
msgstr "Adres URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Okres: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Użyj"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13501,10 +13534,18 @@ msgstr "Własny branding, nieograniczona liczba użytkowników i więcej"
msgid "Width:"
msgstr "Szerokość:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Wycofanie zgody"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Wpisz opis, który będzie wyświetlany w profilu publicznym"
+81 -40
View File
@@ -1411,7 +1411,7 @@ msgid "Add Placeholders"
msgstr "Adicionar Espaços Reservados"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit"
msgid "Add rate limit window"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
@@ -2003,6 +2003,7 @@ msgstr "Qualquer Origem"
msgid "Any Status"
msgstr "Qualquer Status"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr ""
@@ -2012,10 +2013,6 @@ msgstr ""
msgid "API key"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr ""
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr ""
@@ -2676,6 +2673,10 @@ msgstr "Não é possível remover o signatário"
msgid "Cannot upload items after the document has been sent"
msgstr "Não é possível fazer upload de itens após o envio do documento"
#: 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"
@@ -4402,10 +4403,6 @@ msgstr "Preferências de Documento"
msgid "Document preferences updated"
msgstr "Preferências de documento atualizadas"
#: 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"
@@ -4541,6 +4538,7 @@ msgstr "Documentação"
#: 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
@@ -4990,10 +4988,6 @@ msgstr "Preferências de E-mail"
msgid "Email preferences updated"
msgstr "Preferências de e-mail atualizadas"
#: 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 ""
@@ -5089,6 +5083,7 @@ msgstr "A verificação de e-mail foi removida"
msgid "Email verification has been resent"
msgstr "A verificação de e-mail foi reenviada"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,16 +5097,14 @@ msgstr "E-mails"
msgid "Embedding, 5 members included and more"
msgstr "Incorporação, 5 membros incluídos e mais"
#: 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 ""
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Campo vazio"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Ativar"
@@ -5231,6 +5224,10 @@ msgstr ""
msgid "Enter"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Digite um nome para sua nova pasta. As pastas ajudam você a organizar seus itens."
@@ -5239,6 +5236,10 @@ msgstr "Digite um nome para sua nova pasta. As pastas ajudam você a organizar s
msgid "Enter a new title"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Digite o nome da reivindicação"
@@ -5497,6 +5498,10 @@ msgstr "Todos assinaram"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Todos assinaram! Você receberá uma cópia do documento assinado por e-mail."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Tempo limite excedido"
@@ -6760,6 +6765,10 @@ msgstr "Modo Claro"
msgid "Like to have your own public profile with agreements?"
msgstr "Gostaria de ter seu próprio perfil público com contratos?"
#: 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 ""
@@ -7065,6 +7074,10 @@ msgstr "MAU (entrou)"
msgid "Max"
msgstr "Máx"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Tamanho máximo do arquivo: 4MB. Máximo de 100 linhas por upload. Valores em branco usarão os padrões do modelo."
@@ -7111,12 +7124,12 @@ msgstr "Membro 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
@@ -7185,15 +7198,11 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Usuários Ativos Mensais: Usuários que tiveram pelo menos um de seus documentos concluídos"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly API quota"
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"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
@@ -7285,7 +7294,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 "O nome é obrigatório"
@@ -7293,6 +7301,10 @@ msgstr "O nome é obrigatório"
msgid "Name Settings"
msgstr "Configurações de Nome"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Precisa assinar documentos?"
@@ -7432,6 +7444,10 @@ msgstr "Nenhuma ação adicional é necessária de sua parte neste momento."
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 ""
@@ -8148,6 +8164,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"
@@ -8310,10 +8330,6 @@ msgstr "Por favor, insira um nome significativo para seu token. Isso ajudará vo
msgid "Please enter a number"
msgstr ""
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Por favor, insira um nome válido."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Por favor, insira um número válido"
@@ -9052,6 +9068,10 @@ msgstr "Remover membro da organização"
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 "Remover destinatário"
@@ -9242,6 +9262,10 @@ msgstr "Resolver"
msgid "Resolve payment"
msgstr "Resolver pagamento"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Resposta"
@@ -9818,6 +9842,10 @@ msgstr "Enviando..."
msgid "Sent"
msgstr "Enviado"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sessão revogada"
@@ -10800,10 +10828,10 @@ msgid "Team URL"
msgstr "URL da Equipe"
#: 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
@@ -10814,6 +10842,10 @@ msgstr "Equipes"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Equipes ajudam você a organizar seu trabalho e colaborar com outros. Crie sua primeira equipe para começar."
#: 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 "Equipes às quais este grupo da organização está atualmente atribuído"
@@ -12292,8 +12324,6 @@ msgstr "Nome desconhecido"
#: 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"
@@ -12586,15 +12616,18 @@ msgstr "Enviando"
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 ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Usar"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13496,10 +13529,18 @@ msgstr "Whitelabeling, membros ilimitados e mais"
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 "Retirada de Consentimento"
#: 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 "Escreva uma descrição para exibir em seu perfil público"
+84 -43
View File
@@ -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 ""
#: 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 ""
#: 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 ""
#: 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 "未配置许可证"
@@ -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 "请输入有效的数字"
@@ -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 "会话已撤销"
@@ -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 "不限"
@@ -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 ""
#: 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 "撰写将在您的公共主页上展示的简介"
-1
View File
@@ -72,7 +72,6 @@ export const ZBaseFieldMeta = z.object({
readOnly: z.boolean().optional(),
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
overflow: ZFieldOverflowMode.optional(),
source: z.enum(['acroform']).optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { ZNameSchema } from './name';
describe('ZNameSchema', () => {
describe('valid names', () => {
it('accepts a normal name', () => {
expect(ZNameSchema.safeParse('Example User')).toEqual({
success: true,
data: 'Example User',
});
});
it('accepts international characters', () => {
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
success: true,
data: 'Døcumensø Üser',
});
});
it('trims surrounding whitespace', () => {
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
success: true,
data: 'Documenso User',
});
});
it('accepts names at the minimum length', () => {
expect(ZNameSchema.safeParse('DU')).toEqual({
success: true,
data: 'DU',
});
});
it('accepts names at the maximum length', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
expect(name.length).toBe(100);
expect(ZNameSchema.safeParse(name)).toEqual({
success: true,
data: name,
});
});
});
describe('length validation', () => {
it('rejects names shorter than 2 characters', () => {
expect(ZNameSchema.safeParse('D')).toMatchObject({
success: false,
error: {
issues: [{ message: 'Please enter a valid name.' }],
},
});
});
it('rejects names longer than 100 characters', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
expect(name.length).toBe(101);
expect(ZNameSchema.safeParse(name)).toMatchObject({
success: false,
error: {
issues: [{ message: 'Name cannot be more than 100 characters.' }],
},
});
});
it('rejects whitespace-only input after trim', () => {
expect(ZNameSchema.safeParse(' ')).toMatchObject({
success: false,
});
});
});
describe('URL validation', () => {
it.each([
'https://example.com',
'http://example.com',
'HTTPS://EXAMPLE.COM',
'Northwind www.example.com',
'www.example.com',
])('rejects URLs in names: %s', (value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
},
});
});
});
describe('invalid character validation', () => {
it.each([
['NUL character', 'Acme\u0000Corp'],
['zero-width space', 'Acme\u200bCorp'],
['bidi override', 'Acme\u202eCorp'],
['byte order mark', 'Acme\ufeffCorp'],
['lone surrogate', 'Acme\ud800Corp'],
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
['noncharacter', 'Acme\ufffeCorp'],
['private use character', 'Acme\ue000Corp'],
['Hangul filler', 'Acme\u3164Corp'],
['braille blank', 'Acme\u2800Corp'],
['combining grapheme joiner', 'Acme\u034fCorp'],
])('rejects names containing a %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['fixed form', String.raw`Acme\u200bCorp`],
['uppercase U', String.raw`Acme\U200BCorp`],
['braced form', String.raw`Acme\u{200b}Corp`],
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
['lone surrogate', String.raw`Acme\ud800Corp`],
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['escape of a valid code point', String.raw`Acme\u0041Corp`],
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
['astral characters such as emoji', 'Acme 😀 Corp'],
['emoji with a variation selector', 'I ❤️ Docs'],
])('accepts %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: true,
});
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Characters that render as empty/invisible or break text layout:
*
* - `\p{C}` - control, format, lone surrogate, private use and
* unassigned code points (NUL, zero-width spaces, bidi
* overrides, BOM, tag characters, noncharacters).
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
* character class because it is a combining mark, which
* lint rules reject inside classes.
* - remaining - letters that render as blank (Hangul fillers, braille blank).
*
* The `\p{...}` classes are maintained by the Unicode database, so newly
* assigned characters in these categories are covered automatically.
*/
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
/**
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
* break rendering downstream if anything decodes them.
*/
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
const hasInvalidEscapeSequence = (value: string) => {
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
if (codePoint > 0x10ffff) {
continue;
}
// Decode the escape and run it through the same character policy as the
// unescaped check, so the two can never drift apart.
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
return true;
}
}
return false;
};
export const hasInvalidTextCharacters = (value: string) =>
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering,
* and invisible/control characters that render as empty or break the UI.
*/
export const ZNameSchema = z
.string()
.trim()
.min(2, { message: 'Please enter a valid name.' })
.max(100, { message: 'Name cannot be more than 100 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
})
.refine((value) => !hasInvalidTextCharacters(value), {
message: 'Name contains invalid characters.',
});
export type TName = z.infer<typeof ZNameSchema>;
+32 -7
View File
@@ -6,14 +6,39 @@ import { z } from 'zod';
*
* Example: "5m", "1h", "1d"
*/
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
export const ZRateLimitArraySchema = z.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
);
const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
message: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
});
export const ZRateLimitArraySchema = z
.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
)
.superRefine((entries, ctx) => {
const windows = new Set<string>();
entries.forEach((entry, index) => {
const window = entry.window.trim();
if (windows.has(window)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
path: [index, 'window'],
});
}
windows.add(window);
});
});
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
getQuotaUsagePercent,
getQuotaWarningCount,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from './quota-usage';
describe('isQuotaExceeded', () => {
it('treats null quota as unlimited (never exceeded)', () => {
expect(isQuotaExceeded(null, 0)).toBe(false);
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
});
it('treats a zero quota as blocked (always exceeded)', () => {
expect(isQuotaExceeded(0, 0)).toBe(true);
expect(isQuotaExceeded(0, 5)).toBe(true);
});
it('is exceeded once usage reaches the quota (>= boundary)', () => {
expect(isQuotaExceeded(10, 9)).toBe(false);
expect(isQuotaExceeded(10, 10)).toBe(true);
expect(isQuotaExceeded(10, 11)).toBe(true);
});
});
describe('getQuotaWarningCount', () => {
it('rounds the 80% threshold up', () => {
expect(getQuotaWarningCount(10)).toBe(8);
expect(getQuotaWarningCount(100)).toBe(80);
// 5 * 0.8 = 4 exactly.
expect(getQuotaWarningCount(5)).toBe(4);
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
expect(getQuotaWarningCount(3)).toBe(3);
});
});
describe('isQuotaNearing', () => {
it('is never nearing for unlimited or blocked quotas', () => {
expect(isQuotaNearing(null, 5)).toBe(false);
expect(isQuotaNearing(0, 5)).toBe(false);
});
it('is nearing from the warning threshold up to (but not including) the quota', () => {
expect(isQuotaNearing(10, 7)).toBe(false);
expect(isQuotaNearing(10, 8)).toBe(true);
expect(isQuotaNearing(10, 9)).toBe(true);
});
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
expect(isQuotaNearing(10, 10)).toBe(false);
expect(isQuotaNearing(10, 11)).toBe(false);
});
it('can never fire for tiny quotas where the warning count equals the quota', () => {
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
expect(isQuotaNearing(3, 2)).toBe(false);
expect(isQuotaNearing(3, 3)).toBe(false);
});
it('agrees with the warning-count helper at the boundary', () => {
const quota = 250;
const warningCount = getQuotaWarningCount(quota);
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
expect(isQuotaNearing(quota, warningCount)).toBe(true);
});
});
describe('getQuotaUsagePercent', () => {
it('returns 0 for unlimited or non-positive quotas', () => {
expect(getQuotaUsagePercent(5, null)).toBe(0);
expect(getQuotaUsagePercent(5, 0)).toBe(0);
expect(getQuotaUsagePercent(5, -10)).toBe(0);
});
it('rounds the percentage to the nearest integer', () => {
expect(getQuotaUsagePercent(1, 3)).toBe(33);
expect(getQuotaUsagePercent(2, 3)).toBe(67);
expect(getQuotaUsagePercent(50, 100)).toBe(50);
});
it('clamps the percentage to 100 when usage exceeds the quota', () => {
expect(getQuotaUsagePercent(150, 100)).toBe(100);
});
});
describe('normalizeCapacityLimit', () => {
it('maps 0 (unlimited for capacity limits) to null', () => {
expect(normalizeCapacityLimit(0)).toBeNull();
});
it('passes positive limits through unchanged', () => {
expect(normalizeCapacityLimit(1)).toBe(1);
expect(normalizeCapacityLimit(25)).toBe(25);
});
});
+57
View File
@@ -0,0 +1,57 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
/**
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
*/
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* The usage count at which a positive quota starts "nearing" (80% rounded up).
* The single source for the warning threshold math so the UI panel, quota flags,
* and the per-request alert path can't drift apart.
*/
export const getQuotaWarningCount = (quota: number): number => {
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
/**
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
*/
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= getQuotaWarningCount(quota);
};
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
if (quota === null || quota <= 0) {
return 0;
}
return Math.min(100, Math.round((usage / quota) * 100));
};
/** Member/team capacity limits use `0` for unlimited. */
export const normalizeCapacityLimit = (limit: number): number | null => {
if (limit === 0) {
return null;
}
return limit;
};
+1 -71
View File
@@ -1,10 +1,5 @@
import { env } from '@documenso/lib/utils/env';
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error';
type File = {
@@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
return result;
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File, options?: PutFileOptions) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}, options))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
.otherwise(async () => putFileInDatabase(file));
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildUploadAuthHeaders(options),
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});
if (!getPresignedUrlResponse.ok) {
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
}
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
const body = await file.arrayBuffer();
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};
+12
View File
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
content,
};
};
/**
* Validate and sanitise an uploaded branding logo. Re-encoding through `sharp`
* proves the bytes are a real raster image and strips any embedded payloads.
* Throws if the input cannot be parsed as an image.
*/
export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise<Buffer> => {
return await sharp(input)
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
.png({ quality: 80 })
.toBuffer();
};
@@ -1,11 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateAdminOrganisationRequestSchema = z.object({
ownerUserId: z.number(),
data: z.object({
name: ZOrganisationNameSchema,
name: ZNameSchema,
}),
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
import { z } from 'zod';
export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: z.string().min(1),
name: ZNameSchema,
teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0),
envelopeItemCount: z.number().int().min(1),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateUserRequestSchema = z.object({
@@ -1,9 +1,10 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
name: ZNameSchema,
fromName: ZNameSchema,
fromAddress: z.string().email(),
config: ZEmailTransportConfigSchema,
});
@@ -4,6 +4,7 @@ import {
ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
// Reuses the canonical transport config schemas, but relaxes the secret field so
@@ -21,8 +22,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [
export const ZUpdateEmailTransportRequestSchema = z.object({
id: z.string(),
data: z.object({
name: z.string().min(1),
fromName: z.string().min(1),
name: ZNameSchema,
fromName: ZNameSchema,
fromAddress: z.string().email(),
config: ZUpdateConfigSchema,
}),
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
name: true,
url: true,
ownerUserId: true,
organisationGlobalSettings: true,
},
},
teamEmail: true,
@@ -1,5 +1,6 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true,
url: true,
ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}),
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
@@ -1,13 +1,13 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateAdminOrganisationRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
name: ZOrganisationNameSchema.optional(),
name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(),
claims: ZCreateSubscriptionClaimRequestSchema.pick({
teamCount: true,
@@ -1,10 +1,11 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { Role } from '@prisma/client';
import { z } from 'zod';
export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
name: ZNameSchema.nullish(),
email: zEmail().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateApiTokenRequestSchema = z.object({
teamId: z.number(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
tokenName: ZNameSchema,
expirationDate: z.string().nullable(),
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { z } from 'zod';
export const ZCreatePasskeyRequestSchema = z.object({
passkeyName: z.string().trim().min(1),
passkeyName: ZNameSchema,
verificationResponse: ZRegistrationResponseJSONSchema,
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZUpdatePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1),
name: ZNameSchema,
});
export const ZUpdatePasskeyResponseSchema = z.void();
@@ -1,9 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(),
emailName: z.string().min(1).max(100),
emailName: ZNameSchema,
email: zEmail().toLowerCase(),
// This does not need to be validated to be part of the domain.
@@ -6,6 +6,7 @@ import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-plac
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { EnvelopeType } from '@prisma/client';
import type { Logger } from 'pino';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
@@ -119,7 +120,7 @@ export const createEnvelopeRouteCaller = async ({
}
const normalized = await normalizePdf(pdf, {
flattenForm: false,
flattenForm: type !== EnvelopeType.TEMPLATE,
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
@@ -1,68 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
ZImportAcroFormFieldsRequestSchema,
ZImportAcroFormFieldsResponseSchema,
} from './import-acroform-fields.types';
export const importAcroFormFieldsRoute = authenticatedProcedure
.input(ZImportAcroFormFieldsRequestSchema)
.output(ZImportAcroFormFieldsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { envelopeId } = input;
ctx.logger.info({ input: { envelopeId } });
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: true,
envelopeItems: {
include: { documentData: true },
orderBy: { order: 'asc' },
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only supported for version 2 envelopes',
});
}
if (envelope.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only allowed while the envelope is in draft',
});
}
if (envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope has no items to import from',
});
}
return UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: metadata,
});
});
@@ -1,24 +0,0 @@
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
import { z } from 'zod';
export const ZImportAcroFormFieldsRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZImportAcroFormFieldsResponseSchema = z.object({
itemsProcessed: z.number().int().min(0),
fieldsCreated: z.number().int().min(0),
unsupportedCount: z.number().int().min(0),
signedSignatureCount: z.number().int().min(0),
skippedItems: z.array(
z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
reason: z.enum(['encrypted', 'xfa-hybrid', 'no-form', 'error']),
}),
),
fields: z.array(ZEnvelopeFieldSchema),
});
export type TImportAcroFormFieldsRequest = z.infer<typeof ZImportAcroFormFieldsRequestSchema>;
export type TImportAcroFormFieldsResponse = z.infer<typeof ZImportAcroFormFieldsResponseSchema>;
@@ -34,7 +34,6 @@ import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { importAcroFormFieldsRoute } from './import-acroform-fields';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
import { saveAsTemplateRoute } from './save-as-template';
@@ -84,7 +83,6 @@ export const envelopeRouter = router({
delete: deleteEnvelopeFieldRoute,
set: setEnvelopeFieldsRoute,
sign: signEnvelopeFieldRoute,
importFromPdf: importAcroFormFieldsRoute,
},
find: findEnvelopesRoute,
auditLog: {
+3 -2
View File
@@ -1,4 +1,5 @@
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
@@ -42,7 +43,7 @@ const ZFolderParentIdSchema = z
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
export const ZCreateFolderRequestSchema = z.object({
name: z.string(),
name: ZNameSchema,
parentId: ZFolderParentIdSchema.optional(),
type: ZFolderTypeSchema.optional(),
});
@@ -52,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'),
data: z.object({
name: z.string().optional().describe('The name of the folder'),
name: ZNameSchema.optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZCreateOrganisationGroupRequestSchema = z.object({
organisationId: z.string(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
name: z.string().max(100),
name: ZNameSchema,
memberIds: z.array(z.string()),
});
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
// export const createOrganisationMeta: TrpcOpenApiMeta = {
@@ -10,13 +11,8 @@ import { z } from 'zod';
// },
// };
export const ZOrganisationNameSchema = z
.string()
.min(3, { message: 'Minimum 3 characters' })
.max(50, { message: 'Maximum 50 characters' });
export const ZCreateOrganisationRequestSchema = z.object({
name: ZOrganisationNameSchema,
name: ZNameSchema,
priceId: z.string().optional(),
});
@@ -20,6 +20,7 @@ import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation';
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
import { updateOrganisationRoute } from './update-organisation';
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
import { updateOrganisationGroupRoute } from './update-organisation-group';
import { updateOrganisationMemberRoute } from './update-organisation-members';
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
@@ -55,6 +56,7 @@ export const organisationRouter = router({
},
settings: {
update: updateOrganisationSettingsRoute,
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
},
internal: {
getOrganisationSession: getOrganisationSessionRoute,
@@ -0,0 +1,69 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationBrandingLogoRequestSchema,
ZUpdateOrganisationBrandingLogoResponseSchema,
} from './update-organisation-branding-logo.types';
export const updateOrganisationBrandingLogoRoute = authenticatedProcedure
.input(ZUpdateOrganisationBrandingLogoRequestSchema)
.output(ZUpdateOrganisationBrandingLogoResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { payload, brandingLogo } = input;
const { organisationId } = payload;
ctx.logger.info({
input: {
organisationId,
},
});
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this organisation.',
});
}
// Setting a logo requires the custom-branding entitlement; clearing it is
// always allowed so a downgraded organisation can still remove its logo.
if (brandingLogo && IS_BILLING_ENABLED()) {
const claim = await getOrganisationClaim({ organisationId });
if (claim.flags?.allowCustomBranding !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Your plan does not allow custom branding.',
});
}
}
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
organisationGlobalSettings: {
update: {
brandingLogo: brandingLogoValue,
},
},
},
});
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({
payload: zfd.json(
z.object({
organisationId: z.string(),
}),
),
brandingLogo: zfdBrandingImageFile().optional(),
});
export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void();
export type TUpdateOrganisationBrandingLogoRequest = z.infer<typeof ZUpdateOrganisationBrandingLogoRequestSchema>;
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZUpdateOrganisationGroupRequestSchema = z.object({
id: z.string(),
name: z.string().nullable().optional(),
name: ZNameSchema.nullable().optional(),
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
memberIds: z.array(z.string()).optional(),
});

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