mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8eb9fc6a | |||
| 40472bc26c | |||
| 3ff7f70a7d | |||
| 5c41740859 | |||
| d6268b1d7d | |||
| 12223c79cb | |||
| b16f979eb3 | |||
| db031e2865 | |||
| 4e0038f2e8 | |||
| c5efd34e95 | |||
| 21cff7a727 | |||
| 400b6a24f1 | |||
| 12d44e1c59 | |||
| 1b1e3d197b | |||
| a276e18e1f | |||
| 9dc66afc7b | |||
| 50f272be87 | |||
| a55e6d9484 | |||
| d35d13db23 | |||
| 7a13be6bf2 | |||
| 1032028395 | |||
| 2396d0d5d0 | |||
| dac262edc9 | |||
| 25eb4ffedf | |||
| ee0ea82635 | |||
| 5f7f6698fd | |||
| 402809c809 | |||
| 733e273c05 | |||
| 7c7933c2d4 | |||
| bb120d65dd | |||
| 9c14aa6297 | |||
| 6387e809a8 | |||
| 5b2b1591a4 | |||
| e5cb4c6bfd | |||
| 8185f916d3 | |||
| c308dbf257 |
@@ -0,0 +1,222 @@
|
|||||||
|
---
|
||||||
|
date: 2026-05-07
|
||||||
|
title: Pdf Placeholder Selection Fields
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Extend PDF placeholders so radio and dropdown fields can be configured from the existing Documenso placeholder syntax:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{FIELD_TYPE, RECIPIENT, key=value, key=value}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not introduce a new delimiter style. Existing applications may already generate placeholders in this format, so the new selection-field behavior should fit into it.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Keep the current placeholder grammar unchanged.
|
||||||
|
- Support checkbox placeholders with option lists, checked values, validation, direction, required, read-only, and font size.
|
||||||
|
- Support radio placeholders with option lists, default/preselected values, direction, required, read-only, and font size.
|
||||||
|
- Support dropdown placeholders with option lists, default value, required, read-only, and font size.
|
||||||
|
- Use `options` as the only public list key in PDF placeholders.
|
||||||
|
- Convert `options` into internal `fieldMeta.values` during parsing.
|
||||||
|
- Make generated fields usable immediately in the editor, signing UI, preview renderer, and final PDF export.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No semicolon placeholder syntax.
|
||||||
|
- No `values` alias in PDF placeholder syntax.
|
||||||
|
- No database migration.
|
||||||
|
- No behavior change for existing placeholders such as `{{text, r1, required=true}}`.
|
||||||
|
|
||||||
|
## Placeholder Syntax
|
||||||
|
|
||||||
|
Use the existing comma-separated placeholder format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||||
|
{{radio, r1, options=Card|Bank transfer|Check, defaultValue=Check}}
|
||||||
|
{{radio, r1, options=Basic|Pro|Enterprise, selected=Pro, direction=horizontal}}
|
||||||
|
{{dropdown, r1, options=United States|Canada|United Kingdom}}
|
||||||
|
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `|` inside `options` because `,` is already the top-level placeholder delimiter.
|
||||||
|
|
||||||
|
Parsing rules:
|
||||||
|
|
||||||
|
- Split top-level placeholder tokens on unescaped commas.
|
||||||
|
- Split metadata tokens on the first unescaped equals sign.
|
||||||
|
- Split `options` on unescaped pipes.
|
||||||
|
- Trim option values and drop empty values.
|
||||||
|
- Preserve option order.
|
||||||
|
- Support escaped delimiters: `\,`, `\=`, and `\|`.
|
||||||
|
- Treat field type values case-insensitively.
|
||||||
|
|
||||||
|
## Field Type Mapping
|
||||||
|
|
||||||
|
- `checkbox` maps to `FieldType.CHECKBOX`.
|
||||||
|
- `radio` maps to `FieldType.RADIO`.
|
||||||
|
- `dropdown` maps to `FieldType.DROPDOWN`.
|
||||||
|
|
||||||
|
## Metadata Mapping
|
||||||
|
|
||||||
|
### Checkbox
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Normalize to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'checkbox',
|
||||||
|
validationRule: 'Select at least',
|
||||||
|
validationLength: 1,
|
||||||
|
values: [
|
||||||
|
{ id: 1, value: 'Email', checked: true },
|
||||||
|
{ id: 2, value: 'SMS', checked: false },
|
||||||
|
{ id: 3, value: 'Phone', checked: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted keys:
|
||||||
|
|
||||||
|
- `options`
|
||||||
|
- `checked`
|
||||||
|
- `direction=vertical|horizontal`
|
||||||
|
- `validationRule=atLeast|exactly|atMost`
|
||||||
|
- `validationLength=1`
|
||||||
|
- `required=true|false`
|
||||||
|
- `readOnly=true|false`
|
||||||
|
- `fontSize=12`
|
||||||
|
|
||||||
|
Map checkbox validation aliases internally: `atLeast` -> `Select at least`, `exactly` -> `Select exactly`, `atMost` -> `Select at most`.
|
||||||
|
|
||||||
|
Checkbox placeholders do not support `label` or `placeholder` metadata.
|
||||||
|
|
||||||
|
### Radio
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{radio, r1, options=Card|Bank transfer|Check, selected=Bank transfer}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Normalize to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, value: 'Card', checked: false },
|
||||||
|
{ id: 2, value: 'Bank transfer', checked: true },
|
||||||
|
{ id: 3, value: 'Check', checked: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted keys:
|
||||||
|
|
||||||
|
- `options`
|
||||||
|
- `selected`, `default`, or `defaultValue`
|
||||||
|
- `direction=vertical|horizontal`
|
||||||
|
- `required=true|false`
|
||||||
|
- `readOnly=true|false`
|
||||||
|
- `fontSize=12`
|
||||||
|
|
||||||
|
Radio placeholders do not support `label` or `placeholder` metadata.
|
||||||
|
|
||||||
|
### Dropdown
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{dropdown, r1, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Normalize to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'dropdown',
|
||||||
|
values: [{ value: 'Sales' }, { value: 'Legal' }, { value: 'Finance' }],
|
||||||
|
defaultValue: 'Legal',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted keys:
|
||||||
|
|
||||||
|
- `options`
|
||||||
|
- `selected`, `default`, or `defaultValue`
|
||||||
|
- `required=true|false`
|
||||||
|
- `readOnly=true|false`
|
||||||
|
- `fontSize=12`
|
||||||
|
|
||||||
|
`defaultValue` should only be set if it matches one parsed option.
|
||||||
|
|
||||||
|
Dropdown placeholders do not support `label` or `placeholder` metadata.
|
||||||
|
|
||||||
|
## Code Touchpoints
|
||||||
|
|
||||||
|
- `packages/lib/server-only/pdf/helpers.ts`
|
||||||
|
- Extend `parseFieldMetaFromPlaceholder` so `options` normalizes into checkbox/radio/dropdown `fieldMeta.values`.
|
||||||
|
- Add delimiter-aware helpers for commas, equals signs, and pipes.
|
||||||
|
- `packages/lib/server-only/pdf/auto-place-fields.ts`
|
||||||
|
- Replace plain comma splitting with delimiter-aware splitting.
|
||||||
|
- Preserve the existing positional structure: field type, recipient, metadata.
|
||||||
|
- `packages/lib/types/field-meta.ts`
|
||||||
|
- Keep current internal schemas: checkbox/radio/dropdown still store options as `fieldMeta.values`.
|
||||||
|
- `packages/ui/primitives/document-flow/field-content.tsx`
|
||||||
|
- Display a radio fallback when a placeholder-created radio has no options.
|
||||||
|
- Docs:
|
||||||
|
- `apps/docs/content/docs/users/documents/advanced/pdf-placeholders.mdx`
|
||||||
|
- `apps/docs/content/docs/developers/api/fields.mdx`
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
Unit tests:
|
||||||
|
|
||||||
|
- `options=Yes|No|Maybe` becomes stable radio values.
|
||||||
|
- `selected=No` marks only the matching radio option checked.
|
||||||
|
- Checkbox `options`, `checked`, `validationRule`, and `validationLength` normalize correctly.
|
||||||
|
- Dropdown `options` and `defaultValue` normalize correctly.
|
||||||
|
- Escaped delimiters parse correctly, for example `options=Sales\|Ops|Legal\, Compliance|A\=B`.
|
||||||
|
|
||||||
|
E2E/API tests:
|
||||||
|
|
||||||
|
- Add a PDF fixture with checkbox, radio, and dropdown placeholders using the current syntax.
|
||||||
|
- Verify created fields have schema-compatible metadata and expected options/defaults.
|
||||||
|
|
||||||
|
Suggested verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -w @documenso/lib -- server-only/pdf/helpers.test.ts
|
||||||
|
npm run test:dev -w @documenso/app-tests -- e2e/auto-placing-fields/auto-place-fields-document.spec.ts
|
||||||
|
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-fields.spec.ts
|
||||||
|
npx tsc --noEmit -p packages/lib/tsconfig.json
|
||||||
|
npx tsc --noEmit -p apps/remix/tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use `npm run build` for routine verification unless explicitly requested.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Keep the existing placeholder format.
|
||||||
|
- Use only `options` publicly.
|
||||||
|
- Keep `values` as an internal metadata field only.
|
||||||
|
- Use `|` as the option delimiter inside `options`.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
legacy-peer-deps = true
|
legacy-peer-deps = true
|
||||||
prefer-dedupe = true
|
prefer-dedupe = true
|
||||||
min-release-age = 7
|
# min-release-age = 7
|
||||||
|
|||||||
@@ -65,3 +65,4 @@ When you exceed a resource limit:
|
|||||||
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
||||||
- [API Versioning](/docs/developers/api/versioning) - API version management
|
- [API Versioning](/docs/developers/api/versioning) - API version management
|
||||||
- [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API
|
- [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
|
4. Restart your Documenso instance
|
||||||
5. Verify the license is active in the **Admin Panel** under the **Stats** section
|
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>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
|
|||||||
1. Sign the Enterprise license agreement
|
1. Sign the Enterprise license agreement
|
||||||
2. Receive license key and access credentials
|
2. Receive license key and access credentials
|
||||||
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
|
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>
|
||||||
<Step>
|
<Step>
|
||||||
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
|
|||||||
|
|
||||||
## Related
|
## 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
|
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
|
||||||
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
|
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
|
||||||
- [Support](/docs/policies/support) - Support channels and response times
|
- [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
|
## 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 |
|
| 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_API_KEY` | Stripe API key for billing |
|
||||||
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
|
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
|
||||||
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
|
| `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
|
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
|
||||||
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
|
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
|
||||||
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
|
- [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
|
- [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."
|
description="Digital signature certificate setup."
|
||||||
href="/docs/self-hosting/configuration/signing-certificate"
|
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>
|
</Cards>
|
||||||
|
|
||||||
## Required Configuration
|
## 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",
|
"title": "Configuration",
|
||||||
"pages": [
|
"pages": [
|
||||||
"environment",
|
"environment",
|
||||||
|
"license",
|
||||||
"database",
|
"database",
|
||||||
"email",
|
"email",
|
||||||
"storage",
|
"storage",
|
||||||
"background-jobs",
|
"background-jobs",
|
||||||
"signing-certificate",
|
"signing-certificate",
|
||||||
"telemetry",
|
"telemetry",
|
||||||
|
"organisation-limits",
|
||||||
"advanced"
|
"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
|
### 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>
|
||||||
<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.
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,37 @@ You can customize fields by adding options after the recipient identifier:
|
|||||||
| `maxValue` | Number | Maximum allowed value |
|
| `maxValue` | Number | Maximum allowed value |
|
||||||
| `numberFormat` | Format string | Number display format |
|
| `numberFormat` | Format string | Number display format |
|
||||||
|
|
||||||
|
### Selection Field Options
|
||||||
|
|
||||||
|
Checkbox, radio, and dropdown placeholders can define their selectable choices via the `options` property.
|
||||||
|
Separate choices with pipe (`|`) characters.
|
||||||
|
Checkbox, radio, and dropdown placeholders do not support `label` or `placeholder` metadata.
|
||||||
|
|
||||||
|
| Option | Applies To | Values | Description |
|
||||||
|
| ------------------ | ------------------------- | ------------------------ | ---------------------------------------- |
|
||||||
|
| `options` | Checkbox, Radio, Dropdown | `Option 1|Option 2` | Selectable choices |
|
||||||
|
| `checked` | Checkbox | `Option 1|Option 2` | Pre-checked choices |
|
||||||
|
| `selected` | Radio, Dropdown | One option value | Pre-selected/default choice |
|
||||||
|
| `default` | Radio, Dropdown | One option value | Alias for `selected` |
|
||||||
|
| `defaultValue` | Radio, Dropdown | One option value | Alias for `selected` |
|
||||||
|
| `direction` | Checkbox, Radio | `vertical`, `horizontal` | Option layout |
|
||||||
|
| `validationRule` | Checkbox | `atLeast`, `exactly`, `atMost` | Checkbox selection validation rule |
|
||||||
|
| `validationLength` | Checkbox | Number (e.g., `1`) | Checkbox validation option count |
|
||||||
|
| `required` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the field must be completed |
|
||||||
|
| `readOnly` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the pre-selected value is locked |
|
||||||
|
| `fontSize` | Checkbox, Radio, Dropdown | Number (e.g., `12`) | Field text size |
|
||||||
|
|
||||||
|
For checkbox validation, `validationLength` defines the option count:
|
||||||
|
- `atLeast` means at least that many options must be selected
|
||||||
|
- `exactly` means exactly that many options must be selected
|
||||||
|
- `atMost` means at most that many options must be selected
|
||||||
|
|
||||||
|
If an option needs a literal delimiter, escape it with a backslash:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{dropdown, r1, options=Sales\|Ops|Legal\, Compliance|A\=B}}
|
||||||
|
```
|
||||||
|
|
||||||
### Examples with Options
|
### Examples with Options
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -116,6 +147,10 @@ You can customize fields by adding options after the recipient identifier:
|
|||||||
{{number, r1, minValue=0, maxValue=100, value=50}}
|
{{number, r1, minValue=0, maxValue=100, value=50}}
|
||||||
{{name, r1, fontSize=14}}
|
{{name, r1, fontSize=14}}
|
||||||
{{text, r2, readOnly=true, text=Contract #12345}}
|
{{text, r2, readOnly=true, text=Contract #12345}}
|
||||||
|
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||||
|
{{radio, r1, options=Card|Bank transfer|Check, selected=Check}}
|
||||||
|
{{dropdown, r1, options=United States|Canada|United Kingdom}}
|
||||||
|
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"@types/node": "^25.1.0",
|
"@types/node": "^25.1.0",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.19",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
--accent: hsl(0 0% 27.8431%);
|
--accent: hsl(0 0% 27.8431%);
|
||||||
--accent-foreground: hsl(95.0847 71.0843% 67.451%);
|
--accent-foreground: hsl(95.0847 71.0843% 67.451%);
|
||||||
--destructive: hsl(0 86.5979% 61.9608%);
|
--destructive: hsl(0 86.5979% 61.9608%);
|
||||||
--destructive-foreground: hsl(0 87.6289% 19.0196%);
|
--destructive-foreground: hsl(0 0% 98.0392%);
|
||||||
--border: hsl(0 0% 27.8431%);
|
--border: hsl(0 0% 27.8431%);
|
||||||
--input: hsl(0 0% 27.8431%);
|
--input: hsl(0 0% 27.8431%);
|
||||||
--ring: hsl(95.0847 71.0843% 67.451%);
|
--ring: hsl(95.0847 71.0843% 67.451%);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const ZCreateFolderFormSchema = z.object({
|
const ZCreateFolderFormSchema = z.object({
|
||||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
name: ZNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||||
@@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
|
|||||||
toast({
|
toast({
|
||||||
description: t`Folder created successfully`,
|
description: t`Folder created successfully`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to create folder`,
|
title: t`Failed to create folder`,
|
||||||
description: t`An unknown error occurred while creating the folder.`,
|
description: t`An unknown error occurred while creating the folder.`,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>
|
<Trans>
|
||||||
Confirm by typing:{' '}
|
Confirm by typing:{' '}
|
||||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -23,8 +24,6 @@ import { useEffect } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type FolderUpdateDialogProps = {
|
export type FolderUpdateDialogProps = {
|
||||||
folder: TFolderWithSubfolders | null;
|
folder: TFolderWithSubfolders | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
|
|||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
export const ZUpdateFolderFormSchema = z.object({
|
export const ZUpdateFolderFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: ZNameSchema,
|
||||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
|||||||
|
|
||||||
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
|
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
|||||||
>
|
>
|
||||||
<div className="w-full text-left">
|
<div className="w-full text-left">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-medium">
|
<p className="font-medium">
|
||||||
<Trans context="Plan price">Free</Trans>
|
<Trans context="Plan price">Free</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const OrganisationEmailDomainDeleteDialog = ({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>
|
<Trans>
|
||||||
Confirm by typing{' '}
|
Confirm by typing{' '}
|
||||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
'inline-flex h-10 w-10 items-center justify-start text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
index === 0 ? 'mt-8' : 'mt-0',
|
index === 0 ? 'mt-8' : 'mt-0',
|
||||||
)}
|
)}
|
||||||
disabled={organisationMemberInvites.length === 1}
|
disabled={organisationMemberInvites.length === 1}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export type PasskeyCreateDialogProps = {
|
export type PasskeyCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZCreatePasskeyFormSchema = z.object({
|
const ZCreatePasskeyFormSchema = z.object({
|
||||||
passkeyName: z.string().min(3),
|
passkeyName: ZNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
import { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
export type TeamEmailUpdateDialogProps = {
|
export type TeamEmailUpdateDialogProps = {
|
||||||
teamEmail: TeamEmail;
|
teamEmail: TeamEmail;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZUpdateTeamEmailFormSchema = z.object({
|
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
data: true,
|
||||||
});
|
}).shape.data;
|
||||||
|
|
||||||
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: teamEmail.name,
|
name: teamEmail.name,
|
||||||
},
|
},
|
||||||
|
mode: 'onSubmit',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
|
||||||
|
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
const NEVER_EXPIRE = 'NEVER' as const;
|
||||||
|
|
||||||
|
export const EXPIRATION_DATES = {
|
||||||
|
ONE_WEEK: msg`7 days`,
|
||||||
|
ONE_MONTH: msg`1 month`,
|
||||||
|
THREE_MONTHS: msg`3 months`,
|
||||||
|
SIX_MONTHS: msg`6 months`,
|
||||||
|
ONE_YEAR: msg`12 months`,
|
||||||
|
[NEVER_EXPIRE]: msg`Never`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
|
||||||
|
tokenName: true,
|
||||||
|
expirationDate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
||||||
|
|
||||||
|
export type TokenCreateDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const TokenCreateDialog = ({ trigger, ...props }: TokenCreateDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<TCreateTokenFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreateTokenFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
tokenName: '',
|
||||||
|
expirationDate: 'THREE_MONTHS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createToken } = trpc.apiToken.create.useMutation();
|
||||||
|
|
||||||
|
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
|
||||||
|
try {
|
||||||
|
const { token } = await createToken({
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName,
|
||||||
|
expirationDate: expirationDate === NEVER_EXPIRE ? null : expirationDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCreatedToken(token);
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const errorMessage = match(error.code)
|
||||||
|
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
|
||||||
|
.otherwise(() => msg`Something went wrong. Please try again later.`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`An error occurred`),
|
||||||
|
description: _(errorMessage),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset();
|
||||||
|
setCreatedToken(null);
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button className="flex-shrink-0">
|
||||||
|
<Trans>Create token</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-lg"
|
||||||
|
position="center"
|
||||||
|
onInteractOutside={(event) => {
|
||||||
|
// Prevent losing the created token by accidentally clicking outside the dialog.
|
||||||
|
if (createdToken) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{createdToken ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Token created</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Copy your token now. For security reasons you will not be able to see it again.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
className="pr-12 font-mono text-sm"
|
||||||
|
aria-label={_(msg`Your new API token`)}
|
||||||
|
name="createdToken"
|
||||||
|
readOnly
|
||||||
|
value={createdToken}
|
||||||
|
/>
|
||||||
|
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
|
||||||
|
<CopyTextButton
|
||||||
|
value={createdToken}
|
||||||
|
onCopySuccess={() => toast({ title: _(msg`Token copied to clipboard`) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Done</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Create API token</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Use API tokens to authenticate with the Documenso API.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tokenName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>A name to help you identify this token later.</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="expirationDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Expires in</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select value={field.value ?? NEVER_EXPIRE} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{_(date)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Create token</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Are you sure you want to delete this token?</Trans>
|
<Trans>Delete token</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -126,7 +126,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>
|
<Trans>
|
||||||
Confirm by typing:{' '}
|
Confirm by typing:{' '}
|
||||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
@@ -139,21 +139,18 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-nowrap gap-4">
|
<Button type="button" variant="secondary" onClick={() => setIsOpen(false)}>
|
||||||
<Button type="button" variant="secondary" className="flex-1" onClick={() => setIsOpen(false)}>
|
<Trans>Cancel</Trans>
|
||||||
<Trans>Cancel</Trans>
|
</Button>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1"
|
disabled={!form.formState.isValid}
|
||||||
disabled={!form.formState.isValid}
|
loading={form.formState.isSubmitting}
|
||||||
loading={form.formState.isSubmitting}
|
>
|
||||||
>
|
<Trans>Delete</Trans>
|
||||||
<Trans>I'm sure! Delete it</Trans>
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>
|
<Trans>
|
||||||
Confirm by typing:{' '}
|
Confirm by typing:{' '}
|
||||||
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
|
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ export const ConfigureFieldsView = ({
|
|||||||
{selectedField && (
|
{selectedField && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted-background',
|
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted',
|
||||||
selectedRecipientStyles.base,
|
selectedRecipientStyles.base,
|
||||||
{
|
{
|
||||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
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 { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -23,15 +28,15 @@ import { useCspNonce } from '~/utils/nonce';
|
|||||||
|
|
||||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
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({
|
const ZBrandingPreferencesFormSchema = z.object({
|
||||||
brandingEnabled: z.boolean().nullable(),
|
brandingEnabled: z.boolean().nullable(),
|
||||||
brandingLogo: z
|
brandingLogo: z
|
||||||
.instanceof(File)
|
.instanceof(File)
|
||||||
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
|
.refine(
|
||||||
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
|
(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(),
|
.nullish(),
|
||||||
brandingUrl: z.string().url().optional().or(z.literal('')),
|
brandingUrl: z.string().url().optional().or(z.literal('')),
|
||||||
brandingCompanyDetails: z.string().max(500).optional(),
|
brandingCompanyDetails: z.string().max(500).optional(),
|
||||||
@@ -245,7 +250,7 @@ export function BrandingPreferencesForm({
|
|||||||
<FormControl className="relative">
|
<FormControl className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
|
||||||
disabled={!isBrandingEnabled}
|
disabled={!isBrandingEnabled}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const ZEmailTransportFormSchema = z.object({
|
const ZEmailTransportFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: ZNameSchema,
|
||||||
fromName: z.string().min(1),
|
fromName: ZNameSchema,
|
||||||
fromAddress: z.string().email(),
|
fromAddress: z.string().email(),
|
||||||
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
|
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
|
||||||
host: z.string().optional(),
|
host: z.string().optional(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
@@ -96,7 +96,7 @@ export const SignUpForm = ({
|
|||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
signature: '',
|
||||||
},
|
},
|
||||||
mode: 'onBlur',
|
mode: 'onChange',
|
||||||
resolver: zodResolver(ZSignUpFormSchema),
|
resolver: zodResolver(ZSignUpFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { ApiToken } from '@prisma/client';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export const EXPIRATION_DATES = {
|
|
||||||
ONE_WEEK: msg`7 days`,
|
|
||||||
ONE_MONTH: msg`1 month`,
|
|
||||||
THREE_MONTHS: msg`3 months`,
|
|
||||||
SIX_MONTHS: msg`6 months`,
|
|
||||||
ONE_YEAR: msg`12 months`,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
|
|
||||||
tokenName: true,
|
|
||||||
expirationDate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
|
||||||
|
|
||||||
type NewlyCreatedToken = {
|
|
||||||
id: number;
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiTokenFormProps = {
|
|
||||||
className?: string;
|
|
||||||
tokens?: Pick<ApiToken, 'id'>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
|
||||||
const [, copy] = useCopyToClipboard();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
|
|
||||||
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
|
|
||||||
onSuccess(data) {
|
|
||||||
setNewlyCreatedToken(data);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<TCreateTokenFormSchema>({
|
|
||||||
resolver: zodResolver(ZCreateTokenFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
tokenName: '',
|
|
||||||
expirationDate: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyToken = async (token: string) => {
|
|
||||||
try {
|
|
||||||
const copied = await copy(token);
|
|
||||||
|
|
||||||
if (!copied) {
|
|
||||||
throw new Error('Unable to copy the token');
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Token copied to clipboard`),
|
|
||||||
description: _(msg`The token was copied to your clipboard.`),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Unable to copy token`),
|
|
||||||
description: _(msg`We were unable to copy the token to your clipboard. Please try again.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createTokenMutation({
|
|
||||||
teamId: team.id,
|
|
||||||
tokenName,
|
|
||||||
expirationDate: noExpirationDate ? null : expirationDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Token created`),
|
|
||||||
description: _(msg`A new token was created successfully.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
const errorMessage = match(error.code)
|
|
||||||
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
|
|
||||||
.otherwise(() => msg`Something went wrong. Please try again later.`);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`An error occurred`),
|
|
||||||
description: _(errorMessage),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(className)}>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset className="mt-6 flex w-full flex-col gap-4" disabled={form.formState.isSubmitting}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="tokenName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel className="text-muted-foreground">
|
|
||||||
<Trans>Token name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<FormControl className="flex-1">
|
|
||||||
<Input type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormDescription className="text-xs italic">
|
|
||||||
<Trans>Please enter a meaningful name for your token. This will help you identify it later.</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="expirationDate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel className="text-muted-foreground">
|
|
||||||
<Trans>Token expiration date</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<FormControl className="flex-1">
|
|
||||||
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder={_(msg`Choose...`)} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
|
|
||||||
<SelectItem key={key} value={key}>
|
|
||||||
{_(date)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel className="mt-2 text-muted-foreground">
|
|
||||||
<Trans>Never expire</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<div className="block md:py-1.5">
|
|
||||||
<Switch
|
|
||||||
className="mt-2 bg-background"
|
|
||||||
checked={noExpirationDate}
|
|
||||||
onCheckedChange={setNoExpirationDate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="hidden md:inline-flex" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Create token</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="md:hidden">
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Create token</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && (
|
|
||||||
<motion.div
|
|
||||||
className="mt-8"
|
|
||||||
initial={{ opacity: 0, y: -40 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 40 }}
|
|
||||||
>
|
|
||||||
<Card gradient>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="mt-2 text-muted-foreground text-sm">
|
|
||||||
<Trans>
|
|
||||||
Your token was created successfully! Make sure to copy it because you won't be able to see it again!
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
|
|
||||||
{newlyCreatedToken.token}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken.token)}>
|
|
||||||
<Trans>Copy token</Trans>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||||
|
|
||||||
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
|
|||||||
type AdminGlobalSettingsSectionProps = {
|
type AdminGlobalSettingsSectionProps = {
|
||||||
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
||||||
isTeam?: boolean;
|
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 { _ } = useLingui();
|
||||||
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
|
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textValue = (value: string | null | undefined) => {
|
const notSet = <Trans>Not set</Trans>;
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return notSetLabel;
|
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) => {
|
const textValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||||
if (value === null || value === undefined || value.trim() === '') {
|
if (value && value.trim() !== '') {
|
||||||
return notSetLabel;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
if (inherited && inherited.trim() !== '') {
|
||||||
|
return inheritedValue(inherited);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notSet;
|
||||||
};
|
};
|
||||||
|
|
||||||
const booleanValue = (value: boolean | null | undefined) => {
|
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return notSetLabel;
|
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);
|
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">
|
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
||||||
<DetailsValue>
|
<DetailsValue>
|
||||||
{settings.documentVisibility != null
|
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
|
||||||
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
|
|
||||||
: notSetLabel}
|
|
||||||
</DetailsValue>
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Document language</Trans>}>
|
<DetailsCard label={<Trans>Document language</Trans>}>
|
||||||
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
|
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
||||||
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
|
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Date format</Trans>}>
|
<DetailsCard label={<Trans>Date format</Trans>}>
|
||||||
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
|
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
|
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
|
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
||||||
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
|
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
||||||
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
|
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
||||||
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
||||||
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
|
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
{isTeam && parsedEmailSettings.success && (
|
{isTeam && parsedEmailSettings.success && (
|
||||||
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DetailsCard label={<Trans>AI features</Trans>}>
|
<DetailsCard label={<Trans>AI features</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
|
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
|||||||
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
|
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">
|
<h3 className="mb-2 flex items-end font-medium text-foreground text-sm leading-tight">
|
||||||
<Trans>Documenso License</Trans>
|
<Trans>Documenso License</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
@@ -19,7 +20,6 @@ import { useRef } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
||||||
|
|
||||||
export type ClaimAccountProps = {
|
export type ClaimAccountProps = {
|
||||||
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
|
|||||||
|
|
||||||
export const ZClaimAccountFormSchema = z
|
export const ZClaimAccountFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
|
name: ZNameSchema,
|
||||||
email: zEmail().min(1),
|
email: zEmail().min(1),
|
||||||
password: ZPasswordSchema,
|
password: ZPasswordSchema,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { ReactNode } from 'react';
|
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';
|
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> = {
|
type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||||
control: Control<T>;
|
control: Control<T>;
|
||||||
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
||||||
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LimitGroup = {
|
||||||
|
title: ReactNode;
|
||||||
|
quotaKey: string;
|
||||||
|
rateLimitKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const ClaimLimitFields = <T extends FieldValues>({
|
export const ClaimLimitFields = <T extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
prefix = '',
|
prefix = '',
|
||||||
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
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
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
name={name(key)}
|
name={name(group.quotaKey)}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
<FormLabel className="text-muted-foreground text-xs">
|
||||||
|
<Trans>Monthly quota</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
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))}
|
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{description}</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRateLimitField = (key: string, label: ReactNode) => (
|
const renderRateLimitField = (group: LimitGroup) => (
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
name={name(key)}
|
name={name(group.rateLimitKey)}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="space-y-3">
|
||||||
<FormLabel>
|
<div>
|
||||||
<Trans>Limits</Trans>
|
<h3 className="font-semibold text-base">
|
||||||
</FormLabel>
|
<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(
|
<div className="overflow-hidden rounded-lg border">
|
||||||
'documentQuota',
|
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
|
||||||
<Trans>Monthly document quota</Trans>,
|
{limitGroups.map((group) => (
|
||||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
<div key={group.quotaKey} className="space-y-4 p-4">
|
||||||
)}
|
<h4 className="font-semibold text-sm">{group.title}</h4>
|
||||||
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
|
||||||
|
|
||||||
{renderQuotaField(
|
{renderQuotaField(group)}
|
||||||
'emailQuota',
|
{renderRateLimitField(group)}
|
||||||
<Trans>Monthly email quota</Trans>,
|
</div>
|
||||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
))}
|
||||||
)}
|
</div>
|
||||||
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
|
</div>
|
||||||
|
|
||||||
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
|
|
||||||
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -270,7 +270,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
{selectedField && (
|
{selectedField && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted-background',
|
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
|
||||||
selectedRecipientStyles.base,
|
selectedRecipientStyles.base,
|
||||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
|||||||
{type === EnvelopeType.DOCUMENT ? <Trans>Upload Document</Trans> : <Trans>Upload Template</Trans>}
|
{type === EnvelopeType.DOCUMENT ? <Trans>Upload Document</Trans> : <Trans>Upload Template</Trans>}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="mt-4 text-md text-muted-foreground">
|
<p className="mt-4 text-base text-muted-foreground">
|
||||||
<Trans>Drag and drop your document here</Trans>
|
<Trans>Drag and drop your document here</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
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 { Progress } from '@documenso/ui/primitives/progress';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||||
import { useState } from 'react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
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';
|
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 = {
|
type OrganisationUsagePanelProps = {
|
||||||
organisationId: string;
|
organisationId: string;
|
||||||
monthlyStats: Pick<
|
monthlyStats: Pick<
|
||||||
@@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = {
|
|||||||
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
||||||
>[];
|
>[];
|
||||||
organisationClaim: OrganisationClaim;
|
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 = ({
|
export const OrganisationUsagePanel = ({
|
||||||
organisationId,
|
organisationId,
|
||||||
monthlyStats,
|
monthlyStats,
|
||||||
organisationClaim,
|
organisationClaim,
|
||||||
|
capacityUsage,
|
||||||
}: OrganisationUsagePanelProps) => {
|
}: OrganisationUsagePanelProps) => {
|
||||||
|
const monthlyUsagePeriodId = useId();
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
|
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
|
||||||
|
|
||||||
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
|
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.
|
// current period), so only offer the reset action when viewing the current month.
|
||||||
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
|
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>,
|
label: <Trans>Documents</Trans>,
|
||||||
|
icon: FileIcon,
|
||||||
used: selectedStat?.documentCount ?? 0,
|
used: selectedStat?.documentCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.documentQuota,
|
effectiveLimit: organisationClaim.documentQuota,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
counter: 'email' as const,
|
counter: 'email',
|
||||||
label: <Trans>Emails</Trans>,
|
label: <Trans>Emails</Trans>,
|
||||||
|
icon: MailIcon,
|
||||||
used: selectedStat?.emailCount ?? 0,
|
used: selectedStat?.emailCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.emailQuota,
|
effectiveLimit: organisationClaim.emailQuota,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
counter: 'api' as const,
|
counter: 'api',
|
||||||
label: <Trans>API requests</Trans>,
|
label: <Trans>API requests</Trans>,
|
||||||
|
icon: PlugIcon,
|
||||||
used: selectedStat?.apiCount ?? 0,
|
used: selectedStat?.apiCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.apiQuota,
|
effectiveLimit: organisationClaim.apiQuota,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="mt-4 space-y-6">
|
||||||
<div className="flex items-center justify-between gap-2">
|
{capacityRows.length > 0 ? (
|
||||||
<h3 className="font-medium text-sm">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
|
{capacityRows.map((row) => (
|
||||||
</h3>
|
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{monthlyStats.length > 0 && (
|
<div className="space-y-3">
|
||||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<SelectTrigger className="w-40">
|
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
|
||||||
<SelectValue />
|
<Trans>Monthly usage</Trans>
|
||||||
</SelectTrigger>
|
</h3>
|
||||||
<SelectContent>
|
|
||||||
{monthlyStats.map((stat) => (
|
|
||||||
<SelectItem key={stat.period} value={stat.period}>
|
|
||||||
{stat.period}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rows.map((row) => {
|
{monthlyStats.length > 0 ? (
|
||||||
const percent =
|
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||||
row.effectiveLimit && row.effectiveLimit > 0
|
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
|
||||||
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
|
<SelectValue />
|
||||||
: 0;
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{monthlyStats.map((stat) => (
|
||||||
|
<SelectItem key={stat.period} value={stat.period}>
|
||||||
|
{stat.period}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<div key={row.counter} className="space-y-1">
|
{monthlyRows.map((row) => (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<UsageStatCard
|
||||||
<span>{row.label}</span>
|
key={row.counter}
|
||||||
<span className="text-muted-foreground">
|
label={row.label}
|
||||||
{row.used} /{' '}
|
icon={row.icon}
|
||||||
{match(row.effectiveLimit)
|
used={row.used}
|
||||||
.with(null, () => <Trans>Unlimited</Trans>)
|
limit={row.effectiveLimit}
|
||||||
.with(0, () => <Trans>Blocked</Trans>)
|
action={
|
||||||
.otherwise(String)}
|
selectedStat && isCurrentPeriod ? (
|
||||||
</span>
|
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||||
</div>
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
<UsageStatCard
|
||||||
|
label={<Trans>Reports</Trans>}
|
||||||
{selectedStat && isCurrentPeriod && (
|
icon={MailOpenIcon}
|
||||||
<div className="flex w-full justify-end pt-1">
|
used={selectedStat?.emailReports ?? 0}
|
||||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
limit={null}
|
||||||
</div>
|
countOnly
|
||||||
)}
|
footnote={<Trans>Sent this period</Trans>}
|
||||||
</div>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span>
|
|
||||||
<Trans>Reports</Trans>
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { RotateCcwIcon } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
type OrganisationUsageResetButtonProps = {
|
type OrganisationUsageResetButtonProps = {
|
||||||
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
|
|||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={() => reset({ organisationId, counter })}
|
onClick={() => reset({ organisationId, counter })}
|
||||||
>
|
>
|
||||||
|
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
|
||||||
<Trans>Reset</Trans>
|
<Trans>Reset</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
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 { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
type RateLimitEntryValue = { window: string; max: number };
|
type RateLimitEntryValue = { window: string; max: number };
|
||||||
|
|
||||||
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
|
|||||||
disabled?: boolean;
|
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) => {
|
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 updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
|
||||||
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
if (index >= value.length) {
|
||||||
onChange(next);
|
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) => {
|
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 = () => {
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{entries.map((entry, index) => (
|
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||||
<div key={index} className="flex items-center gap-2">
|
<span className="w-20 shrink-0">
|
||||||
<Input
|
<Trans>Window</Trans>
|
||||||
className="w-24"
|
</span>
|
||||||
placeholder="5m"
|
<span className="flex-1">
|
||||||
value={entry.window}
|
<Trans>Max requests</Trans>
|
||||||
disabled={disabled}
|
</span>
|
||||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
<span className="w-9 shrink-0" aria-hidden="true" />
|
||||||
/>
|
</div>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<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" />
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Add rate limit</Trans>
|
<Trans>Add rate limit window</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ZUpdatePasskeySchema = z.object({
|
const ZUpdatePasskeySchema = z.object({
|
||||||
name: z.string(),
|
name: ZNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||||
|
|||||||
@@ -3,27 +3,32 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
|||||||
import { i18n } from '@lingui/core';
|
import { i18n } from '@lingui/core';
|
||||||
import { detect, fromHtmlTag } from '@lingui/detect-locale';
|
import { detect, fromHtmlTag } from '@lingui/detect-locale';
|
||||||
import { I18nProvider } from '@lingui/react';
|
import { I18nProvider } from '@lingui/react';
|
||||||
import { StrictMode, startTransition, useEffect } from 'react';
|
import { StrictMode, startTransition } from 'react';
|
||||||
import { hydrateRoot } from 'react-dom/client';
|
import { hydrateRoot } from 'react-dom/client';
|
||||||
import { HydratedRouter } from 'react-router/dom';
|
import { HydratedRouter } from 'react-router/dom';
|
||||||
|
|
||||||
import './utils/polyfills/promise-with-resolvers';
|
import './utils/polyfills/promise-with-resolvers';
|
||||||
|
|
||||||
function PosthogInit() {
|
/**
|
||||||
|
* Initialised imperatively (not as a component inside `hydrateRoot`) because
|
||||||
|
* rendering extra client-only siblings changes the React tree structure
|
||||||
|
* relative to the server render in `entry.server.tsx`. That shifts every
|
||||||
|
* `useId` value (used by Radix for `id`/`htmlFor`/`aria-*`), causing hydration
|
||||||
|
* mismatches which can abort hydration entirely when the user interacts with
|
||||||
|
* the page early, leaving dead event handlers (broken dropdowns, native form
|
||||||
|
* submits).
|
||||||
|
*/
|
||||||
|
function initPosthog() {
|
||||||
const postHogConfig = extractPostHogConfig();
|
const postHogConfig = extractPostHogConfig();
|
||||||
|
|
||||||
useEffect(() => {
|
if (postHogConfig) {
|
||||||
if (postHogConfig) {
|
void import('posthog-js').then(({ default: posthog }) => {
|
||||||
void import('posthog-js').then(({ default: posthog }) => {
|
posthog.init(postHogConfig.key, {
|
||||||
posthog.init(postHogConfig.key, {
|
api_host: postHogConfig.host,
|
||||||
api_host: postHogConfig.host,
|
capture_exceptions: true,
|
||||||
capture_exceptions: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -38,11 +43,11 @@ async function main() {
|
|||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<HydratedRouter />
|
<HydratedRouter />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|
||||||
<PosthogInit />
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void initPosthog();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
|||||||
+10
-2
@@ -119,7 +119,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
|
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
|
// `suppressHydrationWarning` because `remix-themes` intentionally mutates
|
||||||
|
// `data-theme`/`class` on <html> before hydration (PreventFlashOnWrongTheme),
|
||||||
|
// so the server-rendered attributes never match the client render when the
|
||||||
|
// theme is resolved from the system preference. Attribute-only, one level deep.
|
||||||
|
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
@@ -173,7 +177,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
<script
|
<script
|
||||||
nonce={nonce(cspNonce)}
|
nonce={nonce(cspNonce)}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
|
// `__webpack_nonce__` is read by `get-nonce` (used by
|
||||||
|
// react-remove-scroll / react-style-singleton inside Radix menus and
|
||||||
|
// dialogs) to stamp runtime-injected <style> elements. Without it the
|
||||||
|
// strict `style-src-elem` CSP blocks the scroll-lock styles.
|
||||||
|
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}; window.__webpack_nonce__ = ${JSON.stringify(cspNonce ?? '')}`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
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 { 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 { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/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 { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
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 { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||||
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-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 { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
||||||
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
<GenericOrganisationAdminForm organisation={organisation} />
|
<GenericOrganisationAdminForm organisation={organisation} />
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg border p-4">
|
<SettingsHeader
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
title={t`Organisation usage`}
|
||||||
<div>
|
subtitle={t`Current usage against organisation limits.`}
|
||||||
<p className="font-medium text-sm">
|
className="mt-6"
|
||||||
<Trans>Organisation usage</Trans>
|
hideDivider
|
||||||
</p>
|
/>
|
||||||
<p className="mt-1 text-muted-foreground text-sm">
|
|
||||||
<Trans>Current usage against organisation limits.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
<OrganisationUsagePanel
|
||||||
<DetailsCard label={<Trans>Members</Trans>}>
|
organisationId={organisation.id}
|
||||||
<DetailsValue>
|
monthlyStats={organisation.monthlyStats}
|
||||||
{organisation.members.length} /{' '}
|
organisationClaim={organisation.organisationClaim}
|
||||||
{organisation.organisationClaim.memberCount === 0
|
capacityUsage={{
|
||||||
? t`Unlimited`
|
members: organisation.members.length,
|
||||||
: organisation.organisationClaim.memberCount}
|
teams: organisation.teams.length,
|
||||||
</DetailsValue>
|
}}
|
||||||
</DetailsCard>
|
/>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Teams</Trans>}>
|
|
||||||
<DetailsValue>
|
|
||||||
{organisation.teams.length} /{' '}
|
|
||||||
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
|
|
||||||
</DetailsValue>
|
|
||||||
</DetailsCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<OrganisationUsagePanel
|
|
||||||
organisationId={organisation.id}
|
|
||||||
monthlyStats={organisation.monthlyStats}
|
|
||||||
organisationClaim={organisation.organisationClaim}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg border p-4">
|
<div className="mt-6 rounded-lg border p-4">
|
||||||
<Accordion type="single" collapsible>
|
<Accordion type="single" collapsible>
|
||||||
<AccordionItem value="global-settings" className="border-b-0">
|
<AccordionItem value="global-settings" className="border-b-0">
|
||||||
<AccordionTrigger className="py-0">
|
<AccordionTrigger className="py-0">
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-medium text-sm">
|
<p className="font-semibold text-base">
|
||||||
<Trans>Global Settings</Trans>
|
<Trans>Global Settings</Trans>
|
||||||
</p>
|
</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>
|
<Trans>Default settings applied to this organisation.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
className="mt-16"
|
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">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Subscription</Trans>
|
<Trans>Subscription</Trans>
|
||||||
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
{organisation.subscription ? (
|
{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>
|
<span>
|
||||||
<Trans>No subscription found</Trans>
|
<Trans>No subscription found</Trans>
|
||||||
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="bg-background"
|
||||||
loading={isCreatingStripeCustomer}
|
loading={isCreatingStripeCustomer}
|
||||||
onClick={async () => createStripeCustomer({ organisationId })}
|
onClick={async () => createStripeCustomer({ organisationId })}
|
||||||
>
|
>
|
||||||
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
{organisation.customerId && !organisation.subscription && (
|
{organisation.customerId && !organisation.subscription && (
|
||||||
<div>
|
<div>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" className="bg-background" asChild>
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
|
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
|
<AdminOrganisationSyncSubscriptionDialog
|
||||||
organisationId={organisationId}
|
organisationId={organisationId}
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="outline">
|
<Button variant="outline" className="bg-background">
|
||||||
<Trans>Sync Stripe subscription</Trans>
|
<Trans>Sync Stripe subscription</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" className="bg-background" asChild>
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
|
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 className="mt-16 space-y-10">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium text-sm leading-none">
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Organisation Members</Trans>
|
<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} />
|
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium text-sm leading-none">
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Organisation Teams</Trans>
|
<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} />
|
<DataTable columns={teamsColumns} data={organisation.teams} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
<FormLabel className="flex items-center">
|
<FormLabel className="flex items-center">
|
||||||
<Trans>Inherited subscription claim</Trans>
|
<Trans>Inherited subscription claim</Trans>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
|
||||||
<Input disabled {...field} />
|
{field.value ? (
|
||||||
</FormControl>
|
<span className="font-mono text-foreground">{field.value}</span>
|
||||||
<FormMessage />
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<Trans>No inherited claim</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="claims.teamCount"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="claims.teamCount"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
<Trans>Team Count</Trans>
|
<FormLabel>
|
||||||
</FormLabel>
|
<Trans>Team Count</Trans>
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
type="number"
|
<Input
|
||||||
min={0}
|
type="number"
|
||||||
{...field}
|
min={0}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
{...field}
|
||||||
/>
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
</FormControl>
|
/>
|
||||||
<FormDescription>
|
</FormControl>
|
||||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
<FormDescription>
|
||||||
</FormDescription>
|
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||||
<FormMessage />
|
</FormDescription>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.memberCount"
|
name="claims.memberCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Member Count</Trans>
|
<Trans>Member Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.envelopeItemCount"
|
name="claims.envelopeItemCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Envelope Item Count</Trans>
|
<Trans>Envelope Item Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.recipientCount"
|
name="claims.recipientCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Recipient Count</Trans>
|
<Trans>Recipient Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Feature Flags</Trans>
|
<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 }) => {
|
{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
|
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>
|
</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
|
<AdminGlobalSettingsSection
|
||||||
|
settings={team.teamGlobalSettings}
|
||||||
|
inheritedSettings={team.organisation.organisationGlobalSettings}
|
||||||
|
isTeam
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
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 { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
|
|
||||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
|
||||||
|
|
||||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
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) {
|
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
|
||||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty the branding logo if the user unsets it.
|
if (brandingLogo instanceof File) {
|
||||||
if (brandingLogo === null) {
|
formData.append('brandingLogo', brandingLogo);
|
||||||
uploadedBrandingLogo = '';
|
}
|
||||||
|
|
||||||
|
await updateOrganisationBrandingLogo(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateOrganisationSettings({
|
const result = await updateOrganisationSettings({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
data: {
|
data: {
|
||||||
brandingEnabled: brandingEnabled ?? undefined,
|
brandingEnabled: brandingEnabled ?? undefined,
|
||||||
brandingLogo: uploadedBrandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors,
|
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 { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
|
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -28,7 +29,6 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
OrganisationMembersMultiSelectCombobox,
|
OrganisationMembersMultiSelectCombobox,
|
||||||
} from '~/components/general/organisation-members-multiselect-combobox';
|
} from '~/components/general/organisation-members-multiselect-combobox';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
|
||||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||||
|
|
||||||
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
||||||
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ZUpdateOrganisationGroupFormSchema = z.object({
|
const ZUpdateOrganisationGroupFormSchema = z.object({
|
||||||
name: z.string().min(1, msg`Name is required`.id),
|
name: ZNameSchema,
|
||||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||||
memberIds: z.array(z.string()),
|
memberIds: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
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 { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -38,6 +37,7 @@ export default function TeamsSettingsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
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();
|
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||||
|
|
||||||
@@ -48,22 +48,23 @@ export default function TeamsSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
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) {
|
formData.append('payload', JSON.stringify({ teamId: team.id }));
|
||||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty the branding logo if the user unsets it.
|
if (brandingLogo instanceof File) {
|
||||||
if (brandingLogo === null) {
|
formData.append('brandingLogo', brandingLogo);
|
||||||
uploadedBrandingLogo = '';
|
}
|
||||||
|
|
||||||
|
await updateTeamBrandingLogo(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateTeamSettings({
|
const result = await updateTeamSettings({
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
data: {
|
data: {
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo: uploadedBrandingLogo,
|
|
||||||
brandingUrl: brandingUrl || null,
|
brandingUrl: brandingUrl || null,
|
||||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||||
brandingColors,
|
brandingColors,
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TGetApiTokensResponse } from '@documenso/trpc/server/api-token-router/get-api-tokens.types';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { TokenCreateDialog } from '~/components/dialogs/token-create-dialog';
|
||||||
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
|
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
|
||||||
import { ApiTokenForm } from '~/components/forms/token';
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@@ -18,33 +22,88 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ApiTokensPage() {
|
export default function ApiTokensPage() {
|
||||||
const { i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
const { data: tokens } = trpc.apiToken.getMany.useQuery();
|
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: tokens,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = trpc.apiToken.getMany.useQuery(undefined, {
|
||||||
|
enabled: !isUnauthorized,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: t`Name`,
|
||||||
|
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Created`,
|
||||||
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Expires`,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.expires) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<Trans>Never</Trans>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.original.expires < new Date()) {
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" size="small">
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return i18n.date(row.original.expires);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Actions`,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<TokenDeleteDialog token={row.original}>
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
</TokenDeleteDialog>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<TGetApiTokensResponse[number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={<Trans>API Tokens</Trans>}
|
title={<Trans>API Tokens</Trans>}
|
||||||
subtitle={
|
subtitle={
|
||||||
<Trans>
|
<Trans>
|
||||||
On this page, you can create and manage API tokens. See our{' '}
|
Create and manage API tokens. See our{' '}
|
||||||
<a
|
<a
|
||||||
className="text-primary underline"
|
className="text-primary underline"
|
||||||
href={'https://docs.documenso.com/developers/public-api'}
|
href={'https://docs.documenso.com/developers/public-api'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
Documentation
|
documentation
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
for more information.
|
for more information.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
{!isUnauthorized && <TokenCreateDialog />}
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
{team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? (
|
{isUnauthorized ? (
|
||||||
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
|
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
|
||||||
<div>
|
<div>
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
@@ -56,58 +115,43 @@ export default function ApiTokensPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<DataTable
|
||||||
<ApiTokenForm className="max-w-xl" tokens={tokens} />
|
columns={columns}
|
||||||
|
data={tokens ?? []}
|
||||||
<hr className="mt-8 mb-4" />
|
perPage={0}
|
||||||
|
currentPage={0}
|
||||||
<h4 className="font-medium text-xl">
|
totalPages={0}
|
||||||
<Trans>Your existing tokens</Trans>
|
error={{
|
||||||
</h4>
|
enable: isError,
|
||||||
|
}}
|
||||||
{tokens && tokens.length === 0 && (
|
emptyState={
|
||||||
<div className="mb-4">
|
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||||
<p className="mt-2 text-muted-foreground text-sm italic">
|
<p>
|
||||||
<Trans>Your tokens will be shown here once you create them.</Trans>
|
<Trans>You have no API tokens yet. Your tokens will be shown here once you create them.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
skeleton={{
|
||||||
{tokens && tokens.length > 0 && (
|
enable: isLoading,
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
rows: 3,
|
||||||
{tokens.map((token) => (
|
component: (
|
||||||
<div key={token.id} className="rounded-lg border border-border p-4">
|
<>
|
||||||
<div className="flex items-center justify-between gap-x-4">
|
<TableCell>
|
||||||
<div>
|
<Skeleton className="h-4 w-24 rounded-full" />
|
||||||
<h5 className="text-base">{token.name}</h5>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
<p className="mt-2 text-muted-foreground text-xs">
|
<Skeleton className="h-4 w-16 rounded-full" />
|
||||||
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
|
</TableCell>
|
||||||
</p>
|
<TableCell>
|
||||||
{token.expires ? (
|
<Skeleton className="h-4 w-16 rounded-full" />
|
||||||
<p className="mt-1 text-muted-foreground text-xs">
|
</TableCell>
|
||||||
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
|
<TableCell>
|
||||||
</p>
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
) : (
|
</TableCell>
|
||||||
<p className="mt-1 text-muted-foreground text-xs">
|
</>
|
||||||
<Trans>Token doesn't have an expiration date</Trans>
|
),
|
||||||
</p>
|
}}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<TokenDeleteDialog token={token}>
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</TokenDeleteDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function WaitingForTurnToSignPage({ loaderData }: Route.Component
|
|||||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||||
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||||
<div className="w-full max-w-md text-center">
|
<div className="w-full max-w-md text-center">
|
||||||
<h2 className="font-bold text-3xl tracking-tigh">
|
<h2 className="font-bold text-3xl tracking-tight">
|
||||||
<Trans>Waiting for Your Turn</Trans>
|
<Trans>Waiting for Your Turn</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
"@lingui/react": "^5.6.0",
|
"@lingui/react": "^5.6.0",
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
"@react-router/node": "^7.12.0",
|
"@react-router/node": "^7.18.1",
|
||||||
"@react-router/serve": "^7.12.0",
|
"@react-router/serve": "^7.18.1",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@tanstack/react-query": "5.90.10",
|
"@tanstack/react-query": "5.90.10",
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
|
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
|
||||||
"@lingui/vite-plugin": "^5.6.0",
|
"@lingui/vite-plugin": "^5.6.0",
|
||||||
"@react-router/dev": "^7.12.0",
|
"@react-router/dev": "^7.18.1",
|
||||||
"@react-router/remix-routes-option-adapter": "^7.12.0",
|
"@react-router/remix-routes-option-adapter": "^7.18.1",
|
||||||
"@rollup/plugin-babel": "^6.1.0",
|
"@rollup/plugin-babel": "^6.1.0",
|
||||||
"@rollup/plugin-commonjs": "^28.0.9",
|
"@rollup/plugin-commonjs": "^28.0.9",
|
||||||
"@rollup/plugin-json": "^6.1.0",
|
"@rollup/plugin-json": "^6.1.0",
|
||||||
@@ -100,11 +100,11 @@
|
|||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"remix-flat-routes": "^0.8.5",
|
"remix-flat-routes": "^0.8.5",
|
||||||
"rollup": "^4.53.3",
|
"rollup": "^4.53.3",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "2.14.0"
|
"version": "2.15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
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 { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
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 { prisma } from '@documenso/prisma';
|
||||||
import { sValidator } from '@hono/standard-validator';
|
import { sValidator } from '@hono/standard-validator';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
|
|||||||
import type { HonoEnv } from '../../router';
|
import type { HonoEnv } from '../../router';
|
||||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
||||||
import {
|
import {
|
||||||
isAllowedUploadContentType,
|
|
||||||
type TGetPresignedPostUrlResponse,
|
|
||||||
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileRequestParamsSchema,
|
ZGetEnvelopeItemFileRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileRequestQuerySchema,
|
ZGetEnvelopeItemFileRequestQuerySchema,
|
||||||
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
||||||
ZGetPresignedPostUrlRequestSchema,
|
|
||||||
ZUploadPdfRequestSchema,
|
ZUploadPdfRequestSchema,
|
||||||
} from './files.types';
|
} from './files.types';
|
||||||
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
|
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);
|
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(
|
.get(
|
||||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
||||||
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
||||||
|
|||||||
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
|
|||||||
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
||||||
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
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({
|
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
|
||||||
envelopeId: z.string().min(1),
|
envelopeId: z.string().min(1),
|
||||||
envelopeItemId: z.string().min(1),
|
envelopeItemId: z.string().min(1),
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
|
|||||||
|
|
||||||
// Files route.
|
// Files route.
|
||||||
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
||||||
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
|
|
||||||
app.route('/api/files', filesRoute);
|
app.route('/api/files', filesRoute);
|
||||||
|
|
||||||
// AI route.
|
// AI route.
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm install -g "turbo@^1.9.3"
|
RUN npm install -g "turbo@^2.10.0"
|
||||||
|
|
||||||
# Outputs to the /out folder
|
# Outputs to the /out folder
|
||||||
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
|
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
|
||||||
@@ -79,7 +79,7 @@ COPY --from=builder /app/out/full/ .
|
|||||||
# Finally copy the turbo.json file so that we can run turbo commands
|
# Finally copy the turbo.json file so that we can run turbo commands
|
||||||
COPY turbo.json turbo.json
|
COPY turbo.json turbo.json
|
||||||
|
|
||||||
RUN npm install -g "turbo@^1.9.3"
|
RUN npm install -g "turbo@^2.10.0"
|
||||||
|
|
||||||
RUN turbo run build --filter=@documenso/remix...
|
RUN turbo run build --filter=@documenso/remix...
|
||||||
|
|
||||||
|
|||||||
Generated
+4838
-2015
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -5,7 +5,7 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "2.14.0",
|
"version": "2.15.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
@@ -61,12 +61,13 @@
|
|||||||
"@ts-rest/serverless": "^3.52.1",
|
"@ts-rest/serverless": "^3.52.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
|
"esbuild": "^0.27.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"inngest": "^3.54.0",
|
"inngest": "^3.54.0",
|
||||||
"inngest-cli": "^1.17.9",
|
"inngest-cli": "^1.17.9",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^9.0.0",
|
||||||
"pdfjs-dist": "5.4.296",
|
"pdfjs-dist": "5.4.296",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
"rimraf": "^6.1.2",
|
"rimraf": "^6.1.2",
|
||||||
"superjson": "^2.2.5",
|
"superjson": "^2.2.5",
|
||||||
"syncpack": "^14.0.0-alpha.27",
|
"syncpack": "^14.0.0-alpha.27",
|
||||||
"turbo": "^1.13.4",
|
"turbo": "^2.10.0",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vite-plugin-static-copy": "^3.1.4",
|
"vite-plugin-static-copy": "^3.1.4",
|
||||||
"zod-openapi": "^4.2.4",
|
"zod-openapi": "^4.2.4",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"lodash": "4.18.1",
|
"lodash": "4.18.1",
|
||||||
"pdfjs-dist": "5.4.296",
|
"pdfjs-dist": "5.4.296",
|
||||||
|
"postcss": "^8.5.19",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "$zod",
|
"zod": "$zod",
|
||||||
"fumadocs-mdx": {
|
"fumadocs-mdx": {
|
||||||
|
|||||||
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
|
|||||||
expect(res.status()).toBe(401);
|
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 }) => {
|
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { expect, type Page, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clickAddSignerButton,
|
||||||
|
clickEnvelopeEditorStep,
|
||||||
|
getRecipientEmailInputs,
|
||||||
|
openDocumentEnvelopeEditor,
|
||||||
|
setRecipientEmail,
|
||||||
|
setRecipientName,
|
||||||
|
type TEnvelopeEditorSurface,
|
||||||
|
} from '../fixtures/envelope-editor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reproduction for the recipient autosave race condition.
|
||||||
|
*
|
||||||
|
* Symptom (production only, where there is real network lag):
|
||||||
|
* 1. The author adds a recipient and types its name/email.
|
||||||
|
* 2. They navigate to the "Add Fields" step.
|
||||||
|
* 3. The recipient selector shows the default "Recipient 1" placeholder
|
||||||
|
* instead of the recipient they just typed, and the typed name/email is
|
||||||
|
* silently lost.
|
||||||
|
*
|
||||||
|
* Theory (see packages/lib/client-only/hooks/use-envelope-autosave.ts):
|
||||||
|
* When the author navigates, `flushAutosave()` is awaited before the Add
|
||||||
|
* Fields page renders. If an *earlier* (empty) recipient save is still
|
||||||
|
* in-flight at that moment, `flush()` awaits that in-flight save and returns
|
||||||
|
* WITHOUT committing the newer typed data sitting in `lastArgsRef` (whose
|
||||||
|
* debounce timer it just cleared). The typed data is dropped, the empty
|
||||||
|
* recipient persists, and the selector renders "Recipient 1".
|
||||||
|
*
|
||||||
|
* This only happens when a save is still in-flight at navigation time, which is
|
||||||
|
* why it never reproduces locally (fast saves) but does on a laggy network.
|
||||||
|
*
|
||||||
|
* The test below simulates that lag by holding the first `envelope.recipient.set`
|
||||||
|
* request open. It asserts the CORRECT behaviour (typed recipient survives), so
|
||||||
|
* it is RED while the bug exists and GREEN once the autosave hook is fixed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RECIPIENT_SET_PROCEDURE = 'envelope.recipient.set';
|
||||||
|
|
||||||
|
// How long to hold the first recipient autosave "in-flight" to emulate prod lag.
|
||||||
|
const SIMULATED_NETWORK_LAG_MS = 5000;
|
||||||
|
|
||||||
|
const FIRST_RECIPIENT = {
|
||||||
|
name: 'Alice Author',
|
||||||
|
email: 'alice-autosave-race@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECOND_RECIPIENT = {
|
||||||
|
name: 'Bob Builder',
|
||||||
|
email: 'bob-autosave-race@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecipientSetLagHandle = {
|
||||||
|
/** Resolves the instant the first recipient.set request is in-flight on the client. */
|
||||||
|
firstRecipientSetInFlight: Promise<void>;
|
||||||
|
/** Raw request bodies of every recipient.set call we intercepted. */
|
||||||
|
recipientSetRequestBodies: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs a fake "production network lag" on the recipient autosave mutation.
|
||||||
|
*
|
||||||
|
* Only the FIRST recipient.set request is held open for `lagMs` (this is the save
|
||||||
|
* that must still be in-flight at navigation time for the race to occur). It
|
||||||
|
* resolves `firstRecipientSetInFlight` the instant it is intercepted so the test
|
||||||
|
* can keep typing while that save is pending. Subsequent recipient.set requests
|
||||||
|
* (e.g. the follow-up save the fixed hook issues) are forwarded immediately so the
|
||||||
|
* test does not pay the lag twice.
|
||||||
|
*/
|
||||||
|
const installRecipientSetLag = async (page: Page, lagMs: number): Promise<RecipientSetLagHandle> => {
|
||||||
|
let markFirstInFlight: () => void = () => {};
|
||||||
|
|
||||||
|
const firstRecipientSetInFlight = new Promise<void>((resolve) => {
|
||||||
|
markFirstInFlight = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientSetRequestBodies: string[] = [];
|
||||||
|
|
||||||
|
await page.route('**/api/trpc/**', async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
|
||||||
|
if (request.method() !== 'POST' || !request.url().includes(RECIPIENT_SET_PROCEDURE)) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callIndex = recipientSetRequestBodies.length + 1;
|
||||||
|
recipientSetRequestBodies.push(request.postData() ?? '');
|
||||||
|
|
||||||
|
if (callIndex === 1) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[test] holding first ${RECIPIENT_SET_PROCEDURE} for ${lagMs}ms (simulated network lag)`);
|
||||||
|
|
||||||
|
// The empty save is now in-flight from the client's perspective.
|
||||||
|
markFirstInFlight();
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, lagMs));
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[test] forwarding ${RECIPIENT_SET_PROCEDURE} #${callIndex} (no lag)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { firstRecipientSetInFlight, recipientSetRequestBodies };
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertEnvelopeRecipientsPersisted = async (surface: TEnvelopeEditorSurface) => {
|
||||||
|
if (!surface.envelopeId) {
|
||||||
|
throw new Error('Expected the document editor surface to have an envelopeId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: { id: surface.envelopeId },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistedEmails = envelope.recipients.map((recipient) => recipient.email).filter(Boolean);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
'[test] persisted recipients:',
|
||||||
|
JSON.stringify(
|
||||||
|
envelope.recipients.map((recipient) => ({ name: recipient.name, email: recipient.email })),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(persistedEmails).toContain(FIRST_RECIPIENT.email);
|
||||||
|
expect(persistedEmails).toContain(SECOND_RECIPIENT.email);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('envelope editor recipient autosave race (network lag)', () => {
|
||||||
|
test('document editor: typed recipient survives navigation to Add Fields', async ({ page }) => {
|
||||||
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
|
|
||||||
|
const { firstRecipientSetInFlight, recipientSetRequestBodies } = await installRecipientSetLag(
|
||||||
|
page,
|
||||||
|
SIMULATED_NETWORK_LAG_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Add a second signer row. A blank document already has one empty default
|
||||||
|
// signer, so this schedules an autosave of TWO empty recipients
|
||||||
|
// (name='' / email='') - this is the save that will be in-flight.
|
||||||
|
await clickAddSignerButton(surface.root);
|
||||||
|
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||||
|
|
||||||
|
// 2. Wait until that empty autosave is actually in-flight on the client. This
|
||||||
|
// is the precondition the bug needs: a slow save holding the autosave lock.
|
||||||
|
await firstRecipientSetInFlight;
|
||||||
|
|
||||||
|
// 3. The author now fills in the recipients they are adding.
|
||||||
|
await setRecipientName(surface.root, 0, FIRST_RECIPIENT.name);
|
||||||
|
await setRecipientEmail(surface.root, 0, FIRST_RECIPIENT.email);
|
||||||
|
await setRecipientName(surface.root, 1, SECOND_RECIPIENT.name);
|
||||||
|
await setRecipientEmail(surface.root, 1, SECOND_RECIPIENT.email);
|
||||||
|
|
||||||
|
// 4. Immediately navigate to Add Fields (before the typed data's debounce
|
||||||
|
// fires). flushAutosave() awaits the in-flight EMPTY save; with the bug
|
||||||
|
// present it returns without ever committing the typed data.
|
||||||
|
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||||
|
|
||||||
|
// 5. Wait for the Add Fields page to render (after the lagged flush resolves).
|
||||||
|
await expect(surface.root.getByText('Selected Recipient')).toBeVisible({
|
||||||
|
timeout: SIMULATED_NETWORK_LAG_MS + 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Diagnostics - the request bodies show what actually reached the server.
|
||||||
|
// Buggy: only the first (empty) save is ever sent. Fixed: a follow-up save
|
||||||
|
// carrying the typed recipients is sent too.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('\n===== AUTOSAVE RACE DIAGNOSTICS =====');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`recipient.set requests sent to server: ${recipientSetRequestBodies.length}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`server ever received "${FIRST_RECIPIENT.email}": ${recipientSetRequestBodies.some((body) => body.includes(FIRST_RECIPIENT.email))}`,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('=====================================\n');
|
||||||
|
|
||||||
|
// 6. THE USER-VISIBLE BUG: the selected recipient must be the one we typed
|
||||||
|
// (Alice), not the default "Recipient 1" placeholder.
|
||||||
|
const selectedRecipientSection = surface.root.locator('section').filter({ hasText: 'Selected Recipient' });
|
||||||
|
|
||||||
|
await expect(selectedRecipientSection.getByRole('combobox')).toContainText(FIRST_RECIPIENT.name);
|
||||||
|
|
||||||
|
// 7. THE DATA LOSS: the typed recipients must actually be persisted.
|
||||||
|
await assertEnvelopeRecipientsPersisted(surface);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||||
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.56.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.23.1",
|
||||||
"pixelmatch": "^7.1.0",
|
"pixelmatch": "^7.1.0",
|
||||||
"pngjs": "^7.0.0"
|
"pngjs": "^7.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"clean": "rimraf node_modules"
|
"clean": "rimraf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/nodemailer-resend": "4.0.0",
|
"@documenso/nodemailer-resend": "5.0.0",
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@react-email/body": "0.2.0",
|
"@react-email/body": "0.2.0",
|
||||||
"@react-email/button": "0.2.0",
|
"@react-email/button": "0.2.0",
|
||||||
@@ -38,12 +38,12 @@
|
|||||||
"@react-email/section": "0.0.16",
|
"@react-email/section": "0.0.16",
|
||||||
"@react-email/tailwind": "^2.0.1",
|
"@react-email/tailwind": "^2.0.1",
|
||||||
"@react-email/text": "0.1.5",
|
"@react-email/text": "0.1.5",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^9.0.0",
|
||||||
"react-email": "^5.0.6",
|
"react-email": "^5.0.6",
|
||||||
"resend": "^6.5.2"
|
"resend": "^6.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tsconfig": "*",
|
"@documenso/tsconfig": "*",
|
||||||
"@types/nodemailer": "^8.0.0"
|
"@types/nodemailer": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
|
|||||||
|
|
||||||
<Section className="mt-8 mb-6 text-center">
|
<Section className="mt-8 mb-6 text-center">
|
||||||
<Button
|
<Button
|
||||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sbase no-underline"
|
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-base text-primary-foreground no-underline"
|
||||||
href={signDocumentLink}
|
href={signDocumentLink}
|
||||||
>
|
>
|
||||||
{match(role)
|
{match(role)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { SentMessageInfo, Transport } from 'nodemailer';
|
|||||||
import type { Address } from 'nodemailer/lib/mailer';
|
import type { Address } from 'nodemailer/lib/mailer';
|
||||||
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
|
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
|
||||||
|
|
||||||
|
import { normalizeMailHeaders } from './normalize-headers';
|
||||||
|
|
||||||
const VERSION = '1.0.0';
|
const VERSION = '1.0.0';
|
||||||
|
|
||||||
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
|
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
|
||||||
@@ -54,6 +56,7 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
|
|||||||
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
|
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
|
||||||
|
|
||||||
const [from] = this.toMailChannelsAddresses(mail.data.from);
|
const [from] = this.toMailChannelsAddresses(mail.data.from);
|
||||||
|
const [replyTo] = this.toMailChannelsAddresses(mail.data.replyTo);
|
||||||
|
|
||||||
if (!from) {
|
if (!from) {
|
||||||
return callback(new Error('Missing required field "from"'), null);
|
return callback(new Error('Missing required field "from"'), null);
|
||||||
@@ -72,6 +75,8 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
|
|||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from: from,
|
from: from,
|
||||||
|
reply_to: replyTo,
|
||||||
|
headers: normalizeMailHeaders(mail.data.headers),
|
||||||
subject: mail.data.subject,
|
subject: mail.data.subject,
|
||||||
personalizations: [
|
personalizations: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type Mail from 'nodemailer/lib/mailer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes nodemailer mail headers into the flat `Record<string, string>`
|
||||||
|
* shape accepted by HTTP email APIs such as Resend and MailChannels.
|
||||||
|
*
|
||||||
|
* Kept in sync with `toResendHeaders` in the `@documenso/nodemailer-resend`
|
||||||
|
* package, which applies the same normalization for the Resend transport.
|
||||||
|
*/
|
||||||
|
export const normalizeMailHeaders = (headers: Mail.Options['headers']): Record<string, string> | undefined => {
|
||||||
|
if (!headers) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
|
||||||
|
const appendHeader = (key: string, value: unknown) => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringValue = String(value);
|
||||||
|
|
||||||
|
normalized[key] = normalized[key] ? `${normalized[key]}, ${stringValue}` : stringValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(headers)) {
|
||||||
|
for (const { key, value } of headers) {
|
||||||
|
appendHeader(key, value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
appendHeader(key, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
appendHeader(key, value.value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendHeader(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(normalized).length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
@@ -1,84 +1,100 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced autosave for the envelope editor (recipients, fields, settings).
|
||||||
|
*
|
||||||
|
* Only one save runs at a time and the latest edit always wins. If the user
|
||||||
|
* keeps editing while a save is on the wire, their newest changes get saved
|
||||||
|
* right after, never dropped.
|
||||||
|
*/
|
||||||
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
|
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastArgsRef = useRef<T | null>(null);
|
|
||||||
const pendingPromiseRef = useRef<Promise<void> | null>(null);
|
// The edit waiting to be saved. Wrapped in an object so null always means "nothing queued".
|
||||||
|
const pendingRef = useRef<{ value: T } | null>(null);
|
||||||
|
|
||||||
|
// The save currently running, if any. Shared so we never kick off two at once.
|
||||||
|
const commitPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
// saveFn closes over editor state, so keep the latest one around without
|
||||||
|
// making triggerSave/flush depend on it.
|
||||||
|
const saveFnRef = useRef(saveFn);
|
||||||
|
saveFnRef.current = saveFn;
|
||||||
|
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [isCommiting, setIsCommiting] = useState(false);
|
const [isCommiting, setIsCommiting] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs saves one at a time until the queue is empty. Anything queued
|
||||||
|
* mid-save gets picked up on the next loop.
|
||||||
|
*/
|
||||||
|
const commit = useCallback((): Promise<void> => {
|
||||||
|
if (commitPromiseRef.current) {
|
||||||
|
return commitPromiseRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingRef.current) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pump = (async () => {
|
||||||
|
try {
|
||||||
|
setIsCommiting(true);
|
||||||
|
|
||||||
|
while (pendingRef.current) {
|
||||||
|
const { value } = pendingRef.current;
|
||||||
|
pendingRef.current = null;
|
||||||
|
|
||||||
|
await saveFnRef.current(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// eslint-disable-next-line require-atomic-updates
|
||||||
|
commitPromiseRef.current = null;
|
||||||
|
setIsCommiting(false);
|
||||||
|
setIsPending(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
commitPromiseRef.current = pump;
|
||||||
|
|
||||||
|
return pump;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const triggerSave = useCallback(
|
const triggerSave = useCallback(
|
||||||
(data: T) => {
|
(data: T) => {
|
||||||
lastArgsRef.current = data;
|
pendingRef.current = { value: data };
|
||||||
|
|
||||||
// A debounce or promise means something is pending
|
|
||||||
setIsPending(true);
|
setIsPending(true);
|
||||||
|
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
timeoutRef.current = setTimeout(() => {
|
||||||
timeoutRef.current = setTimeout(async () => {
|
|
||||||
if (!lastArgsRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = lastArgsRef.current;
|
|
||||||
lastArgsRef.current = null;
|
|
||||||
timeoutRef.current = null;
|
timeoutRef.current = null;
|
||||||
|
void commit();
|
||||||
setIsCommiting(true);
|
|
||||||
pendingPromiseRef.current = saveFn(args);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pendingPromiseRef.current;
|
|
||||||
} finally {
|
|
||||||
// eslint-disable-next-line require-atomic-updates
|
|
||||||
pendingPromiseRef.current = null;
|
|
||||||
setIsCommiting(false);
|
|
||||||
setIsPending(false);
|
|
||||||
}
|
|
||||||
}, delay);
|
}, delay);
|
||||||
},
|
},
|
||||||
[saveFn, delay],
|
[commit, delay],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip the debounce and save now. The editor calls this when it needs
|
||||||
|
* everything persisted, e.g. before sending or switching steps.
|
||||||
|
*/
|
||||||
const flush = useCallback(async () => {
|
const flush = useCallback(async () => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
timeoutRef.current = null;
|
timeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingPromiseRef.current) {
|
await commit();
|
||||||
// Already running → wait for it
|
}, [commit]);
|
||||||
await pendingPromiseRef.current;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastArgsRef.current) {
|
|
||||||
const args = lastArgsRef.current;
|
|
||||||
lastArgsRef.current = null;
|
|
||||||
|
|
||||||
setIsCommiting(true);
|
|
||||||
setIsPending(true);
|
|
||||||
|
|
||||||
pendingPromiseRef.current = saveFn(args);
|
|
||||||
try {
|
|
||||||
await pendingPromiseRef.current;
|
|
||||||
} finally {
|
|
||||||
// eslint-disable-next-line require-atomic-updates
|
|
||||||
pendingPromiseRef.current = null;
|
|
||||||
setIsCommiting(false);
|
|
||||||
setIsPending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [saveFn]);
|
|
||||||
|
|
||||||
|
// Last-ditch attempt to save if the tab closes with unsaved edits.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
if (timeoutRef.current || pendingPromiseRef.current) {
|
if (timeoutRef.current || pendingRef.current || commitPromiseRef.current) {
|
||||||
void flush();
|
void flush();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
import MailChecker from 'mailchecker';
|
import MailChecker from 'mailchecker';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { env } from '../utils/env';
|
import { env } from '../utils/env';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
||||||
|
|
||||||
export const SALT_ROUNDS = 12;
|
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> = {
|
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
||||||
DOCUMENSO: 'Documenso',
|
DOCUMENSO: 'Documenso',
|
||||||
GOOGLE: 'Google',
|
GOOGLE: 'Google',
|
||||||
|
|||||||
@@ -9,3 +9,13 @@
|
|||||||
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
||||||
*/
|
*/
|
||||||
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
|
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'];
|
||||||
|
|||||||
@@ -60,8 +60,8 @@
|
|||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
"playwright": "1.56.1",
|
"playwright": "1.56.1",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.19",
|
||||||
"postcss-selector-parser": "^7.1.1",
|
"postcss-selector-parser": "^7.1.4",
|
||||||
"posthog-js": "^1.297.2",
|
"posthog-js": "^1.297.2",
|
||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -57,7 +57,10 @@ export const UNSAFE_createEnvelopeItems = async ({
|
|||||||
flattenForm: envelope.type !== 'TEMPLATE',
|
flattenForm: envelope.type !== 'TEMPLATE',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
|
||||||
const { documentData } = await putPdfFileServerSide({
|
const { documentData } = await putPdfFileServerSide({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
|
|||||||
flattenForm: envelope.type !== 'TEMPLATE',
|
flattenForm: envelope.type !== 'TEMPLATE',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
fileName: data.file.name,
|
||||||
|
});
|
||||||
|
|
||||||
// Upload the new PDF and get a new DocumentData record.
|
// Upload the new PDF and get a new DocumentData record.
|
||||||
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
|
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
import { logger } from '@documenso/lib/utils/logger';
|
||||||
import { PDF, rgb } from '@libpdf/core';
|
import { PDF, rgb } from '@libpdf/core';
|
||||||
import type { FieldType, Recipient } from '@prisma/client';
|
import type { FieldType, Recipient } from '@prisma/client';
|
||||||
|
|
||||||
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
|
import {
|
||||||
|
parseFieldMetaFromPlaceholder,
|
||||||
|
parseFieldTypeFromPlaceholder,
|
||||||
|
parsePlaceholderData,
|
||||||
|
parseRawFieldMetaFromPlaceholder,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
|
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
|
||||||
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
||||||
@@ -61,7 +68,15 @@ export type FieldToCreate = TFieldAndMeta & {
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
type ExtractPlaceholdersLogContext = {
|
||||||
|
envelopeId?: string;
|
||||||
|
fileName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractPlaceholdersFromPDF = async (
|
||||||
|
pdf: Buffer,
|
||||||
|
logContext?: ExtractPlaceholdersLogContext,
|
||||||
|
): Promise<PlaceholderInfo[]> => {
|
||||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||||
|
|
||||||
const placeholders: PlaceholderInfo[] = [];
|
const placeholders: PlaceholderInfo[] = [];
|
||||||
@@ -85,7 +100,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
|
const placeholderData = parsePlaceholderData(innerMatch[1]);
|
||||||
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
|
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
|
||||||
|
|
||||||
let fieldType: FieldType;
|
let fieldType: FieldType;
|
||||||
@@ -109,14 +124,51 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
|||||||
|
|
||||||
const recipient = recipientOrMeta;
|
const recipient = recipientOrMeta;
|
||||||
|
|
||||||
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
|
/*
|
||||||
|
Parse and validate the field metadata. A malformed selection placeholder
|
||||||
|
(e.g. an unknown validation rule or a default value that doesn't match an
|
||||||
|
option) is skipped like an invalid field type rather than aborting the whole
|
||||||
|
upload, which may contain other valid placeholders and files.
|
||||||
|
*/
|
||||||
|
let fieldAndMeta: TFieldAndMeta;
|
||||||
|
|
||||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
try {
|
||||||
|
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
||||||
|
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||||
|
|
||||||
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
|
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
|
||||||
type: fieldType,
|
type: fieldType,
|
||||||
fieldMeta: parsedFieldMeta,
|
fieldMeta: parsedFieldMeta,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Surface schema failures as INVALID_BODY (400) instead of letting the raw
|
||||||
|
ZodError bubble up to the caller as an INTERNAL_SERVER_ERROR (500).
|
||||||
|
*/
|
||||||
|
if (!parsedFieldAndMeta.success) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Invalid field metadata for placeholder "${placeholder}": ${parsedFieldAndMeta.error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldAndMeta = parsedFieldAndMeta.data;
|
||||||
|
} catch (error) {
|
||||||
|
const appError = AppError.parseError(error);
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
envelopeId: logContext?.envelopeId,
|
||||||
|
fileName: logContext?.fileName,
|
||||||
|
placeholder,
|
||||||
|
page: page.index + 1,
|
||||||
|
code: appError.code,
|
||||||
|
message: appError.message,
|
||||||
|
},
|
||||||
|
'Skipping placeholder with invalid field metadata',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
LibPDF returns bbox in points with bottom-left origin.
|
LibPDF returns bbox in points with bottom-left origin.
|
||||||
@@ -182,8 +234,9 @@ export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: Plac
|
|||||||
*/
|
*/
|
||||||
export const extractPdfPlaceholders = async (
|
export const extractPdfPlaceholders = async (
|
||||||
pdf: Buffer,
|
pdf: Buffer,
|
||||||
|
logContext?: ExtractPlaceholdersLogContext,
|
||||||
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
|
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
|
||||||
|
|
||||||
if (placeholders.length === 0) {
|
if (placeholders.length === 0) {
|
||||||
return { cleanedPdf: pdf, placeholders: [] };
|
return { cleanedPdf: pdf, placeholders: [] };
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import {
|
||||||
|
parseFieldMetaFromPlaceholder,
|
||||||
|
parseFieldTypeFromPlaceholder,
|
||||||
|
parsePlaceholderData,
|
||||||
|
parseRawFieldMetaFromPlaceholder,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
const expectInvalidBody = (fn: () => unknown) => {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
expect.unreachable('Expected an AppError to be thrown');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(AppError);
|
||||||
|
expect((error as AppError).code).toBe(AppErrorCode.INVALID_BODY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseFieldTypeFromPlaceholder function', () => {
|
||||||
|
it('maps known field type strings to the FieldType enum', () => {
|
||||||
|
expect(parseFieldTypeFromPlaceholder('signature')).toBe(FieldType.SIGNATURE);
|
||||||
|
expect(parseFieldTypeFromPlaceholder('radio')).toBe(FieldType.RADIO);
|
||||||
|
expect(parseFieldTypeFromPlaceholder('checkbox')).toBe(FieldType.CHECKBOX);
|
||||||
|
expect(parseFieldTypeFromPlaceholder('dropdown')).toBe(FieldType.DROPDOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive and trims surrounding whitespace', () => {
|
||||||
|
expect(parseFieldTypeFromPlaceholder(' SiGnAtUrE ')).toBe(FieldType.SIGNATURE);
|
||||||
|
expect(parseFieldTypeFromPlaceholder('RADIO')).toBe(FieldType.RADIO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws INVALID_BODY for an unknown field type', () => {
|
||||||
|
expectInvalidBody(() => parseFieldTypeFromPlaceholder('FILE'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsePlaceholderData function', () => {
|
||||||
|
it('splits top-level parts on commas and trims each token', () => {
|
||||||
|
expect(parsePlaceholderData('SIGNATURE, r1, required=true')).toEqual(['SIGNATURE', 'r1', 'required=true']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not split on escaped commas', () => {
|
||||||
|
expect(parsePlaceholderData('dropdown, r1, options=Legal\\, Compliance|Sales')).toEqual([
|
||||||
|
'dropdown',
|
||||||
|
'r1',
|
||||||
|
'options=Legal\\, Compliance|Sales',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRawFieldMetaFromPlaceholder function', () => {
|
||||||
|
it('splits each token into a key/value entry', () => {
|
||||||
|
expect(parseRawFieldMetaFromPlaceholder(['required=true', 'fontSize=12'])).toEqual({
|
||||||
|
required: 'true',
|
||||||
|
fontSize: '12',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only splits on the first unescaped equals sign', () => {
|
||||||
|
expect(parseRawFieldMetaFromPlaceholder(['label=a=b'])).toEqual({ label: 'a=b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops tokens without a value and overwrites duplicate keys with the last', () => {
|
||||||
|
expect(parseRawFieldMetaFromPlaceholder(['required', 'fontSize=12', 'fontSize=14'])).toEqual({
|
||||||
|
fontSize: '14',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFieldMetaFromPlaceholder function', () => {
|
||||||
|
describe('non-field-meta cases', () => {
|
||||||
|
it('returns undefined for signature and free signature fields', () => {
|
||||||
|
expect(parseFieldMetaFromPlaceholder({ required: 'true' }, FieldType.SIGNATURE)).toBeUndefined();
|
||||||
|
expect(parseFieldMetaFromPlaceholder({}, FieldType.FREE_SIGNATURE)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when there is no metadata', () => {
|
||||||
|
expect(parseFieldMetaFromPlaceholder({}, FieldType.TEXT)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generic metadata', () => {
|
||||||
|
it('coerces required/readOnly to booleans (case-insensitive)', () => {
|
||||||
|
expect(parseFieldMetaFromPlaceholder({ required: 'TRUE', readOnly: 'false' }, FieldType.TEXT)).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
readOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces numeric properties to numbers', () => {
|
||||||
|
expect(parseFieldMetaFromPlaceholder({ fontSize: '14' }, FieldType.TEXT)).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
fontSize: 14,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops numeric properties that are not a number', () => {
|
||||||
|
const parsed = parseFieldMetaFromPlaceholder({ fontSize: 'abc' }, FieldType.TEXT);
|
||||||
|
|
||||||
|
expect(parsed).toEqual({ type: 'text' });
|
||||||
|
expect(parsed).not.toHaveProperty('fontSize');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps label/placeholder for non-selection fields', () => {
|
||||||
|
expect(parseFieldMetaFromPlaceholder({ label: 'Company Name', placeholder: 'Acme' }, FieldType.TEXT)).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
label: 'Company Name',
|
||||||
|
placeholder: 'Acme',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('radio fields', () => {
|
||||||
|
it('builds stable values from options', () => {
|
||||||
|
expect(parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe' }, FieldType.RADIO)).toEqual({
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Yes' },
|
||||||
|
{ id: 2, checked: false, value: 'No' },
|
||||||
|
{ id: 3, checked: false, value: 'Maybe' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only the selected option as checked', () => {
|
||||||
|
const parsed = parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe', selected: 'No' }, FieldType.RADIO);
|
||||||
|
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Yes' },
|
||||||
|
{ id: 2, checked: true, value: 'No' },
|
||||||
|
{ id: 3, checked: false, value: 'Maybe' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when a default value is provided without options', () => {
|
||||||
|
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ selected: 'No' }, FieldType.RADIO));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the default value does not match an option', () => {
|
||||||
|
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'Yes|No', selected: 'Maybe' }, FieldType.RADIO));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when options is empty', () => {
|
||||||
|
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: '' }, FieldType.RADIO));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkbox fields', () => {
|
||||||
|
it('builds values with checked state, validation rule alias and length', () => {
|
||||||
|
const parsed = parseFieldMetaFromPlaceholder(
|
||||||
|
{
|
||||||
|
options: 'Email|SMS|Phone',
|
||||||
|
checked: 'Email|Phone',
|
||||||
|
validationRule: 'atLeast',
|
||||||
|
validationLength: '1',
|
||||||
|
},
|
||||||
|
FieldType.CHECKBOX,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
type: 'checkbox',
|
||||||
|
validationRule: 'Select at least',
|
||||||
|
validationLength: 1,
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: true, value: 'Email' },
|
||||||
|
{ id: 2, checked: false, value: 'SMS' },
|
||||||
|
{ id: 3, checked: true, value: 'Phone' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for an unknown validation rule', () => {
|
||||||
|
expectInvalidBody(() =>
|
||||||
|
parseFieldMetaFromPlaceholder({ options: 'A|B', validationRule: 'nope' }, FieldType.CHECKBOX),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when checked values are provided without options', () => {
|
||||||
|
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ checked: 'A' }, FieldType.CHECKBOX));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when a checked value does not match an option', () => {
|
||||||
|
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', checked: 'C' }, FieldType.CHECKBOX));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dropdown fields', () => {
|
||||||
|
it('builds values and sets a matching default value', () => {
|
||||||
|
expect(
|
||||||
|
parseFieldMetaFromPlaceholder(
|
||||||
|
{ options: 'United States|Canada|United Kingdom', defaultValue: 'Canada' },
|
||||||
|
FieldType.DROPDOWN,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: 'dropdown',
|
||||||
|
values: [{ value: 'United States' }, { value: 'Canada' }, { value: 'United Kingdom' }],
|
||||||
|
defaultValue: 'Canada',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the default value does not match an option', () => {
|
||||||
|
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', defaultValue: 'C' }, FieldType.DROPDOWN));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selection field options parsing', () => {
|
||||||
|
it('trims option values and drops empty entries', () => {
|
||||||
|
expect(parseFieldMetaFromPlaceholder({ options: ' A || B ' }, FieldType.DROPDOWN)).toEqual({
|
||||||
|
type: 'dropdown',
|
||||||
|
values: [{ value: 'A' }, { value: 'B' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses escaped delimiters through the full placeholder pipeline', () => {
|
||||||
|
const [, , ...fieldMetaData] = parsePlaceholderData(
|
||||||
|
'dropdown, r1, options=Sales\\|Ops|Legal\\, Compliance|A\\=B',
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
||||||
|
const parsed = parseFieldMetaFromPlaceholder(rawFieldMeta, FieldType.DROPDOWN);
|
||||||
|
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
type: 'dropdown',
|
||||||
|
values: [{ value: 'Sales|Ops' }, { value: 'Legal, Compliance' }, { value: 'A=B' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,134 @@ type RecipientPlaceholderInfo = {
|
|||||||
recipientIndex: number;
|
recipientIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CHECKBOX_VALIDATION_RULE_BY_ALIAS: Record<string, string> = {
|
||||||
|
atLeast: 'Select at least',
|
||||||
|
exactly: 'Select exactly',
|
||||||
|
atMost: 'Select at most',
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Split a string on a delimiter, treating `\` as an escape for the next character.
|
||||||
|
Delimiters preceded by `\` are kept in the output instead of splitting (e.g. `\,`, `\=`, `\|`).
|
||||||
|
|
||||||
|
With delimiter ',' (top-level placeholder parts):
|
||||||
|
'radio, r1, options=Card/Check|Bank Transfer, selected=Bank Transfer'
|
||||||
|
-> ['radio', ' r1', ' options=Card/Check|Bank Transfer', ' selected=Bank Transfer']
|
||||||
|
|
||||||
|
With delimiter '=' (split one field metadata token into key + value):
|
||||||
|
'options=Card/Check|Bank Transfer'
|
||||||
|
-> ['options', 'Card/Check|Bank Transfer']
|
||||||
|
|
||||||
|
With delimiter '|' (split option list inside 'options='):
|
||||||
|
'Card/Check|Bank Transfer'
|
||||||
|
-> ['Card/Check', 'Bank Transfer']
|
||||||
|
*/
|
||||||
|
const splitPlaceholderToken = (value: string, delimiter: string): string[] => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let currentPart = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < value.length; index++) {
|
||||||
|
const char = value[index];
|
||||||
|
const nextChar = value[index + 1];
|
||||||
|
|
||||||
|
if (char === '\\' && nextChar) {
|
||||||
|
currentPart += char + nextChar;
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === delimiter) {
|
||||||
|
parts.push(currentPart);
|
||||||
|
currentPart = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPart += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(currentPart);
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Removes the escape backslashes left over after splitting, so \,=, \|, \\ become their literal characters.
|
||||||
|
|
||||||
|
E.g.
|
||||||
|
'Legal\, Compliance' -> 'Legal, Compliance'
|
||||||
|
'Card\|Check' -> 'Card|Check'
|
||||||
|
'A\=B' -> 'A=B'
|
||||||
|
'C\D' -> 'C\D'
|
||||||
|
*/
|
||||||
|
const unescapePlaceholderValue = (value: string): string => {
|
||||||
|
return value.replace(/\\([,=|\\])/g, '$1');
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Cleans up a selection option/default after splitting:
|
||||||
|
unescapes literal delimiters, collapses repeated whitespace, and trims the ends.
|
||||||
|
|
||||||
|
E.g.
|
||||||
|
' Legal\, Compliance ' -> 'Legal, Compliance'
|
||||||
|
*/
|
||||||
|
const normalizePlaceholderSelectionValue = (value: string): string => {
|
||||||
|
return unescapePlaceholderValue(value).replace(/\s+/g, ' ').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Split an options string into individual choices.
|
||||||
|
Splits on unescaped '|', then unescapes, trims, and drops empty entries.
|
||||||
|
|
||||||
|
E.g.
|
||||||
|
'Card/Check|Bank Transfer' -> ['Card/Check', 'Bank Transfer']
|
||||||
|
'Card\\|Check|Bank Transfer' -> ['Card|Check', 'Bank Transfer']
|
||||||
|
*/
|
||||||
|
const parsePlaceholderOptions = (value: string): string[] => {
|
||||||
|
return splitPlaceholderToken(value, '|')
|
||||||
|
.map((option) => normalizePlaceholderSelectionValue(option))
|
||||||
|
.filter((option) => option.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Split a placeholder string into top-level parts (field type, recipient, metadata).
|
||||||
|
Splits on unescaped commas, then trims whitespace.
|
||||||
|
|
||||||
|
E.g.
|
||||||
|
'SIGNATURE, r1, required=true'
|
||||||
|
-> ['SIGNATURE', 'r1', 'required=true']
|
||||||
|
*/
|
||||||
|
export const parsePlaceholderData = (value: string): string[] => {
|
||||||
|
return splitPlaceholderToken(value, ',').map((token) => token.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Transforms the field metadata string array into a record of key/value pairs.
|
||||||
|
Each token is split on the first unescaped '='; tokens with no key or no '=' are dropped.
|
||||||
|
|
||||||
|
E.g.
|
||||||
|
['required=true', 'fontSize=12', 'label=a=b']
|
||||||
|
-> { required: 'true', fontSize: '12', label: 'a=b' }
|
||||||
|
*/
|
||||||
|
export const parseRawFieldMetaFromPlaceholder = (fieldMetaData: string[]): Record<string, string> => {
|
||||||
|
const rawFieldMeta: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const fieldMeta of fieldMetaData) {
|
||||||
|
// Split on the first '=' only; any further '=' stays part of the value (e.g. 'label=a=b').
|
||||||
|
const [rawKey, ...valueParts] = splitPlaceholderToken(fieldMeta, '=');
|
||||||
|
|
||||||
|
if (!rawKey || valueParts.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = rawKey.trim();
|
||||||
|
const value = valueParts.join('=').trim();
|
||||||
|
|
||||||
|
rawFieldMeta[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawFieldMeta;
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Parse field type string to FieldType enum.
|
Parse field type string to FieldType enum.
|
||||||
Normalizes the input (uppercase, trim) and validates it's a valid field type.
|
Normalizes the input (uppercase, trim) and validates it's a valid field type.
|
||||||
@@ -72,6 +200,169 @@ export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldTyp
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDefaultFieldMetaValue = (rawFieldMeta: Record<string, string>) => {
|
||||||
|
const defaultValue = rawFieldMeta.defaultValue ?? rawFieldMeta.default ?? rawFieldMeta.selected;
|
||||||
|
|
||||||
|
return defaultValue ? normalizePlaceholderSelectionValue(defaultValue) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCheckboxValidationRule = (value: string): string => {
|
||||||
|
const validationRule = CHECKBOX_VALIDATION_RULE_BY_ALIAS[value];
|
||||||
|
|
||||||
|
if (!validationRule) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Invalid checkbox placeholder validation rule: ${value}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return validationRule;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSelectionFieldOptions = (
|
||||||
|
rawFieldMeta: Record<string, string>,
|
||||||
|
fieldType: FieldType,
|
||||||
|
): string[] | undefined => {
|
||||||
|
const rawOptions = rawFieldMeta.options;
|
||||||
|
|
||||||
|
if (rawOptions === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedOptions = parsePlaceholderOptions(rawOptions);
|
||||||
|
|
||||||
|
if (parsedOptions.length === 0) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `${fieldType} placeholder options must contain at least one value`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRadioFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
||||||
|
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.RADIO);
|
||||||
|
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
|
||||||
|
|
||||||
|
if (!options && defaultValue) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: 'Radio placeholder default value requires options',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOptionIndex = defaultValue ? options.findIndex((option) => option === defaultValue) : -1;
|
||||||
|
|
||||||
|
if (defaultValue && selectedOptionIndex === -1) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Radio placeholder default value "${defaultValue}" must match one of the options`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedFieldMeta.values = options.map((option, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
checked: index === selectedOptionIndex,
|
||||||
|
value: option,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCheckboxFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
||||||
|
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.CHECKBOX);
|
||||||
|
const checkedValues = rawFieldMeta.checked ? parsePlaceholderOptions(rawFieldMeta.checked) : [];
|
||||||
|
|
||||||
|
if (!options && checkedValues.length > 0) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: 'Checkbox placeholder checked values require options',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmatchedCheckedValues = checkedValues.filter((checkedValue) => !options.includes(checkedValue));
|
||||||
|
|
||||||
|
if (unmatchedCheckedValues.length > 0) {
|
||||||
|
const unmatchedCheckedValue = unmatchedCheckedValues[0];
|
||||||
|
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: [`Checkbox placeholder checked value "${unmatchedCheckedValue}"`, 'must match one of the options'].join(
|
||||||
|
' ',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedFieldMeta.values = options.map((option, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
checked: checkedValues.includes(option),
|
||||||
|
value: option,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDropdownFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
||||||
|
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.DROPDOWN);
|
||||||
|
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
|
||||||
|
|
||||||
|
if (!options && defaultValue) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: 'Dropdown placeholder default value requires options',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultValue && !options.includes(defaultValue)) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Dropdown placeholder default value "${defaultValue}" must match one of the options`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedFieldMeta.values = options.map((option) => ({
|
||||||
|
value: option,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (defaultValue) {
|
||||||
|
parsedFieldMeta.defaultValue = defaultValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Generic field metadata properties are simple properties consisting of a key and a value.
|
||||||
|
E.g. 'required=true', 'fontSize=12', 'textAlign=left'
|
||||||
|
They don't require special handling.
|
||||||
|
|
||||||
|
Special field metadata properties are complex properties consisting of a key and a value with multiple parts.
|
||||||
|
E.g. 'options=Card/Check|Bank Transfer', 'checked=Card|Check', 'selected=Bank Transfer'
|
||||||
|
They require special handling.
|
||||||
|
*/
|
||||||
|
const shouldSkipGenericFieldMetaParsing = (property: string, fieldType: FieldType): boolean => {
|
||||||
|
if (property === 'options' || property === 'default' || property === 'selected') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelectionField =
|
||||||
|
fieldType === FieldType.CHECKBOX || fieldType === FieldType.RADIO || fieldType === FieldType.DROPDOWN;
|
||||||
|
|
||||||
|
if (!isSelectionField) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
property === 'label' ||
|
||||||
|
property === 'placeholder' ||
|
||||||
|
property === 'defaultValue' ||
|
||||||
|
(fieldType === FieldType.CHECKBOX && property === 'checked')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Transform raw field metadata from placeholder format to schema format.
|
Transform raw field metadata from placeholder format to schema format.
|
||||||
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
||||||
@@ -91,7 +382,7 @@ export const parseFieldMetaFromPlaceholder = (
|
|||||||
|
|
||||||
const fieldTypeString = String(fieldType).toLowerCase();
|
const fieldTypeString = String(fieldType).toLowerCase();
|
||||||
|
|
||||||
const parsedFieldMeta: Record<string, boolean | number | string> = {
|
const parsedFieldMeta: Record<string, unknown> = {
|
||||||
type: fieldTypeString,
|
type: fieldTypeString,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,24 +395,39 @@ export const parseFieldMetaFromPlaceholder = (
|
|||||||
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
|
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
|
||||||
|
|
||||||
for (const [property, value] of rawFieldMetaEntries) {
|
for (const [property, value] of rawFieldMetaEntries) {
|
||||||
|
if (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unescapedValue = unescapePlaceholderValue(value);
|
||||||
|
|
||||||
if (property === 'readOnly' || property === 'required') {
|
if (property === 'readOnly' || property === 'required') {
|
||||||
parsedFieldMeta[property] = value === 'true';
|
parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
|
||||||
|
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
|
||||||
|
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
|
||||||
} else if (
|
} else if (
|
||||||
property === 'fontSize' ||
|
property === 'fontSize' ||
|
||||||
property === 'maxValue' ||
|
property === 'maxValue' ||
|
||||||
property === 'minValue' ||
|
property === 'minValue' ||
|
||||||
property === 'characterLimit'
|
property === 'characterLimit' ||
|
||||||
|
property === 'validationLength'
|
||||||
) {
|
) {
|
||||||
const numValue = Number(value);
|
const numValue = Number(unescapedValue);
|
||||||
|
|
||||||
if (!Number.isNaN(numValue)) {
|
if (!Number.isNaN(numValue)) {
|
||||||
parsedFieldMeta[property] = numValue;
|
parsedFieldMeta[property] = numValue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
parsedFieldMeta[property] = value;
|
parsedFieldMeta[property] = unescapedValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match(fieldType)
|
||||||
|
.with(FieldType.RADIO, () => applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||||
|
.with(FieldType.CHECKBOX, () => applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||||
|
.with(FieldType.DROPDOWN, () => applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||||
|
.otherwise(() => undefined);
|
||||||
|
|
||||||
return parsedFieldMeta;
|
return parsedFieldMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
|
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
|
||||||
|
|
||||||
export type QuotaFlags = {
|
export type QuotaFlags = {
|
||||||
isDocumentQuotaExceeded: boolean;
|
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 => {
|
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
|
||||||
return {
|
return {
|
||||||
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
|
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';
|
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
|
// 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
|
// warning threshold equals the quota itself, the warning can never fire — the
|
||||||
// exhausting request is handled by the quota branch above.
|
// 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;
|
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
|
||||||
|
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "Platzhalter hinzufügen"
|
msgstr "Platzhalter hinzufügen"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "Rate-Limit hinzufügen"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "Jede Quelle"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "Jeder Status"
|
msgstr "Jeder Status"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "API-Schlüssel"
|
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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "API-Anfragen"
|
msgstr "API-Anfragen"
|
||||||
@@ -2681,6 +2678,10 @@ msgstr "Unterzeichner kann nicht entfernt werden"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
msgid "Cannot upload items after the document has been sent"
|
||||||
msgstr "Artikel können nicht hochgeladen werden, nachdem das Dokument versendet wurde."
|
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
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "Dokumenteinstellungen"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "Dokumentpräferenzen aktualisiert"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4546,6 +4543,7 @@ msgstr "Dokumentation"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
||||||
@@ -4995,10 +4993,6 @@ msgstr "E-Mail-Präferenzen"
|
|||||||
msgid "Email preferences updated"
|
msgid "Email preferences updated"
|
||||||
msgstr "E-Mail-Präferenzen aktualisiert"
|
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
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "Empfänger per E-Mail benachrichtigen, wenn ein ausstehendes Dokument gelöscht wird"
|
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"
|
msgid "Email verification has been resent"
|
||||||
msgstr "E-Mail-Verifizierung wurde erneut gesendet"
|
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/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-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"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "Einbettung, 5 Mitglieder enthalten und mehr"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "Leeres Feld"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "Aktivieren"
|
msgstr "Aktivieren"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "Stellen Sie sicher, dass Sie das Embedding-Token verwenden und nicht das
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "Eingeben"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
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."
|
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"
|
msgid "Enter a new title"
|
||||||
msgstr "Neuen Titel eingeben"
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "Anspruchsname eingeben"
|
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."
|
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."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "Zeitüberschreitung überschritten"
|
msgstr "Zeitüberschreitung überschritten"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "Lichtmodus"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "Möchten Sie Ihr eigenes öffentliches Profil mit Vereinbarungen haben?"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "Limits"
|
msgstr "Limits"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "MAU (angemeldet)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "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
|
#: 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."
|
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."
|
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/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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"
|
msgstr "Monatlich aktive Benutzer: Benutzer, die mindestens eines ihrer Dokumente abgeschlossen haben"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "Monatliches API-Kontingent"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "Monatliches Dokumentenkontingent"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "Monatliches E-Mail-Kontingent"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "Name ist erforderlich"
|
msgstr "Name ist erforderlich"
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "Name ist erforderlich"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "Einstellungen für Namen"
|
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
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "Müssen Dokumente signieren?"
|
msgstr "Müssen Dokumente signieren?"
|
||||||
@@ -7437,6 +7449,10 @@ msgstr "Es sind derzeit keine weiteren Maßnahmen Ihrerseits erforderlich."
|
|||||||
msgid "No groups found"
|
msgid "No groups found"
|
||||||
msgstr "Keine Gruppen gefunden"
|
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
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "Keine Lizenz konfiguriert"
|
msgstr "Keine Lizenz konfiguriert"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "Ausstehende Organisationseinladungen"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "Ausstehend seit"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
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"
|
msgid "Please enter a number"
|
||||||
msgstr "Bitte gib eine Zahl ein"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "Bitte geben Sie eine gültige Nummer ein."
|
msgstr "Bitte geben Sie eine gültige Nummer ein."
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "Organisationsmitglied entfernen"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "Organisationsmitglied entfernen"
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "Empfänger entfernen"
|
msgstr "Empfänger entfernen"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "Zahlung klären"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "Zahlung klären"
|
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
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "Antwort"
|
msgstr "Antwort"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "Senden..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Gesendet"
|
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
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "Sitzung widerrufen"
|
msgstr "Sitzung widerrufen"
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "Team-URL"
|
msgstr "Team-URL"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
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."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "Teams, denen diese Organisationsgruppe derzeit zugewiesen ist"
|
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/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "Unbegrenzt"
|
msgstr "Unbegrenzt"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "Hochladen"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "Verwenden"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "Whitelabeling, unbegrenzte Mitglieder und mehr"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "Breite:"
|
msgstr "Breite:"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "Zustimmung widerrufen"
|
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
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "Schreiben Sie eine Beschreibung, die in Ihrem öffentlichen Profil angezeigt wird"
|
msgstr "Schreiben Sie eine Beschreibung, die in Ihrem öffentlichen Profil angezeigt wird"
|
||||||
|
|||||||
@@ -1411,8 +1411,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "Add Placeholders"
|
msgstr "Add Placeholders"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "Add rate limit"
|
msgstr "Add rate limit window"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2003,6 +2003,7 @@ msgstr "Any Source"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "Any Status"
|
msgstr "Any Status"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2012,10 +2013,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "API requests"
|
msgstr "API requests"
|
||||||
@@ -2676,6 +2673,10 @@ msgstr "Cannot remove signer"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
msgid "Cannot upload items after the document has been sent"
|
||||||
msgstr "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
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4402,10 +4403,6 @@ msgstr "Document Preferences"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4541,6 +4538,7 @@ msgstr "Documentation"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
||||||
@@ -4990,10 +4988,6 @@ msgstr "Email Preferences"
|
|||||||
msgid "Email preferences updated"
|
msgid "Email preferences updated"
|
||||||
msgstr "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
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "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"
|
msgid "Email verification has been resent"
|
||||||
msgstr "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/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-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"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "Enable"
|
msgstr "Enable"
|
||||||
@@ -5231,6 +5224,10 @@ msgstr "Ensure that you are using the embedding token, not the API token"
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
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."
|
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"
|
msgid "Enter a new title"
|
||||||
msgstr "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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "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."
|
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."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "Exceeded timeout"
|
msgstr "Exceeded timeout"
|
||||||
@@ -6760,6 +6765,10 @@ msgstr "Light Mode"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "Limits"
|
msgstr "Limits"
|
||||||
@@ -7065,6 +7074,10 @@ msgstr "MAU (signed in)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "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
|
#: 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."
|
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."
|
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/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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"
|
msgstr "Monthly Active Users: Users that had at least one of their documents completed"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "Monthly API quota"
|
msgstr "Monthly quota"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "Monthly document quota"
|
msgstr "Monthly usage"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "Monthly email quota"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7285,7 +7294,6 @@ msgid "Name"
|
|||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "Name is required"
|
msgstr "Name is required"
|
||||||
|
|
||||||
@@ -7293,6 +7301,10 @@ msgstr "Name is required"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "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"
|
msgid "No groups found"
|
||||||
msgstr "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
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "No License Configured"
|
msgstr "No License Configured"
|
||||||
@@ -8148,6 +8164,10 @@ msgstr "Pending Organisation Invites"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
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"
|
msgid "Please enter a number"
|
||||||
msgstr "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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "Please enter a valid number"
|
msgstr "Please enter a valid number"
|
||||||
@@ -9052,6 +9068,10 @@ msgstr "Remove organisation member"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "Remove recipient"
|
msgstr "Remove recipient"
|
||||||
@@ -9242,6 +9262,10 @@ msgstr "Resolve"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "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
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "Response"
|
msgstr "Response"
|
||||||
@@ -9818,6 +9842,10 @@ msgstr "Sending..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "Session revoked"
|
msgstr "Session revoked"
|
||||||
@@ -10800,10 +10828,10 @@ msgid "Team URL"
|
|||||||
msgstr "Team URL"
|
msgstr "Team URL"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
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."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "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/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "Unlimited"
|
msgstr "Unlimited"
|
||||||
|
|
||||||
@@ -12586,15 +12616,18 @@ msgstr "Uploading"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13496,10 +13529,18 @@ msgstr "Whitelabeling, unlimited members and more"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "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
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "Write a description to display on your public profile"
|
msgstr "Write a description to display on your public profile"
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "Agregar Marcadores de posición"
|
msgstr "Agregar Marcadores de posición"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "Agregar límite de velocidad"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "Cualquier fuente"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "Cualquier estado"
|
msgstr "Cualquier estado"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "Clave API"
|
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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "Solicitudes de API"
|
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"
|
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"
|
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
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "Preferencias del documento"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "Preferencias del documento actualizadas"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
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-command-menu.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-desktop.tsx
|
#: apps/remix/app/components/general/app-nav-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.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"
|
msgid "Email preferences updated"
|
||||||
msgstr "Preferencias de correo electrónico actualizadas"
|
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
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "Enviar un correo electrónico a los destinatarios cuando se elimine un documento pendiente"
|
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"
|
msgid "Email verification has been resent"
|
||||||
msgstr "La verificación de correo electrónico ha sido reenviada"
|
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/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-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"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "Incrustación, 5 miembros incluidos y más"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "Campo vacío"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "Habilitar"
|
msgstr "Habilitar"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "Asegúrate de que estás utilizando el token de incrustación, no el tok
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "Ingresar"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
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."
|
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"
|
msgid "Enter a new title"
|
||||||
msgstr "Introduce un nuevo título"
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "Ingresar nombre de la reclamación"
|
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."
|
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."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "Tiempo de espera excedido"
|
msgstr "Tiempo de espera excedido"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "Modo claro"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "¿Te gustaría tener tu propio perfil público con acuerdos?"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "Límites"
|
msgstr "Límites"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "MAU (con sesión iniciada)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "Máx"
|
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
|
#: 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."
|
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."
|
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/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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"
|
msgstr "Usuarios activos mensuales: Usuarios que completaron al menos uno de sus documentos"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "Cuota mensual de API"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "Cuota mensual de documentos"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "Cuota mensual de correos electrónicos"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "Nombre"
|
msgstr "Nombre"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "Se requiere el nombre"
|
msgstr "Se requiere el nombre"
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "Se requiere el nombre"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "Configuración de Nombre"
|
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
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "¿Necesitas firmar documentos?"
|
msgstr "¿Necesitas firmar documentos?"
|
||||||
@@ -7437,6 +7449,10 @@ msgstr "No further action is required from you at this time."
|
|||||||
msgid "No groups found"
|
msgid "No groups found"
|
||||||
msgstr "No se encontraron grupos"
|
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
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "Licencia no configurada"
|
msgstr "Licencia no configurada"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "Invitaciones pendientes de la organización"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "Pendiente desde"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
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"
|
msgid "Please enter a number"
|
||||||
msgstr "Por favor ingresa un número"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "Por favor, ingresa un número válido"
|
msgstr "Por favor, ingresa un número válido"
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "Eliminar miembro de la organización"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "Eliminar miembro de la organización"
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "Eliminar destinatario"
|
msgstr "Eliminar destinatario"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "Resolver"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "Resolver pago"
|
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
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "Respuesta"
|
msgstr "Respuesta"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "Enviando..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Enviado"
|
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
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "Sesión revocada"
|
msgstr "Sesión revocada"
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "URL del equipo"
|
msgstr "URL del equipo"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
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."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "Equipos a los que actualmente está asignado este grupo de organización"
|
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/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "Ilimitado"
|
msgstr "Ilimitado"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "Subiendo"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "Usar"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "Etiqueta blanca, miembros ilimitados y más"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "Ancho:"
|
msgstr "Ancho:"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "Retirar Consentimiento"
|
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
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "Escribe una descripción para mostrar en tu perfil público"
|
msgstr "Escribe una descripción para mostrar en tu perfil público"
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "Ajouter des espaces réservés"
|
msgstr "Ajouter des espaces réservés"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "Ajouter une limite de fréquence"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "Toute source"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "Tout statut"
|
msgstr "Tout statut"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "Clé d’API"
|
msgstr "Clé d’API"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "API rate limits"
|
|
||||||
msgstr "Limites de fréquence de l’API"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "Requêtes API"
|
msgstr "Requêtes API"
|
||||||
@@ -2681,6 +2678,10 @@ msgstr "Impossible de supprimer le signataire"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
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"
|
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
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "Préférences de document"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "Préférences de document mises à jour"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4546,6 +4543,7 @@ msgstr "Documentation"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.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"
|
msgid "Email preferences updated"
|
||||||
msgstr "Préférences de messagerie mises à jour"
|
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
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "Envoyer un e-mail aux destinataires lorsqu’un document en attente est supprimé"
|
msgstr "Envoyer un e-mail aux destinataires lorsqu’un document en attente est supprimé"
|
||||||
@@ -5094,6 +5088,7 @@ msgstr "La vérification par email a été supprimée"
|
|||||||
msgid "Email verification has been resent"
|
msgid "Email verification has been resent"
|
||||||
msgstr "La vérification par email a été renvoyée"
|
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/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-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"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "Intégration, 5 membres inclus et plus"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "Champ vide"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "Activer"
|
msgstr "Activer"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "Assurez-vous d’utiliser le jeton d’intégration, et non le jeton d
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "Entrer"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
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."
|
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"
|
msgid "Enter a new title"
|
||||||
msgstr "Saisissez un nouveau titre"
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "Entrez le nom de la réclamation"
|
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."
|
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."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "Délai dépassé"
|
msgstr "Délai dépassé"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "Mode clair"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "Vous voulez avoir votre propre profil public avec des accords ?"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "Limites"
|
msgstr "Limites"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "MAU (connecté)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "Maximum"
|
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
|
#: 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."
|
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."
|
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/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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"
|
msgstr "Utilisateurs actifs mensuels : utilisateurs ayant terminé au moins un de leurs documents"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "Quota d’API mensuel"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "Quota de documents mensuel"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "Quota d’e-mails mensuel"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "Nom"
|
msgstr "Nom"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "Le nom est requis"
|
msgstr "Le nom est requis"
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "Le nom est requis"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "Paramètres du nom"
|
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
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "Besoin de signer des 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"
|
msgid "No groups found"
|
||||||
msgstr "Aucun groupe trouvé"
|
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
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "Aucune licence configurée"
|
msgstr "Aucune licence configurée"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "Invitations à l’organisation en attente"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "En attente depuis"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
msgid "per month"
|
||||||
@@ -8315,10 +8335,6 @@ msgstr "Veuillez entrer un nom significatif pour votre token. Cela vous aidera
|
|||||||
msgid "Please enter a number"
|
msgid "Please enter a number"
|
||||||
msgstr "Veuillez entrer un nombre"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "Veuillez entrer un numéro valide"
|
msgstr "Veuillez entrer un numéro valide"
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "Supprimer le membre de l'organisation"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "Supprimer un membre de l’organisation"
|
msgstr "Supprimer un membre de l’organisation"
|
||||||
|
|
||||||
|
#: 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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "Supprimer le destinataire"
|
msgstr "Supprimer le destinataire"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "Résoudre"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "Résoudre le paiement"
|
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
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "Réponse"
|
msgstr "Réponse"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "Envoi..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Envoyé"
|
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
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "Session révoquée"
|
msgstr "Session révoquée"
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "URL de l'équipe"
|
msgstr "URL de l'équipe"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
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."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "Équipes auxquelles ce groupe d'organisation est actuellement attribué"
|
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/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "Illimité"
|
msgstr "Illimité"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "Importation en cours"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "Utiliser"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "Marque blanche, membres illimités et plus"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "Largeur :"
|
msgstr "Largeur :"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "Retrait du consentement"
|
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
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "Écrivez une description à afficher sur votre profil public"
|
msgstr "Écrivez une description à afficher sur votre profil public"
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "Aggiungi segnaposto"
|
msgstr "Aggiungi segnaposto"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "Aggiungi limite di velocità"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "Qualsiasi fonte"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "Qualsiasi stato"
|
msgstr "Qualsiasi stato"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "Chiave API"
|
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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "Richieste API"
|
msgstr "Richieste API"
|
||||||
@@ -2681,6 +2678,10 @@ msgstr "Impossibile rimuovere il firmatario"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
msgid "Cannot upload items after the document has been sent"
|
||||||
msgstr "Non è possibile caricare gli elementi dopo che il documento è stato inviato"
|
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
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "Preferenze Documento"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "Preferenze del documento aggiornate"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4546,6 +4543,7 @@ msgstr "Documentazione"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
||||||
@@ -4995,10 +4993,6 @@ msgstr "Preferenze Email"
|
|||||||
msgid "Email preferences updated"
|
msgid "Email preferences updated"
|
||||||
msgstr "Preferenze email aggiornate"
|
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
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "Invia un'email ai destinatari quando un documento in sospeso viene eliminato"
|
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"
|
msgid "Email verification has been resent"
|
||||||
msgstr "Verifica email rinviata"
|
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/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-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"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "Incorporamento, 5 membri inclusi e altro"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "Campo vuoto"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "Abilita"
|
msgstr "Abilita"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "Assicurati di utilizzare il token di embedding, e non il token API"
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "Inserisci"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
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."
|
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"
|
msgid "Enter a new title"
|
||||||
msgstr "Inserisci un nuovo titolo"
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "Inserisci nome richiesta"
|
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."
|
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."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "Tempo scaduto"
|
msgstr "Tempo scaduto"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "Modalità chiara"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "Ti piacerebbe avere il tuo profilo pubblico con accordi?"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "Limiti"
|
msgstr "Limiti"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "MAU (autenticati)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr ""
|
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
|
#: 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."
|
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."
|
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/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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"
|
msgstr "Utenti attivi mensili: Utenti con almeno uno dei loro documenti completati"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "Quota API mensile"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "Quota documenti mensile"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "Quota email mensile"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "Nome"
|
msgstr "Nome"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "Nome richiesto"
|
msgstr "Nome richiesto"
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "Nome richiesto"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "Impostazioni Nome"
|
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
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "Hai bisogno di firmare documenti?"
|
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"
|
msgid "No groups found"
|
||||||
msgstr "Nessun gruppo trovato"
|
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
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "Nessuna licenza configurata"
|
msgstr "Nessuna licenza configurata"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "Inviti all’organizzazione in sospeso"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "In sospeso dal"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
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"
|
msgid "Please enter a number"
|
||||||
msgstr "Inserisci un numero"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "Per favore inserisci un numero valido"
|
msgstr "Per favore inserisci un numero valido"
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "Rimuovere membro dell'organizzazione"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "Rimuovi membro dell'organizzazione"
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "Rimuovi destinatario"
|
msgstr "Rimuovi destinatario"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "Risolvi"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "Risolvere il pagamento"
|
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
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "Risposta"
|
msgstr "Risposta"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "Invio..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Inviato"
|
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
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "Sessione revocata"
|
msgstr "Sessione revocata"
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "URL del team"
|
msgstr "URL del team"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
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."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "Team a cui è attualmente assegnato questo gruppo di organizzazione"
|
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/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "Illimitato"
|
msgstr "Illimitato"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "Caricamento in corso"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "Utilizza"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "White label, membri illimitati e altro"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "Larghezza:"
|
msgstr "Larghezza:"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "Ritiro del consenso"
|
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
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "Scrivi una descrizione da mostrare sul tuo profilo pubblico"
|
msgstr "Scrivi una descrizione da mostrare sul tuo profilo pubblico"
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "プレースホルダーを追加"
|
msgstr "プレースホルダーを追加"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "レート制限を追加"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "すべてのソース"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "すべてのステータス"
|
msgstr "すべてのステータス"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "API キー"
|
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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "API リクエスト"
|
msgstr "API リクエスト"
|
||||||
@@ -2681,6 +2678,10 @@ msgstr "署名者を削除できません"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
msgid "Cannot upload items after the document has been sent"
|
||||||
msgstr "ドキュメント送信後はアイテムをアップロードできません"
|
msgstr "ドキュメント送信後はアイテムをアップロードできません"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||||
|
msgid "Capabilities enabled for this organisation."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/lib/constants/recipient-roles.ts
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "ドキュメント設定"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "文書設定を更新しました"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4546,6 +4543,7 @@ msgstr "ドキュメント"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
||||||
@@ -4995,10 +4993,6 @@ msgstr "メール設定"
|
|||||||
msgid "Email preferences updated"
|
msgid "Email preferences updated"
|
||||||
msgstr "メール設定を更新しました"
|
msgstr "メール設定を更新しました"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Email rate limits"
|
|
||||||
msgstr "メールのレート制限"
|
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "保留中のドキュメントが削除されたときに受信者へメール通知する"
|
msgstr "保留中のドキュメントが削除されたときに受信者へメール通知する"
|
||||||
@@ -5094,6 +5088,7 @@ msgstr "メール認証を削除しました"
|
|||||||
msgid "Email verification has been resent"
|
msgid "Email verification has been resent"
|
||||||
msgstr "メール認証を再送しました"
|
msgstr "メール認証を再送しました"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.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/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
|
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
|
||||||
@@ -5107,16 +5102,14 @@ msgstr "メール"
|
|||||||
msgid "Embedding, 5 members included and more"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "埋め込み、5 メンバー含む など"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "空のフィールド"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "有効化"
|
msgstr "有効化"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "埋め込みトークンを使用していることを確認し、API
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "入力してください"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
msgid "Enter a name for your new folder. Folders help you organise your items."
|
||||||
msgstr "新しいフォルダ名を入力してください。フォルダを使うとアイテムを整理できます。"
|
msgstr "新しいフォルダ名を入力してください。フォルダを使うとアイテムを整理できます。"
|
||||||
@@ -5244,6 +5241,10 @@ msgstr "新しいフォルダ名を入力してください。フォルダを使
|
|||||||
msgid "Enter a new title"
|
msgid "Enter a new title"
|
||||||
msgstr "新しいタイトルを入力してください"
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "クレーム名を入力"
|
msgstr "クレーム名を入力"
|
||||||
@@ -5502,6 +5503,10 @@ msgstr "全員が署名しました"
|
|||||||
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
||||||
msgstr "全員が署名しました。署名済みドキュメントのコピーがメールで送信されます。"
|
msgstr "全員が署名しました。署名済みドキュメントのコピーがメールで送信されます。"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Exceeded"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "タイムアウトを超えました"
|
msgstr "タイムアウトを超えました"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "ライトモード"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "自分の合意書付き公開プロフィールが欲しいですか?"
|
msgstr "自分の合意書付き公開プロフィールが欲しいですか?"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Limit reached"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "上限"
|
msgstr "上限"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "MAU(サインイン済み)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "最大"
|
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
|
#: 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."
|
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
|
||||||
msgstr "最大ファイルサイズ: 4MB。アップロードあたり最大 100 行。空の値はテンプレートのデフォルトが使用されます。"
|
msgstr "最大ファイルサイズ: 4MB。アップロードあたり最大 100 行。空の値はテンプレートのデフォルトが使用されます。"
|
||||||
@@ -7116,12 +7129,12 @@ msgstr "メンバー登録日"
|
|||||||
|
|
||||||
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
|
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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 つ以上の文書が完了したユーザー"
|
msgstr "月間アクティブユーザー:1 つ以上の文書が完了したユーザー"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "月間 API クォータ"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "月間ドキュメントクォータ"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "月間メールクォータ"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "名前"
|
msgstr "名前"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "名前は必須です"
|
msgstr "名前は必須です"
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "名前は必須です"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "名前の設定"
|
msgstr "名前の設定"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Near limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "文書への署名が必要ですか?"
|
msgstr "文書への署名が必要ですか?"
|
||||||
@@ -7437,6 +7449,10 @@ msgstr "現在、お客様が行う必要のある操作はありません。"
|
|||||||
msgid "No groups found"
|
msgid "No groups found"
|
||||||
msgstr "グループが見つかりません"
|
msgstr "グループが見つかりません"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||||
|
msgid "No inherited claim"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/admin-license-card.tsx
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "ライセンスが設定されていません"
|
msgstr "ライセンスが設定されていません"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "保留中の組織招待"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "保留開始日時"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
msgid "per month"
|
||||||
@@ -8315,10 +8335,6 @@ msgstr "トークンの用途が分かる名前を入力してください。後
|
|||||||
msgid "Please enter a number"
|
msgid "Please enter a number"
|
||||||
msgstr "数値を入力してください"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "有効な数値を入力してください"
|
msgstr "有効な数値を入力してください"
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "組織メンバーを削除"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "組織メンバーを削除"
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "受信者を削除"
|
msgstr "受信者を削除"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "解決"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "支払いを解決"
|
msgstr "支払いを解決"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Resource blocked"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "レスポンス"
|
msgstr "レスポンス"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "送信中..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "送信日時"
|
msgstr "送信日時"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Sent this period"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "セッションを取り消しました"
|
msgstr "セッションを取り消しました"
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "チーム URL"
|
msgstr "チーム URL"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
|
||||||
msgstr "チームは作業を整理し、他のメンバーとコラボレーションするのに役立ちます。最初のチームを作成して始めましょう。"
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "この組織グループが現在割り当てられているチーム"
|
msgstr "この組織グループが現在割り当てられているチーム"
|
||||||
@@ -12297,8 +12329,6 @@ msgstr "不明な名前"
|
|||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "無制限"
|
msgstr "無制限"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "アップロード中"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "使用"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "ホワイトラベリング、メンバー無制限など"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "幅:"
|
msgstr "幅:"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "同意の撤回"
|
msgstr "同意の撤回"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Within limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "公開プロフィールに表示する説明文を入力してください"
|
msgstr "公開プロフィールに表示する説明文を入力してください"
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "플레이스홀더 추가"
|
msgstr "플레이스홀더 추가"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "요청 한도 추가"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "모든 소스"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "모든 상태"
|
msgstr "모든 상태"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "API 키"
|
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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "API 요청"
|
msgstr "API 요청"
|
||||||
@@ -2681,6 +2678,10 @@ msgstr "서명자를 제거할 수 없습니다."
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
msgid "Cannot upload items after the document has been sent"
|
||||||
msgstr "문서를 전송한 이후에는 항목을 업로드할 수 없습니다."
|
msgstr "문서를 전송한 이후에는 항목을 업로드할 수 없습니다."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||||
|
msgid "Capabilities enabled for this organisation."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/lib/constants/recipient-roles.ts
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "문서 기본 설정"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "문서 환경설정이 업데이트되었습니다"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4546,6 +4543,7 @@ msgstr "문서"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
||||||
@@ -4995,10 +4993,6 @@ msgstr "이메일 기본 설정"
|
|||||||
msgid "Email preferences updated"
|
msgid "Email preferences updated"
|
||||||
msgstr "이메일 기본 설정이 업데이트되었습니다."
|
msgstr "이메일 기본 설정이 업데이트되었습니다."
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Email rate limits"
|
|
||||||
msgstr "이메일 요청 한도"
|
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "보류 중인 문서가 삭제되면 수신자에게 이메일 보내기"
|
msgstr "보류 중인 문서가 삭제되면 수신자에게 이메일 보내기"
|
||||||
@@ -5094,6 +5088,7 @@ msgstr "이메일 인증이 제거되었습니다"
|
|||||||
msgid "Email verification has been resent"
|
msgid "Email verification has been resent"
|
||||||
msgstr "이메일 인증이 다시 전송되었습니다"
|
msgstr "이메일 인증이 다시 전송되었습니다"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.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/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
|
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
|
||||||
@@ -5107,16 +5102,14 @@ msgstr "이메일"
|
|||||||
msgid "Embedding, 5 members included and more"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "임베딩, 5명의 구성원 포함 등"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "빈 필드"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "활성화"
|
msgstr "활성화"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "임베딩 토큰이 아닌 API 토큰을 사용하고 있지 않은지
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "입력하세요"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
msgid "Enter a name for your new folder. Folders help you organise your items."
|
||||||
msgstr "새 폴더 이름을 입력하세요. 폴더는 항목을 정리하는 데 도움이 됩니다."
|
msgstr "새 폴더 이름을 입력하세요. 폴더는 항목을 정리하는 데 도움이 됩니다."
|
||||||
@@ -5244,6 +5241,10 @@ msgstr "새 폴더 이름을 입력하세요. 폴더는 항목을 정리하는
|
|||||||
msgid "Enter a new title"
|
msgid "Enter a new title"
|
||||||
msgstr "새 제목을 입력하세요."
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "클레임 이름 입력"
|
msgstr "클레임 이름 입력"
|
||||||
@@ -5502,6 +5503,10 @@ msgstr "모든 사람이 서명했습니다"
|
|||||||
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
||||||
msgstr "모두 서명했습니다! 서명된 문서의 사본이 이메일로 전송됩니다."
|
msgstr "모두 서명했습니다! 서명된 문서의 사본이 이메일로 전송됩니다."
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Exceeded"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "시간 초과됨"
|
msgstr "시간 초과됨"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "라이트 모드"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "계약이 포함된 나만의 공개 프로필을 원하시나요?"
|
msgstr "계약이 포함된 나만의 공개 프로필을 원하시나요?"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Limit reached"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "제한"
|
msgstr "제한"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "MAU(로그인 기준)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "최대"
|
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
|
#: 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."
|
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
|
||||||
msgstr "최대 파일 크기: 4MB. 업로드당 최대 100행. 비어 있는 값은 템플릿 기본값이 사용됩니다."
|
msgstr "최대 파일 크기: 4MB. 업로드당 최대 100행. 비어 있는 값은 템플릿 기본값이 사용됩니다."
|
||||||
@@ -7116,12 +7129,12 @@ msgstr "가입일"
|
|||||||
|
|
||||||
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
|
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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 "월간 활성 사용자: 문서가 하나 이상 완료된 사용자"
|
msgstr "월간 활성 사용자: 문서가 하나 이상 완료된 사용자"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "월간 API 할당량"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "월간 문서 할당량"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "월간 이메일 할당량"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "이름"
|
msgstr "이름"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "이름은 필수 항목입니다."
|
msgstr "이름은 필수 항목입니다."
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "이름은 필수 항목입니다."
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "이름 설정"
|
msgstr "이름 설정"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Near limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "문서에 서명이 필요하신가요?"
|
msgstr "문서에 서명이 필요하신가요?"
|
||||||
@@ -7437,6 +7449,10 @@ msgstr "현재 추가로 수행해야 할 작업은 없습니다."
|
|||||||
msgid "No groups found"
|
msgid "No groups found"
|
||||||
msgstr "그룹을 찾을 수 없습니다."
|
msgstr "그룹을 찾을 수 없습니다."
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||||
|
msgid "No inherited claim"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/admin-license-card.tsx
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "라이선스가 구성되지 않았습니다"
|
msgstr "라이선스가 구성되지 않았습니다"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "보류 중인 조직 초대"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "다음 시점부터 보류 중"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
msgid "per month"
|
||||||
@@ -8315,10 +8335,6 @@ msgstr "토큰을 나중에 식별할 수 있도록 의미 있는 이름을 입
|
|||||||
msgid "Please enter a number"
|
msgid "Please enter a number"
|
||||||
msgstr "숫자를 입력하세요"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "올바른 숫자를 입력하세요"
|
msgstr "올바른 숫자를 입력하세요"
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "조직 구성원 제거"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "조직 구성원 제거"
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "수신자 제거"
|
msgstr "수신자 제거"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "해결"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "결제 해결"
|
msgstr "결제 해결"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Resource blocked"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "응답"
|
msgstr "응답"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "전송 중..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "발송됨"
|
msgstr "발송됨"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Sent this period"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "세션이 해지되었습니다."
|
msgstr "세션이 해지되었습니다."
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "팀 URL"
|
msgstr "팀 URL"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
|
||||||
msgstr "팀은 작업을 조직하고 다른 사람과 협업하는 데 도움이 됩니다. 첫 번째 팀을 생성하여 시작하세요."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "이 조직 그룹이 현재 할당된 팀"
|
msgstr "이 조직 그룹이 현재 할당된 팀"
|
||||||
@@ -12297,8 +12329,6 @@ msgstr "이름을 알 수 없음"
|
|||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "무제한"
|
msgstr "무제한"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "업로드 중"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "사용"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "화이트라벨, 무제한 구성원 등"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "너비:"
|
msgstr "너비:"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "동의 철회"
|
msgstr "동의 철회"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Within limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "공개 프로필에 표시될 설명을 작성하세요."
|
msgstr "공개 프로필에 표시될 설명을 작성하세요."
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "Placeholders toevoegen"
|
msgstr "Placeholders toevoegen"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "Snelheidslimiet toevoegen"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "Elke bron"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "Elke status"
|
msgstr "Elke status"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "API-sleutel"
|
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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "API-verzoeken"
|
msgstr "API-verzoeken"
|
||||||
@@ -2681,6 +2678,10 @@ msgstr "Ondertekenaar kan niet worden verwijderd"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
msgid "Cannot upload items after the document has been sent"
|
||||||
msgstr "Items kunnen niet worden geüpload nadat het document is verzonden"
|
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
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "Documentvoorkeuren"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "Documentvoorkeuren bijgewerkt"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4546,6 +4543,7 @@ msgstr "Documentatie"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
||||||
@@ -4995,10 +4993,6 @@ msgstr "E-mailvoorkeuren"
|
|||||||
msgid "Email preferences updated"
|
msgid "Email preferences updated"
|
||||||
msgstr "E-mailvoorkeuren bijgewerkt"
|
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
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "E-mail ontvangers wanneer een in behandeling zijnd document wordt verwijderd"
|
msgstr "E-mail ontvangers wanneer een in behandeling zijnd document wordt verwijderd"
|
||||||
@@ -5094,6 +5088,7 @@ msgstr "E‑mailverificatie is verwijderd"
|
|||||||
msgid "Email verification has been resent"
|
msgid "Email verification has been resent"
|
||||||
msgstr "E‑mailverificatie is opnieuw verzonden"
|
msgstr "E‑mailverificatie is opnieuw verzonden"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-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"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "Inbedding, 5 leden inbegrepen en meer"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "Leeg veld"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "Inschakelen"
|
msgstr "Inschakelen"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "Zorg ervoor dat je het embedding-token gebruikt en niet het API-token"
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "Invoeren"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
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."
|
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"
|
msgid "Enter a new title"
|
||||||
msgstr "Voer een nieuwe titel in"
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "Voer claimnaam in"
|
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."
|
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."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "Time‑out overschreden"
|
msgstr "Time‑out overschreden"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "Lichte modus"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "Wil je een eigen openbaar profiel met overeenkomsten?"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "Limieten"
|
msgstr "Limieten"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "MAU (ingelogd)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "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
|
#: 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."
|
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."
|
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/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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"
|
msgstr "Maandelijks actieve gebruikers: gebruikers van wie ten minste één document is voltooid"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "Maandelijkse API-limiet"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "Maandelijkse documentlimiet"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "Maandelijkse e-maillimiet"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "Naam"
|
msgstr "Naam"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "Naam is verplicht"
|
msgstr "Naam is verplicht"
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "Naam is verplicht"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "Naam-instellingen"
|
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
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "Moet je documenten ondertekenen?"
|
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"
|
msgid "No groups found"
|
||||||
msgstr "Geen groepen gevonden"
|
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
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "Geen licentie geconfigureerd"
|
msgstr "Geen licentie geconfigureerd"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "Openstaande organisatie-uitnodigingen"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "In behandeling sinds"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
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"
|
msgid "Please enter a number"
|
||||||
msgstr "Voer een nummer in"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "Voer een geldig nummer in"
|
msgstr "Voer een geldig nummer in"
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "Organisatielid verwijderen"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "Organisatielid verwijderen"
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "Ontvanger verwijderen"
|
msgstr "Ontvanger verwijderen"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "Oplossen"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "Betaling oplossen"
|
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
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "Antwoord"
|
msgstr "Antwoord"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "Verzenden..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Verzonden"
|
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
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "Sessie ingetrokken"
|
msgstr "Sessie ingetrokken"
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "Team‑URL"
|
msgstr "Team‑URL"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
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."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "Teams waaraan deze organisatiegroep momenteel is toegewezen"
|
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/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "Onbeperkt"
|
msgstr "Onbeperkt"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "Uploaden"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "Gebruiken"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "Whitelabeling, onbeperkte leden en meer"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "Breedte:"
|
msgstr "Breedte:"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "Toestemming intrekken"
|
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
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "Schrijf een beschrijving die op je openbare profiel wordt weergegeven"
|
msgstr "Schrijf een beschrijving die op je openbare profiel wordt weergegeven"
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "Dodaj domyślny tekst"
|
msgstr "Dodaj domyślny tekst"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "Dodaj limit przepustowości"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "Dowolne źródło"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "Dowolny status"
|
msgstr "Dowolny status"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "Klucz API"
|
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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "Żądania API"
|
msgstr "Żądania API"
|
||||||
@@ -2681,6 +2678,10 @@ msgstr "Nie można usunąć podpisującego"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
msgid "Cannot upload items after the document has been sent"
|
||||||
msgstr "Nie możesz przesłać elementów po wysłaniu dokumentu"
|
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
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "Ustawienia dokumentu"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "Ustawienia dokumentu zostały zaktualizowane"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4546,6 +4543,7 @@ msgstr "Dokumentacja"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
||||||
@@ -4995,10 +4993,6 @@ msgstr "Ustawienia adresu e-mail"
|
|||||||
msgid "Email preferences updated"
|
msgid "Email preferences updated"
|
||||||
msgstr "Ustawienia adresu e-mail zostały zaktualizowane"
|
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
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "Wyślij odbiorcom wiadomość, gdy oczekujący dokument zostanie usunięty"
|
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"
|
msgid "Email verification has been resent"
|
||||||
msgstr "Weryfikacja adresu e-mail została ponownie wysłana"
|
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/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-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"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "Osadzanie dokumentów, 5 użytkowników i więcej"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "Puste pole"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "Włącz"
|
msgstr "Włącz"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "Upewnij się, że używasz tokena osadzania, a nie tokenu API."
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "Wpisz"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
msgid "Enter a name for your new folder. Folders help you organise your items."
|
||||||
msgstr "Wpisz nazwę nowego folderu. Foldery pomagają uporządkować elementy."
|
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"
|
msgid "Enter a new title"
|
||||||
msgstr "Wpisz nowy tytuł"
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "Wpisz nazwę"
|
msgstr "Wpisz nazwę"
|
||||||
@@ -5502,6 +5503,10 @@ msgstr "Wszyscy podpisali"
|
|||||||
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
||||||
msgstr "Wszyscy podpisali! Otrzymasz wiadomość z podpisanym dokumentem."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "Przekroczono limit czasu"
|
msgstr "Przekroczono limit czasu"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "Tryb jasny"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "Czy chcesz mieć własny profil publiczny z umowami?"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "Limity"
|
msgstr "Limity"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "MAU (zalogowani)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "Maks."
|
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
|
#: 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."
|
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."
|
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/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "Miesięczny limit API"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "Miesięczny limit dokumentów"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "Miesięczny limit wiadomości"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "Nazwa"
|
msgstr "Nazwa"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "Nazwa jest wymagana"
|
msgstr "Nazwa jest wymagana"
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "Nazwa jest wymagana"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "Ustawienia nazwy"
|
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
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "Potrzebujesz podpisywać dokumenty?"
|
msgstr "Potrzebujesz podpisywać dokumenty?"
|
||||||
@@ -7437,6 +7449,10 @@ msgstr "Nie musisz nic więcej robić."
|
|||||||
msgid "No groups found"
|
msgid "No groups found"
|
||||||
msgstr "Nie znaleziono grup"
|
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
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "Brak skonfigurowanej licencji"
|
msgstr "Brak skonfigurowanej licencji"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "Oczekujące zaproszenia do organizacji"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "Oczekuje od"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
msgid "per month"
|
||||||
@@ -8315,10 +8335,6 @@ msgstr "Wpisz nazwę tokena. Pomoże to później w jego identyfikacji."
|
|||||||
msgid "Please enter a number"
|
msgid "Please enter a number"
|
||||||
msgstr "Wpisz liczbę"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "Wpisz prawidłową liczbę"
|
msgstr "Wpisz prawidłową liczbę"
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "Usuń użytkownika organizacji"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "Usuń użytkownika organizacji"
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "Usuń odbiorcę"
|
msgstr "Usuń odbiorcę"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "Rozwiąż"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "Rozwiąż płatność"
|
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
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "Odpowiedź"
|
msgstr "Odpowiedź"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "Wysyłanie..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Wysłano"
|
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
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "Sesja została unieważniona"
|
msgstr "Sesja została unieważniona"
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "Adres URL zespołu"
|
msgstr "Adres URL zespołu"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
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ąć."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "Zespoły, do których przypisana jest grupa organizacji"
|
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/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "Bez ograniczeń"
|
msgstr "Bez ograniczeń"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "Przesyłanie"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "Adres 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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "Użyj"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "Własny branding, nieograniczona liczba użytkowników i więcej"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "Szerokość:"
|
msgstr "Szerokość:"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "Wycofanie zgody"
|
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
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "Wpisz opis, który będzie wyświetlany w profilu publicznym"
|
msgstr "Wpisz opis, który będzie wyświetlany w profilu publicznym"
|
||||||
|
|||||||
@@ -1411,7 +1411,7 @@ msgid "Add Placeholders"
|
|||||||
msgstr "Adicionar Espaços Reservados"
|
msgstr "Adicionar Espaços Reservados"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
@@ -2003,6 +2003,7 @@ msgstr "Qualquer Origem"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "Qualquer Status"
|
msgstr "Qualquer Status"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -2012,10 +2013,6 @@ msgstr ""
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "API rate limits"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -2676,6 +2673,10 @@ msgstr "Não é possível remover o signatário"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
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"
|
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
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4402,10 +4403,6 @@ msgstr "Preferências de Documento"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "Preferências de documento atualizadas"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
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-command-menu.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-desktop.tsx
|
#: apps/remix/app/components/general/app-nav-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.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"
|
msgid "Email preferences updated"
|
||||||
msgstr "Preferências de e-mail atualizadas"
|
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
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -5089,6 +5083,7 @@ msgstr "A verificação de e-mail foi removida"
|
|||||||
msgid "Email verification has been resent"
|
msgid "Email verification has been resent"
|
||||||
msgstr "A verificação de e-mail foi reenviada"
|
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/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-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"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "Incorporação, 5 membros incluídos e mais"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "Campo vazio"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "Ativar"
|
msgstr "Ativar"
|
||||||
@@ -5231,6 +5224,10 @@ msgstr ""
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr ""
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
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."
|
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"
|
msgid "Enter a new title"
|
||||||
msgstr ""
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "Digite o nome da reivindicação"
|
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."
|
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."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "Tempo limite excedido"
|
msgstr "Tempo limite excedido"
|
||||||
@@ -6760,6 +6765,10 @@ msgstr "Modo Claro"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "Gostaria de ter seu próprio perfil público com contratos?"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -7065,6 +7074,10 @@ msgstr "MAU (entrou)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "Máx"
|
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
|
#: 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."
|
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."
|
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/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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"
|
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
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
@@ -7285,7 +7294,6 @@ msgid "Name"
|
|||||||
msgstr "Nome"
|
msgstr "Nome"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "O nome é obrigatório"
|
msgstr "O nome é obrigatório"
|
||||||
|
|
||||||
@@ -7293,6 +7301,10 @@ msgstr "O nome é obrigatório"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "Configurações de Nome"
|
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
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "Precisa assinar documentos?"
|
msgstr "Precisa assinar documentos?"
|
||||||
@@ -7432,6 +7444,10 @@ msgstr "Nenhuma ação adicional é necessária de sua parte neste momento."
|
|||||||
msgid "No groups found"
|
msgid "No groups found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||||
|
msgid "No inherited claim"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/admin-license-card.tsx
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -8148,6 +8164,10 @@ msgstr ""
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr ""
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
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"
|
msgid "Please enter a number"
|
||||||
msgstr ""
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "Por favor, insira um número válido"
|
msgstr "Por favor, insira um número válido"
|
||||||
@@ -9052,6 +9068,10 @@ msgstr "Remover membro da organização"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr ""
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "Remover destinatário"
|
msgstr "Remover destinatário"
|
||||||
@@ -9242,6 +9262,10 @@ msgstr "Resolver"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "Resolver pagamento"
|
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
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "Resposta"
|
msgstr "Resposta"
|
||||||
@@ -9818,6 +9842,10 @@ msgstr "Enviando..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Enviado"
|
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
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "Sessão revogada"
|
msgstr "Sessão revogada"
|
||||||
@@ -10800,10 +10828,10 @@ msgid "Team URL"
|
|||||||
msgstr "URL da Equipe"
|
msgstr "URL da Equipe"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
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."
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "Equipes às quais este grupo da organização está atualmente atribuído"
|
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/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "Ilimitado"
|
msgstr "Ilimitado"
|
||||||
|
|
||||||
@@ -12586,15 +12616,18 @@ msgstr "Enviando"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "Usar"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13496,10 +13529,18 @@ msgstr "Whitelabeling, membros ilimitados e mais"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "Retirada de Consentimento"
|
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
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "Escreva uma descrição para exibir em seu perfil público"
|
msgstr "Escreva uma descrição para exibir em seu perfil público"
|
||||||
|
|||||||
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
|
|||||||
msgstr "添加占位符"
|
msgstr "添加占位符"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
msgid "Add rate limit"
|
msgid "Add rate limit window"
|
||||||
msgstr "添加速率限制"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Add recipients"
|
msgid "Add recipients"
|
||||||
@@ -2008,6 +2008,7 @@ msgstr "任意来源"
|
|||||||
msgid "Any Status"
|
msgid "Any Status"
|
||||||
msgstr "任意状态"
|
msgstr "任意状态"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
|
||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
@@ -2017,10 +2018,6 @@ msgstr "API"
|
|||||||
msgid "API key"
|
msgid "API key"
|
||||||
msgstr "API 密钥"
|
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
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "API requests"
|
msgid "API requests"
|
||||||
msgstr "API 请求"
|
msgstr "API 请求"
|
||||||
@@ -2681,6 +2678,10 @@ msgstr "无法移除签署人"
|
|||||||
msgid "Cannot upload items after the document has been sent"
|
msgid "Cannot upload items after the document has been sent"
|
||||||
msgstr "文档发送后无法再上传项目"
|
msgstr "文档发送后无法再上传项目"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||||
|
msgid "Capabilities enabled for this organisation."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: packages/lib/constants/recipient-roles.ts
|
#: packages/lib/constants/recipient-roles.ts
|
||||||
msgctxt "Recipient role name"
|
msgctxt "Recipient role name"
|
||||||
msgid "Cc"
|
msgid "Cc"
|
||||||
@@ -4407,10 +4408,6 @@ msgstr "文档偏好"
|
|||||||
msgid "Document preferences updated"
|
msgid "Document preferences updated"
|
||||||
msgstr "文档偏好设置已更新"
|
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-signing/document-signing-reject-dialog.tsx
|
||||||
#: apps/remix/app/components/general/document/document-status.tsx
|
#: apps/remix/app/components/general/document/document-status.tsx
|
||||||
msgid "Document rejected"
|
msgid "Document rejected"
|
||||||
@@ -4546,6 +4543,7 @@ msgstr "文档"
|
|||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: 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-desktop.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.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/envelope-editor/envelope-editor-upload-page.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
|
||||||
@@ -4995,10 +4993,6 @@ msgstr "邮件偏好"
|
|||||||
msgid "Email preferences updated"
|
msgid "Email preferences updated"
|
||||||
msgstr "邮件偏好已更新"
|
msgstr "邮件偏好已更新"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Email rate limits"
|
|
||||||
msgstr "电子邮件速率限制"
|
|
||||||
|
|
||||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||||
msgid "Email recipients when a pending document is deleted"
|
msgid "Email recipients when a pending document is deleted"
|
||||||
msgstr "在待处理文档被删除时向收件人发送电子邮件通知"
|
msgstr "在待处理文档被删除时向收件人发送电子邮件通知"
|
||||||
@@ -5094,6 +5088,7 @@ msgstr "邮箱验证已移除"
|
|||||||
msgid "Email verification has been resent"
|
msgid "Email verification has been resent"
|
||||||
msgstr "验证邮件已重新发送"
|
msgstr "验证邮件已重新发送"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.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/admin-organisation-stats-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
|
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
|
||||||
@@ -5107,16 +5102,14 @@ msgstr "邮箱"
|
|||||||
msgid "Embedding, 5 members included and more"
|
msgid "Embedding, 5 members included and more"
|
||||||
msgstr "内嵌、包含 5 名成员等更多功能"
|
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
|
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||||
msgid "Empty field"
|
msgid "Empty field"
|
||||||
msgstr "空字段"
|
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
|
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
|
||||||
msgid "Enable"
|
msgid "Enable"
|
||||||
msgstr "启用"
|
msgstr "启用"
|
||||||
@@ -5236,6 +5229,10 @@ msgstr "请确保您使用的是嵌入令牌,而不是 API 令牌"
|
|||||||
msgid "Enter"
|
msgid "Enter"
|
||||||
msgstr "输入"
|
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
|
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
|
||||||
msgid "Enter a name for your new folder. Folders help you organise your items."
|
msgid "Enter a name for your new folder. Folders help you organise your items."
|
||||||
msgstr "为新文件夹输入名称。文件夹可以帮助您整理项目。"
|
msgstr "为新文件夹输入名称。文件夹可以帮助您整理项目。"
|
||||||
@@ -5244,6 +5241,10 @@ msgstr "为新文件夹输入名称。文件夹可以帮助您整理项目。"
|
|||||||
msgid "Enter a new title"
|
msgid "Enter a new title"
|
||||||
msgstr "输入新标题"
|
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
|
#: apps/remix/app/components/forms/subscription-claim-form.tsx
|
||||||
msgid "Enter claim name"
|
msgid "Enter claim name"
|
||||||
msgstr "输入声明名称"
|
msgstr "输入声明名称"
|
||||||
@@ -5502,6 +5503,10 @@ msgstr "所有人都已签署"
|
|||||||
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
||||||
msgstr "所有人都已签署!您将收到一份已签署文档的电子邮件副本。"
|
msgstr "所有人都已签署!您将收到一份已签署文档的电子邮件副本。"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Exceeded"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
msgstr "超时"
|
msgstr "超时"
|
||||||
@@ -6765,6 +6770,10 @@ msgstr "浅色模式"
|
|||||||
msgid "Like to have your own public profile with agreements?"
|
msgid "Like to have your own public profile with agreements?"
|
||||||
msgstr "想拥有属于自己的公开协议页面吗?"
|
msgstr "想拥有属于自己的公开协议页面吗?"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Limit reached"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Limits"
|
msgid "Limits"
|
||||||
msgstr "限制"
|
msgstr "限制"
|
||||||
@@ -7070,6 +7079,10 @@ msgstr "月活跃用户(已登录)"
|
|||||||
msgid "Max"
|
msgid "Max"
|
||||||
msgstr "最大值"
|
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
|
#: 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."
|
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
|
||||||
msgstr "最大文件大小:4MB。每次上传最多 100 行。空值将使用模板默认值。"
|
msgstr "最大文件大小:4MB。每次上传最多 100 行。空值将使用模板默认值。"
|
||||||
@@ -7116,12 +7129,12 @@ msgstr "加入时间"
|
|||||||
|
|
||||||
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
|
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/team-member-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/admin-organisation-overview-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-groups-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/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/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+/admin+/teams.$id.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.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 "月活跃用户:至少有一份文档被完成的用户"
|
msgstr "月活跃用户:至少有一份文档被完成的用户"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
msgid "Monthly API quota"
|
msgid "Monthly quota"
|
||||||
msgstr "每月 API 配额"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
msgid "Monthly document quota"
|
msgid "Monthly usage"
|
||||||
msgstr "每月文档配额"
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
|
||||||
msgid "Monthly email quota"
|
|
||||||
msgstr "每月邮件配额"
|
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||||
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
|
||||||
@@ -7290,7 +7299,6 @@ msgid "Name"
|
|||||||
msgstr "姓名"
|
msgstr "姓名"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
|
#: 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"
|
msgid "Name is required"
|
||||||
msgstr "名称为必填项"
|
msgstr "名称为必填项"
|
||||||
|
|
||||||
@@ -7298,6 +7306,10 @@ msgstr "名称为必填项"
|
|||||||
msgid "Name Settings"
|
msgid "Name Settings"
|
||||||
msgstr "姓名设置"
|
msgstr "姓名设置"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Near limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Need to sign documents?"
|
msgid "Need to sign documents?"
|
||||||
msgstr "需要签署文档?"
|
msgstr "需要签署文档?"
|
||||||
@@ -7437,6 +7449,10 @@ msgstr "目前您无需再执行任何操作。"
|
|||||||
msgid "No groups found"
|
msgid "No groups found"
|
||||||
msgstr "未找到群组"
|
msgstr "未找到群组"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||||
|
msgid "No inherited claim"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/admin-license-card.tsx
|
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||||
msgid "No License Configured"
|
msgid "No License Configured"
|
||||||
msgstr "未配置许可证"
|
msgstr "未配置许可证"
|
||||||
@@ -8153,6 +8169,10 @@ msgstr "待处理的组织邀请"
|
|||||||
msgid "Pending since"
|
msgid "Pending since"
|
||||||
msgstr "待处理起始时间"
|
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/dialogs/organisation-create-dialog.tsx
|
||||||
#: apps/remix/app/components/general/billing-plans.tsx
|
#: apps/remix/app/components/general/billing-plans.tsx
|
||||||
msgid "per month"
|
msgid "per month"
|
||||||
@@ -8315,10 +8335,6 @@ msgstr "请输入一个有意义的令牌名称,以便日后识别。"
|
|||||||
msgid "Please enter a number"
|
msgid "Please enter a number"
|
||||||
msgstr "请输入一个数字"
|
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
|
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
|
||||||
msgid "Please enter a valid number"
|
msgid "Please enter a valid number"
|
||||||
msgstr "请输入有效的数字"
|
msgstr "请输入有效的数字"
|
||||||
@@ -9057,6 +9073,10 @@ msgstr "移除组织成员"
|
|||||||
msgid "Remove Organisation Member"
|
msgid "Remove Organisation Member"
|
||||||
msgstr "移除组织成员"
|
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
|
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
|
||||||
msgid "Remove recipient"
|
msgid "Remove recipient"
|
||||||
msgstr "移除收件人"
|
msgstr "移除收件人"
|
||||||
@@ -9247,6 +9267,10 @@ msgstr "解决"
|
|||||||
msgid "Resolve payment"
|
msgid "Resolve payment"
|
||||||
msgstr "解决付款问题"
|
msgstr "解决付款问题"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Resource blocked"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Response"
|
msgid "Response"
|
||||||
msgstr "响应"
|
msgstr "响应"
|
||||||
@@ -9823,6 +9847,10 @@ msgstr "正在发送..."
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "已发送"
|
msgstr "已发送"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Sent this period"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Session revoked"
|
msgid "Session revoked"
|
||||||
msgstr "会话已撤销"
|
msgstr "会话已撤销"
|
||||||
@@ -10805,10 +10833,10 @@ msgid "Team URL"
|
|||||||
msgstr "团队 URL"
|
msgstr "团队 URL"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/org-menu-switcher.tsx
|
#: 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/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/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+/dashboard.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.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."
|
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
|
||||||
msgstr "团队帮助您组织工作并与他人协作。创建您的第一个团队以开始使用。"
|
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
|
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||||
msgid "Teams that this organisation group is currently assigned to"
|
msgid "Teams that this organisation group is currently assigned to"
|
||||||
msgstr "当前已将此组织组分配给以下团队"
|
msgstr "当前已将此组织组分配给以下团队"
|
||||||
@@ -12297,8 +12329,6 @@ msgstr "未知姓名"
|
|||||||
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
#: apps/remix/app/components/general/claim-limit-fields.tsx
|
||||||
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
#: apps/remix/app/components/tables/admin-claims-table.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"
|
msgid "Unlimited"
|
||||||
msgstr "不限"
|
msgstr "不限"
|
||||||
|
|
||||||
@@ -12591,15 +12621,18 @@ msgstr "正在上传"
|
|||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
|
||||||
msgid "Use"
|
msgid "Use"
|
||||||
msgstr "使用"
|
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/2fa/disable-authenticator-app-dialog.tsx
|
||||||
#: apps/remix/app/components/forms/signin.tsx
|
#: apps/remix/app/components/forms/signin.tsx
|
||||||
msgid "Use Authenticator"
|
msgid "Use Authenticator"
|
||||||
@@ -13501,10 +13534,18 @@ msgstr "白标、自定义域、无限成员等更多功能"
|
|||||||
msgid "Width:"
|
msgid "Width:"
|
||||||
msgstr "宽度:"
|
msgstr "宽度:"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/rate-limit-array-input.tsx
|
||||||
|
msgid "Window"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||||
msgid "Withdrawing Consent"
|
msgid "Withdrawing Consent"
|
||||||
msgstr "撤回同意"
|
msgstr "撤回同意"
|
||||||
|
|
||||||
|
#: apps/remix/app/components/general/organisation-usage-panel.tsx
|
||||||
|
msgid "Within limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/remix/app/components/forms/public-profile-form.tsx
|
#: apps/remix/app/components/forms/public-profile-form.tsx
|
||||||
msgid "Write a description to display on your public profile"
|
msgid "Write a description to display on your public profile"
|
||||||
msgstr "撰写将在您的公共主页上展示的简介"
|
msgstr "撰写将在您的公共主页上展示的简介"
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ZNameSchema } from './name';
|
||||||
|
|
||||||
|
describe('ZNameSchema', () => {
|
||||||
|
describe('valid names', () => {
|
||||||
|
it('accepts a normal name', () => {
|
||||||
|
expect(ZNameSchema.safeParse('Example User')).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: 'Example User',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts international characters', () => {
|
||||||
|
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: 'Døcumensø Üser',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace', () => {
|
||||||
|
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: 'Documenso User',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts names at the minimum length', () => {
|
||||||
|
expect(ZNameSchema.safeParse('DU')).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: 'DU',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts names at the maximum length', () => {
|
||||||
|
const name =
|
||||||
|
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
|
||||||
|
|
||||||
|
expect(name.length).toBe(100);
|
||||||
|
expect(ZNameSchema.safeParse(name)).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('length validation', () => {
|
||||||
|
it('rejects names shorter than 2 characters', () => {
|
||||||
|
expect(ZNameSchema.safeParse('D')).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: [{ message: 'Please enter a valid name.' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects names longer than 100 characters', () => {
|
||||||
|
const name =
|
||||||
|
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
|
||||||
|
|
||||||
|
expect(name.length).toBe(101);
|
||||||
|
expect(ZNameSchema.safeParse(name)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: [{ message: 'Name cannot be more than 100 characters.' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects whitespace-only input after trim', () => {
|
||||||
|
expect(ZNameSchema.safeParse(' ')).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('URL validation', () => {
|
||||||
|
it.each([
|
||||||
|
'https://example.com',
|
||||||
|
'http://example.com',
|
||||||
|
'HTTPS://EXAMPLE.COM',
|
||||||
|
'Northwind www.example.com',
|
||||||
|
'www.example.com',
|
||||||
|
])('rejects URLs in names: %s', (value) => {
|
||||||
|
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid character validation', () => {
|
||||||
|
it.each([
|
||||||
|
['NUL character', 'Acme\u0000Corp'],
|
||||||
|
['zero-width space', 'Acme\u200bCorp'],
|
||||||
|
['bidi override', 'Acme\u202eCorp'],
|
||||||
|
['byte order mark', 'Acme\ufeffCorp'],
|
||||||
|
['lone surrogate', 'Acme\ud800Corp'],
|
||||||
|
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
|
||||||
|
['noncharacter', 'Acme\ufffeCorp'],
|
||||||
|
['private use character', 'Acme\ue000Corp'],
|
||||||
|
['Hangul filler', 'Acme\u3164Corp'],
|
||||||
|
['braille blank', 'Acme\u2800Corp'],
|
||||||
|
['combining grapheme joiner', 'Acme\u034fCorp'],
|
||||||
|
])('rejects names containing a %s', (_label, value) => {
|
||||||
|
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['fixed form', String.raw`Acme\u200bCorp`],
|
||||||
|
['uppercase U', String.raw`Acme\U200BCorp`],
|
||||||
|
['braced form', String.raw`Acme\u{200b}Corp`],
|
||||||
|
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
|
||||||
|
['lone surrogate', String.raw`Acme\ud800Corp`],
|
||||||
|
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
|
||||||
|
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['escape of a valid code point', String.raw`Acme\u0041Corp`],
|
||||||
|
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
|
||||||
|
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
|
||||||
|
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
|
||||||
|
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
|
||||||
|
['astral characters such as emoji', 'Acme 😀 Corp'],
|
||||||
|
['emoji with a variation selector', 'I ❤️ Docs'],
|
||||||
|
])('accepts %s', (_label, value) => {
|
||||||
|
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const URL_PATTERN = /https?:\/\/|www\./i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters that render as empty/invisible or break text layout:
|
||||||
|
*
|
||||||
|
* - `\p{C}` - control, format, lone surrogate, private use and
|
||||||
|
* unassigned code points (NUL, zero-width spaces, bidi
|
||||||
|
* overrides, BOM, tag characters, noncharacters).
|
||||||
|
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
|
||||||
|
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
|
||||||
|
* character class because it is a combining mark, which
|
||||||
|
* lint rules reject inside classes.
|
||||||
|
* - remaining - letters that render as blank (Hangul fillers, braille blank).
|
||||||
|
*
|
||||||
|
* The `\p{...}` classes are maintained by the Unicode database, so newly
|
||||||
|
* assigned characters in these categories are covered automatically.
|
||||||
|
*/
|
||||||
|
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
|
||||||
|
|
||||||
|
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
|
||||||
|
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
|
||||||
|
* break rendering downstream if anything decodes them.
|
||||||
|
*/
|
||||||
|
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
|
||||||
|
|
||||||
|
const hasInvalidEscapeSequence = (value: string) => {
|
||||||
|
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
|
||||||
|
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
|
||||||
|
|
||||||
|
if (codePoint > 0x10ffff) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the escape and run it through the same character policy as the
|
||||||
|
// unescaped check, so the two can never drift apart.
|
||||||
|
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasInvalidTextCharacters = (value: string) =>
|
||||||
|
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared name schema that disallows URLs to prevent phishing via email rendering,
|
||||||
|
* and invisible/control characters that render as empty or break the UI.
|
||||||
|
*/
|
||||||
|
export const ZNameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, { message: 'Please enter a valid name.' })
|
||||||
|
.max(100, { message: 'Name cannot be more than 100 characters.' })
|
||||||
|
.refine((value) => !URL_PATTERN.test(value), {
|
||||||
|
message: 'Name cannot contain URLs.',
|
||||||
|
})
|
||||||
|
.refine((value) => !hasInvalidTextCharacters(value), {
|
||||||
|
message: 'Name contains invalid characters.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TName = z.infer<typeof ZNameSchema>;
|
||||||
@@ -6,14 +6,39 @@ import { z } from 'zod';
|
|||||||
*
|
*
|
||||||
* Example: "5m", "1h", "1d"
|
* 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(
|
const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
|
||||||
z.object({
|
const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
|
||||||
window: ZRateLimitWindowSchema,
|
|
||||||
max: z.number().int().positive(),
|
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>;
|
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getQuotaUsagePercent,
|
||||||
|
getQuotaWarningCount,
|
||||||
|
isQuotaExceeded,
|
||||||
|
isQuotaNearing,
|
||||||
|
normalizeCapacityLimit,
|
||||||
|
} from './quota-usage';
|
||||||
|
|
||||||
|
describe('isQuotaExceeded', () => {
|
||||||
|
it('treats null quota as unlimited (never exceeded)', () => {
|
||||||
|
expect(isQuotaExceeded(null, 0)).toBe(false);
|
||||||
|
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a zero quota as blocked (always exceeded)', () => {
|
||||||
|
expect(isQuotaExceeded(0, 0)).toBe(true);
|
||||||
|
expect(isQuotaExceeded(0, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is exceeded once usage reaches the quota (>= boundary)', () => {
|
||||||
|
expect(isQuotaExceeded(10, 9)).toBe(false);
|
||||||
|
expect(isQuotaExceeded(10, 10)).toBe(true);
|
||||||
|
expect(isQuotaExceeded(10, 11)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getQuotaWarningCount', () => {
|
||||||
|
it('rounds the 80% threshold up', () => {
|
||||||
|
expect(getQuotaWarningCount(10)).toBe(8);
|
||||||
|
expect(getQuotaWarningCount(100)).toBe(80);
|
||||||
|
// 5 * 0.8 = 4 exactly.
|
||||||
|
expect(getQuotaWarningCount(5)).toBe(4);
|
||||||
|
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
|
||||||
|
expect(getQuotaWarningCount(3)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isQuotaNearing', () => {
|
||||||
|
it('is never nearing for unlimited or blocked quotas', () => {
|
||||||
|
expect(isQuotaNearing(null, 5)).toBe(false);
|
||||||
|
expect(isQuotaNearing(0, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is nearing from the warning threshold up to (but not including) the quota', () => {
|
||||||
|
expect(isQuotaNearing(10, 7)).toBe(false);
|
||||||
|
expect(isQuotaNearing(10, 8)).toBe(true);
|
||||||
|
expect(isQuotaNearing(10, 9)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
|
||||||
|
expect(isQuotaNearing(10, 10)).toBe(false);
|
||||||
|
expect(isQuotaNearing(10, 11)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can never fire for tiny quotas where the warning count equals the quota', () => {
|
||||||
|
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
|
||||||
|
expect(isQuotaNearing(3, 2)).toBe(false);
|
||||||
|
expect(isQuotaNearing(3, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with the warning-count helper at the boundary', () => {
|
||||||
|
const quota = 250;
|
||||||
|
const warningCount = getQuotaWarningCount(quota);
|
||||||
|
|
||||||
|
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
|
||||||
|
expect(isQuotaNearing(quota, warningCount)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getQuotaUsagePercent', () => {
|
||||||
|
it('returns 0 for unlimited or non-positive quotas', () => {
|
||||||
|
expect(getQuotaUsagePercent(5, null)).toBe(0);
|
||||||
|
expect(getQuotaUsagePercent(5, 0)).toBe(0);
|
||||||
|
expect(getQuotaUsagePercent(5, -10)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds the percentage to the nearest integer', () => {
|
||||||
|
expect(getQuotaUsagePercent(1, 3)).toBe(33);
|
||||||
|
expect(getQuotaUsagePercent(2, 3)).toBe(67);
|
||||||
|
expect(getQuotaUsagePercent(50, 100)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the percentage to 100 when usage exceeds the quota', () => {
|
||||||
|
expect(getQuotaUsagePercent(150, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeCapacityLimit', () => {
|
||||||
|
it('maps 0 (unlimited for capacity limits) to null', () => {
|
||||||
|
expect(normalizeCapacityLimit(0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes positive limits through unchanged', () => {
|
||||||
|
expect(normalizeCapacityLimit(1)).toBe(1);
|
||||||
|
expect(normalizeCapacityLimit(25)).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export const QUOTA_WARNING_THRESHOLD = 0.8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
|
||||||
|
*/
|
||||||
|
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
|
||||||
|
if (quota === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quota === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage >= quota;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The usage count at which a positive quota starts "nearing" (80% rounded up).
|
||||||
|
* The single source for the warning threshold math so the UI panel, quota flags,
|
||||||
|
* and the per-request alert path can't drift apart.
|
||||||
|
*/
|
||||||
|
export const getQuotaWarningCount = (quota: number): number => {
|
||||||
|
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
|
||||||
|
*/
|
||||||
|
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
|
||||||
|
if (quota === null || quota === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQuotaExceeded(quota, usage)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage >= getQuotaWarningCount(quota);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
|
||||||
|
if (quota === null || quota <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(100, Math.round((usage / quota) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Member/team capacity limits use `0` for unlimited. */
|
||||||
|
export const normalizeCapacityLimit = (limit: number): number | null => {
|
||||||
|
if (limit === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return limit;
|
||||||
|
};
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import { env } from '@documenso/lib/utils/env';
|
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
||||||
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 { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|
||||||
import { AppError } from '../../errors/app-error';
|
import { AppError } from '../../errors/app-error';
|
||||||
|
|
||||||
type File = {
|
type File = {
|
||||||
@@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a file to the appropriate storage location.
|
|
||||||
*/
|
|
||||||
export const putFile = async (file: File, options?: PutFileOptions) => {
|
|
||||||
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
|
||||||
|
|
||||||
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
|
|
||||||
.with('s3', async () => putFileInObjectStorage(file, {}, options))
|
|
||||||
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
|
|
||||||
.otherwise(async () => putFileInDatabase(file));
|
|
||||||
};
|
|
||||||
|
|
||||||
const putFileInDatabase = async (file: File) => {
|
|
||||||
const contents = await file.arrayBuffer();
|
|
||||||
|
|
||||||
const binaryData = new Uint8Array(contents);
|
|
||||||
|
|
||||||
const asciiData = base64.encode(binaryData);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: DocumentDataType.BYTES_64,
|
|
||||||
data: asciiData,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
|
|
||||||
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...buildUploadAuthHeaders(options),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileName: file.name,
|
|
||||||
contentType: file.type,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!getPresignedUrlResponse.ok) {
|
|
||||||
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
|
|
||||||
|
|
||||||
const body = await file.arrayBuffer();
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
...extraHeaders,
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: DocumentDataType.S3_PATH,
|
|
||||||
data: key,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
|
|||||||
content,
|
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();
|
||||||
|
};
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "5.6.2"
|
"typescript": "5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.19",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.18",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user