mirror of
https://github.com/documenso/documenso.git
synced 2026-06-26 14:22:14 +10:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 191170923a | |||
| 4078c6b46d | |||
| abbca79b48 | |||
| d6dd2b3292 | |||
| cfaad6efc9 | |||
| 9a45b3564f | |||
| 8b171c9a30 | |||
| a8efb6f495 | |||
| bc184d445f | |||
| 8dfd548c08 | |||
| 73a7335c89 | |||
| be3e45427f | |||
| 57eb40d6aa | |||
| 684fab1909 | |||
| d794ceb8da | |||
| 87315adb0f | |||
| 0a7794be61 | |||
| f15d6f0150 | |||
| 0b86ece1d5 | |||
| a197bf113f | |||
| ec8728b33e | |||
| 22122f51da |
@@ -0,0 +1,138 @@
|
||||
---
|
||||
date: 2026-05-06
|
||||
title: Platform Signing Page Branding
|
||||
---
|
||||
|
||||
## What
|
||||
|
||||
Platform-plan organisations (and their teams) can customise the **non-embed
|
||||
signing pages** (`/sign/:token`, `/d/:token`, and the sibling
|
||||
complete/expired/rejected/waiting pages) with:
|
||||
|
||||
- Six brand colour tokens (background, foreground, primary, primary-foreground,
|
||||
border, ring) plus a border-radius length.
|
||||
- A free-text custom CSS block (up to 256 KB).
|
||||
|
||||
Settings live on `OrganisationGlobalSettings` and `TeamGlobalSettings`. Teams
|
||||
inherit from the org via the existing `brandingEnabled === null` mechanism.
|
||||
|
||||
## Why
|
||||
|
||||
- Embed customers already have white-label CSS; Platform customers want the
|
||||
same coverage on direct signing URLs that they iframe or link to.
|
||||
- Persisting on org/team (not per envelope) means it's set-and-forget.
|
||||
- Sanitising **on save** lets us inline the verbatim string at SSR — no
|
||||
per-render parsing cost, no `<style>.innerHTML` injection on the client.
|
||||
- Reusing the existing `embedSigningWhiteLabel` claim flag keeps "if you can
|
||||
white-label an embed, you can white-label this" as one decision.
|
||||
|
||||
## How
|
||||
|
||||
### Storage (`packages/prisma/schema.prisma`)
|
||||
|
||||
Two new fields on each settings model. No new tables.
|
||||
|
||||
| Field | Org type | Team type |
|
||||
| ---------------- | ------------------ | ------------------ |
|
||||
| `brandingColors` | `Json?` (nullable) | `Json?` (nullable) |
|
||||
| `brandingCss` | `String @default("")` | `String?` |
|
||||
|
||||
Colours are validated against `ZCssVarsSchema`. The team's `null` means
|
||||
"inherit"; an empty colour object is collapsed to `null` server-side so a
|
||||
team toggling `brandingEnabled = true` without filling in colours doesn't
|
||||
silently override the org's defaults with nothing.
|
||||
|
||||
### Sanitiser (`packages/lib/utils/sanitize-branding-css.ts`)
|
||||
|
||||
PostCSS + `postcss-selector-parser`. Runs on save only.
|
||||
|
||||
- Drops selectors containing `::before`/`::after`/`::backdrop`/`::marker` or
|
||||
the universal `*`.
|
||||
- Drops integrity-breaking properties (`display`, `position`, `transform`,
|
||||
layout-affecting dimensions, text-hiding properties).
|
||||
- Drops declaration values containing `url(`, `expression(`, `@import`,
|
||||
`javascript:`.
|
||||
- Strips `!important`.
|
||||
- Allows `@media` only; drops other at-rules.
|
||||
- **Does not** rewrite selectors. Scoping happens at render time via native
|
||||
CSS nesting under `.documenso-branded { ... }`.
|
||||
- Final-pass tripwire: if a literal `</style` somehow survives serialization,
|
||||
reject the entire output. PostCSS already escapes `<` to `\3c` whenever it
|
||||
would form `</...`; the explicit check is belt-and-braces in case a future
|
||||
serializer regresses.
|
||||
- Returns `{ css, warnings[] }`. Warnings are surfaced in the UI.
|
||||
|
||||
Border-radius is the only token interpolated raw into a `<style>` block; it
|
||||
is regex-validated (`CSS_LENGTH_REGEX`) at both the Zod schema and the
|
||||
runtime `toNativeCssVars` call. Belt-and-braces against schema drift.
|
||||
|
||||
### Render (`apps/remix/app/components/general/recipient-branding.tsx`)
|
||||
|
||||
Each recipient loader calls `loadRecipientBrandingByTeamId` and threads the
|
||||
payload through to `<RecipientBranding>`, which emits a single
|
||||
nonce-attributed `<style>`:
|
||||
|
||||
```
|
||||
.documenso-branded {
|
||||
--background: ...; ...
|
||||
<user css>
|
||||
}
|
||||
```
|
||||
|
||||
Native CSS nesting expands user rules under the wrapper. The body class is
|
||||
applied unconditionally to recipient routes in `root.tsx` via `useMatches()`
|
||||
so portaled Radix content (dialogs, popovers, tooltips, dropdowns) inherits
|
||||
the scope.
|
||||
|
||||
CSP for recipient routes already supports `<style nonce>`; no policy
|
||||
changes needed.
|
||||
|
||||
### Plan gate
|
||||
|
||||
`organisationClaim.flags.embedSigningWhiteLabel || !IS_BILLING_ENABLED()`.
|
||||
Self-hosted instances always allow. The outer paywall for logo/URL/details
|
||||
stays on `allowCustomBranding` (Team plan and up); only the new
|
||||
colour/CSS section is Platform-only.
|
||||
|
||||
### UI (`apps/remix/app/components/forms/branding-preferences-form.tsx`)
|
||||
|
||||
Extends the existing branding form. Six `<ColorPicker showHex>` (rewritten
|
||||
to use the native `<input type="color">` instead of `react-colorful`, which
|
||||
was removed) in a 2-col grid, plus a free-text radius input and an
|
||||
`<Accordion>` revealing a mono `<Textarea>`. Defaults come from
|
||||
`packages/lib/constants/theme.ts` (light-mode hex mirror of `theme.css`).
|
||||
|
||||
Warnings from the sanitiser are surfaced in an `<Alert variant="warning">`
|
||||
after save, and the `brandingCss` textarea is re-synced from the persisted
|
||||
value so the user sees exactly what was stored. Other fields are
|
||||
deliberately NOT reset on settings refetch — that would clobber in-flight
|
||||
edits.
|
||||
|
||||
### TRPC
|
||||
|
||||
`update-organisation-settings` and `update-team-settings` accept the new
|
||||
fields, run them through `sanitizeBrandingCss` + `normalizeBrandingColors`,
|
||||
and return any sanitiser warnings to the client. The team route treats
|
||||
`null` as "inherit"; an empty post-sanitisation string is collapsed to
|
||||
`null` (team) so an empty override doesn't mask the org's CSS.
|
||||
|
||||
## Known accepted limitations
|
||||
|
||||
- The sanitiser does not prevent hostile-but-syntactically-valid CSS
|
||||
(`color: transparent`, low-contrast values, etc.). The customer is
|
||||
branding **their own** signing pages — we focus on integrity (no
|
||||
overlay/hide/exfiltrate), not aesthetic policing.
|
||||
- User rules targeting `body`/`html`/`:root` no-op once nested under the
|
||||
wrapper class. Documented for users.
|
||||
- CSS nesting baseline is Chrome 120+ / Firefox 117+ / Safari 16.5+.
|
||||
Acceptable for the Platform-tier audience.
|
||||
- No automated `theme.css` ↔ `theme.ts` sync check; fat comment in
|
||||
`theme.ts` reminds devs to update both.
|
||||
- Per-section team inherit is coarse — `brandingEnabled = null` inherits
|
||||
everything from the org. Per-field inherit toggles are deferred.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Live preview, embed-route sanitiser unification, email/PDF certificate
|
||||
branding, custom font upload, the full ~30 colour tokens in the picker UI,
|
||||
wiring `hidePoweredBy` through to the actual footer.
|
||||
+23
-1
@@ -160,8 +160,16 @@ NEXT_PRIVATE_REDIS_PREFIX="documenso"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
# OPTIONAL: Leave blank to disable billing.
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||
# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal).
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to disable email/password signup only.
|
||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to block new-account creation through Google. Existing linked users can still sign in.
|
||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to block new-account creation through Microsoft. Existing linked users can still sign in.
|
||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to block new-account creation through OIDC (including the organisation portal).
|
||||
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
|
||||
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
||||
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
||||
@@ -203,3 +211,17 @@ NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||
|
||||
# [[PLAIN SUPPORT]]
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
|
||||
# [[DOCUMENT CONVERSION]]
|
||||
# OPTIONAL: Base URL of a Gotenberg-compatible service used to convert uploaded
|
||||
# DOCX files to PDF on the server. When unset, DOCX uploads are disabled and
|
||||
# only PDF is accepted. The dev docker compose exposes Gotenberg on port 3005.
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_URL="http://localhost:3005"
|
||||
# OPTIONAL: Per-request timeout in milliseconds for the conversion service.
|
||||
# Defaults to 30000 (30s) if unset.
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS=30000
|
||||
# OPTIONAL: HTTP Basic auth credentials for the conversion service. Set both
|
||||
# when the service is started with `--api-enable-basic-auth` (the dev compose
|
||||
# does this; the matching values there are `documenso` / `password`).
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=password
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: 'Setup node and cache node_modules'
|
||||
name: 'Setup node'
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
@@ -16,25 +16,7 @@ runs:
|
||||
shell: bash
|
||||
run: corepack enable npm
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v3
|
||||
id: cache-node-modules
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
apps/*/node_modules
|
||||
key: modules-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
npm ci --no-audit
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
name: Install playwright binaries
|
||||
description: 'Install playwright, cache and restore if necessary'
|
||||
description: 'Install playwright'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Cache playwright
|
||||
id: cache-playwright
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
${{ github.workspace }}/node_modules/playwright
|
||||
key: playwright-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: playwright-
|
||||
|
||||
- name: Install playwright
|
||||
if: steps.cache-playwright.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps
|
||||
shell: bash
|
||||
|
||||
@@ -41,14 +41,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -56,13 +48,3 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
tags: documenso-${{ github.sha }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
- # Temp fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: npm
|
||||
|
||||
- name: Install Octokit
|
||||
run: npm install @octokit/rest@18
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'PR Labeler'
|
||||
|
||||
on:
|
||||
- pull_request_target
|
||||
- pull_request
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: npm
|
||||
|
||||
- name: Install Octokit
|
||||
run: npm install @octokit/rest@18
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'Validate PR Name'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
|
||||
+9
-9
@@ -1,24 +1,24 @@
|
||||
---
|
||||
title: Authoring
|
||||
title: Editor
|
||||
description: Embed document, template, and envelope creation directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
In addition to embedding signing, Documenso supports embedded authoring. It allows your users to create and edit documents, templates, and envelopes without leaving your application.
|
||||
In addition to embedding signing, Documenso supports embedded editor. It allows your users to create and edit documents, templates, and envelopes without leaving your application.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
Embedded editor is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
|
||||
Contact sales for access.
|
||||
</Callout>
|
||||
|
||||
## Versions
|
||||
|
||||
Embedded authoring is available in two versions:
|
||||
Embedded editor is available in two versions:
|
||||
|
||||
- **[V1 Authoring](/docs/developers/embedding/authoring/v1)** — Works with V1 Documents and Templates.
|
||||
- **[V2 Authoring](/docs/developers/embedding/authoring/v2)** — Works with Envelopes, which are the unified model for documents and templates.
|
||||
- **[V1 Editor](/docs/developers/embedding/editor/v1)** — Works with V1 Documents and Templates.
|
||||
- **[V2 Editor](/docs/developers/embedding/editor/v2)** — Works with Envelopes, which are the unified model for documents and templates.
|
||||
|
||||
### Comparison
|
||||
|
||||
@@ -32,7 +32,7 @@ Embedded authoring is available in two versions:
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
Before using any authoring component, obtain a presign token from your backend:
|
||||
Before using any editor component, obtain a presign token from your backend:
|
||||
|
||||
```
|
||||
POST /api/v2/embedding/create-presign-token
|
||||
@@ -50,7 +50,7 @@ See the [API documentation](https://openapi.documenso.com/reference#tag/embeddin
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [V1 Authoring](/docs/developers/embedding/authoring/v1) — Create and edit documents and templates using V1 components
|
||||
- [V2 Authoring](/docs/developers/embedding/authoring/v2) — Create and edit envelopes using V2 components
|
||||
- [V1 Editor](/docs/developers/embedding/editor/v1) — Create and edit documents and templates using V1 components
|
||||
- [V2 Editor](/docs/developers/embedding/editor/v2) — Create and edit envelopes using V2 components
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) — Customize the appearance of embedded components
|
||||
- [SDKs](/docs/developers/embedding/sdks) — Framework-specific SDK documentation
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Authoring",
|
||||
"title": "Editor",
|
||||
"pages": ["v1", "v2"]
|
||||
}
|
||||
+9
-9
@@ -1,21 +1,21 @@
|
||||
---
|
||||
title: V1 Authoring
|
||||
title: V1 Editor
|
||||
description: Embed V1 document and template creation directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
V1 authoring components allow your users to create and edit documents and templates using the V1 Documents and Templates API without leaving your application.
|
||||
V1 editor components allow your users to create and edit documents and templates using the V1 Documents and Templates API without leaving your application.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
Embedded editor is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
|
||||
Contact sales for access.
|
||||
</Callout>
|
||||
|
||||
## Components
|
||||
|
||||
The SDK provides four V1 authoring components:
|
||||
The SDK provides four V1 editor components:
|
||||
|
||||
| Component | Purpose |
|
||||
| ----------------------- | ----------------------- |
|
||||
@@ -29,7 +29,7 @@ The SDK provides four V1 authoring components:
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
All authoring components require a **presign token** for authentication. See the [Authoring overview](/docs/developers/embedding/authoring) for details on obtaining presign tokens.
|
||||
All editor components require a **presign token** for authentication. See the [Editor overview](/docs/developers/embedding/editor) for details on obtaining presign tokens.
|
||||
|
||||
|
||||
<Callout type="warn">
|
||||
@@ -131,7 +131,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
|
||||
|
||||
## Props
|
||||
|
||||
### All Authoring Components
|
||||
### All Editor Components
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ------------------ | --------- | -------- | -------------------------------------------------------- |
|
||||
@@ -143,7 +143,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
|
||||
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
|
||||
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
| `features` | `object` | No | Feature toggles for the editor experience |
|
||||
|
||||
### Update Components Only
|
||||
|
||||
@@ -157,7 +157,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
|
||||
|
||||
## Feature Toggles
|
||||
|
||||
Customize what options are available in the authoring experience:
|
||||
Customize what options are available in the editor experience:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocumentV1
|
||||
@@ -294,7 +294,7 @@ Pass extra props to the iframe for testing experimental features:
|
||||
## See Also
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Signing embed concepts and props
|
||||
- [V2 Authoring](/docs/developers/embedding/authoring/v2) - V2 envelope authoring
|
||||
- [V2 Editor](/docs/developers/embedding/editor/v2) - V2 envelope editor
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Documents API](/docs/developers/api/documents) - Create documents via API
|
||||
- [Templates API](/docs/developers/api/templates) - Create templates via API
|
||||
+13
-13
@@ -1,21 +1,21 @@
|
||||
---
|
||||
title: V2 Authoring
|
||||
title: V2 Editor
|
||||
description: Embed envelope creation and editing directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
V2 authoring components allow your users to create and edit envelopes without leaving your application. Envelopes are the unified model for documents and templates in the V2 API.
|
||||
V2 editor components allow your users to create and edit envelopes without leaving your application. Envelopes are the unified model for documents and templates in the V2 API.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
Embedded editor is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
|
||||
Contact sales for access.
|
||||
</Callout>
|
||||
|
||||
## Components
|
||||
|
||||
The SDK provides two V2 authoring components:
|
||||
The SDK provides two V2 editor components:
|
||||
|
||||
| Component | Purpose |
|
||||
| ---------------------- | ------------------------ |
|
||||
@@ -26,7 +26,7 @@ The SDK provides two V2 authoring components:
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
All authoring components require a **presign token** for authentication. See the [Authoring overview](/docs/developers/embedding/authoring) for details on obtaining presign tokens.
|
||||
All editor components require a **presign token** for authentication. See the [Editor overview](/docs/developers/embedding/editor) for details on obtaining presign tokens.
|
||||
|
||||
<Callout type="warn">
|
||||
A presigned token is NOT an API token
|
||||
@@ -100,7 +100,7 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
|
||||
## Props
|
||||
|
||||
### All V2 Authoring Components
|
||||
### All V2 Editor Components
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ---------------- | --------- | -------- | -------------------------------------------------------- |
|
||||
@@ -113,7 +113,7 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `user` | `object` | No | Current user info. When provided, enables the "Add Myself" button in the recipients list. Object with optional `email` and `name` fields |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
| `features` | `object` | No | Feature toggles for the editor experience |
|
||||
|
||||
### Create Component Only
|
||||
|
||||
@@ -132,7 +132,7 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
|
||||
## Feature Toggles
|
||||
|
||||
V2 authoring provides rich, structured feature toggles organized into sections. Pass a partial configuration to customize the authoring experience — any omitted fields will use their defaults.
|
||||
V2 editor provides rich, structured feature toggles organized into sections. Pass a partial configuration to customize the editor experience — any omitted fields will use their defaults.
|
||||
|
||||
```jsx
|
||||
<EmbedCreateEnvelope
|
||||
@@ -160,7 +160,7 @@ V2 authoring provides rich, structured feature toggles organized into sections.
|
||||
|
||||
### General
|
||||
|
||||
Controls the overall authoring flow and UI:
|
||||
Controls the overall editor flow and UI:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| ------------------------------- | --------- | ------- | ------------------------------------------------ |
|
||||
@@ -188,7 +188,7 @@ Controls envelope configuration options. Set to `null` to hide envelope settings
|
||||
|
||||
### Actions
|
||||
|
||||
Controls available actions during authoring:
|
||||
Controls available actions during editing:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| ------------------ | --------- | ------- | ------------------------ |
|
||||
@@ -221,7 +221,7 @@ Controls recipient configuration options. Set to `null` to prevent any recipient
|
||||
|
||||
### Disabling Steps
|
||||
|
||||
You can also disable entire steps of the authoring flow. This allows you to skip steps that are not relevant to your use case:
|
||||
You can also disable entire steps of the editor flow. This allows you to skip steps that are not relevant to your use case:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateEnvelope
|
||||
@@ -338,7 +338,7 @@ const EnvelopeManager = ({ presignToken }) => {
|
||||
|
||||
## See Also
|
||||
|
||||
- [Authoring Overview](/docs/developers/embedding/authoring) - V1 vs V2 comparison and presign tokens
|
||||
- [V1 Authoring](/docs/developers/embedding/authoring/v1) - V1 document and template authoring
|
||||
- [Editor Overview](/docs/developers/embedding/editor) - V1 vs V2 comparison and presign tokens
|
||||
- [V1 Editor](/docs/developers/embedding/editor/v1) - V1 document and template editor
|
||||
- [Embedding Overview](/docs/developers/embedding) - Signing embed concepts and props
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
@@ -6,14 +6,14 @@ description: Embed document signing experiences directly in your application usi
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Embedded Signing vs Embedded Authoring
|
||||
## Embedded Signing vs Embedded Editor
|
||||
|
||||
Documenso offers two types of embedding:
|
||||
|
||||
- **Embedded Signing** lets you embed the signing experience in your application. Your users sign documents without leaving your site. Available on Teams Plan and above.
|
||||
- **Embedded Authoring** lets you embed document and template _creation and editing_ in your application. This is an [Enterprise](/docs/policies/enterprise-edition) feature (also available as a Platform Plan add-on). See the [Authoring](/docs/developers/embedding/authoring) guide.
|
||||
- **Embedded Editor** lets you embed document and template _creation and editing_ in your application. This is an [Enterprise](/docs/policies/enterprise-edition) feature (also available as a Platform Plan add-on). See the [Editor](/docs/developers/embedding/editor) guide.
|
||||
|
||||
This page covers **embedded signing**. If you need your users to create or edit documents inside your app, see [Authoring](/docs/developers/embedding/authoring).
|
||||
This page covers **embedded signing**. If you need your users to create or edit documents inside your app, see [Editor](/docs/developers/embedding/editor).
|
||||
|
||||
---
|
||||
|
||||
@@ -229,9 +229,9 @@ Receives an object with:
|
||||
href="/docs/developers/embedding/css-variables"
|
||||
/>
|
||||
<Card
|
||||
title="Authoring"
|
||||
title="Editor"
|
||||
description="Embed document and template creation."
|
||||
href="/docs/developers/embedding/authoring"
|
||||
href="/docs/developers/embedding/editor"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Embedding",
|
||||
"pages": ["sdks", "direct-links", "css-variables", "authoring"]
|
||||
"pages": ["sdks", "direct-links", "css-variables", "editor"]
|
||||
}
|
||||
|
||||
@@ -89,4 +89,4 @@ export class SigningComponent {
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -93,4 +93,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -133,4 +133,4 @@ const DocumentSigning = ({ token }: { token: string }) => {
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -93,4 +93,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -101,4 +101,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -104,4 +104,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -53,8 +53,8 @@ The Enterprise Edition is required when you:
|
||||
- Document Action Reauthentication (Passkeys and 2FA)
|
||||
- 21 CFR Part 11 Compliance
|
||||
- Email Domains (custom sender addresses)
|
||||
- Embed Authoring
|
||||
- Embed Authoring White Label
|
||||
- Embed Editor
|
||||
- Embed Editor White Label
|
||||
- Custom signing certificates
|
||||
- Priority feature requests
|
||||
|
||||
|
||||
@@ -19,16 +19,19 @@ Use the limitless plans as much as you like. They are meant to offer a lot. Plea
|
||||
|
||||
### Do
|
||||
|
||||
- Sign as many documents as you need with the individual plan for your single business or organisation
|
||||
- Use the API and automation tools to automate your signing workflows
|
||||
- Experiment with plans and integrations while testing what you want to build
|
||||
- Use team or platform plans to run your workflows, even with significant volume, as long as it aligns with the plan’s intended purpose.
|
||||
- Experiment and automate freely within the plan features.
|
||||
- If volume grows beyond what’s sustainable on your plan, we’ll reach out to discuss an upgrade.
|
||||
- Assume that extreme usage will lead to us contacting you. You can scale up—or scale back. It’s about finding the right fit.
|
||||
|
||||
### Don't
|
||||
|
||||
- Use an individual account API to power a platform or product
|
||||
- Run a large company signing thousands of documents per day on a small team plan
|
||||
- Expect enterprise-level support on a fair support plan
|
||||
- Overthink this policy — if you are a paying customer, we want you to win
|
||||
- Use an individual account's API to power a platform or product.
|
||||
- Run a large company signing thousands of documents per day on a small team plan.
|
||||
- Expect enterprise-level support on a fair support plan (i.e. business edition).
|
||||
- Use a team plan to power an external platform or commercial product or platform beyond moderate testing.
|
||||
- Expect a platform plan to support enterprise-level volumes indefinitely without a conversation.
|
||||
- Don’t expect the platform plan to cover enterprise-scale volume or support. If you reach that point, we’ll reach out to guide you to the right fit.
|
||||
- Don’t overthink this – if you’re building something valuable, we want to see you succeed. If we need to talk, we will.
|
||||
|
||||
## Rate Limits
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: AI Recipient & Field Detection (Self-hosting)
|
||||
title: AI Recipient & Field Detection
|
||||
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
---
|
||||
title: Document Conversion
|
||||
description: Enable DOCX uploads on a self-hosted Documenso instance by running a Gotenberg sidecar that converts Word documents to PDF.
|
||||
---
|
||||
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
## Overview
|
||||
|
||||
Documenso can accept `.docx` uploads in addition to PDFs. When a user uploads a Word document, the Documenso server sends it to a [Gotenberg](https://gotenberg.dev) service which uses LibreOffice to convert it to PDF. The converted PDF is what gets stored, signed, and downloaded. The original DOCX is discarded.
|
||||
|
||||
This feature is **opt-in for self-hosted instances**. When the conversion service is not configured, DOCX uploads are rejected in the UI and only PDFs are accepted.
|
||||
|
||||
| Property | Value |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| Conversion engine | [Gotenberg](https://gotenberg.dev) + LibreOffice |
|
||||
| Input format | `.docx` (Office Open XML Word documents) |
|
||||
| Output format | PDF |
|
||||
| Network requirement | Documenso must reach the Gotenberg HTTP API |
|
||||
| Default request timeout | 30 seconds per file |
|
||||
| Failure handling | An internal circuit breaker opens for 30 seconds after a failure |
|
||||
|
||||
<Callout type="info">
|
||||
Only `.docx` is accepted. Legacy `.doc`, `.odt`, `.rtf`, and other LibreOffice-supported formats
|
||||
are rejected at the upload step even when Gotenberg is configured.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- A running Gotenberg 8 instance with the LibreOffice module (`gotenberg/gotenberg:8-libreoffice` or newer).
|
||||
- Network reachability from the Documenso container to the Gotenberg HTTP API.
|
||||
- A version of Documenso that includes the document conversion feature.
|
||||
|
||||
## Build the Gotenberg Image
|
||||
|
||||
The upstream `gotenberg/gotenberg:8-libreoffice` image works out of the box, but it ships only **metric-compatible font substitutes** (Carlito for Calibri, Liberation for Arial/Times/Courier). Layout widths are preserved but documents will look noticeably different from Word.
|
||||
|
||||
For better fidelity, especially for non-Latin scripts, build a derived image that adds Microsoft Core Fonts and additional language fonts. The Documenso repository ships a reference Dockerfile at [`docker/development/Dockerfile.gotenberg`](https://github.com/documenso/documenso/blob/main/docker/development/Dockerfile.gotenberg) that you can use as a starting point:
|
||||
|
||||
```dockerfile
|
||||
FROM gotenberg/gotenberg:8-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
RUN echo "deb http://deb.debian.org/debian trixie contrib non-free" \
|
||||
> /etc/apt/sources.list.d/contrib.list \
|
||||
&& echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" \
|
||||
| debconf-set-selections \
|
||||
&& apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates \
|
||||
ttf-mscorefonts-installer \
|
||||
fonts-symbola \
|
||||
fonts-noto-extra \
|
||||
fonts-hosny-amiri \
|
||||
fonts-thai-tlwg \
|
||||
fonts-sil-padauk \
|
||||
fonts-sarai \
|
||||
fonts-samyak-taml \
|
||||
culmus \
|
||||
libfribidi0 \
|
||||
libharfbuzz0b \
|
||||
&& fc-cache -f \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
USER gotenberg
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
`ttf-mscorefonts-installer` accepts the Microsoft Core Fonts EULA on your behalf via debconf. By
|
||||
installing this image you are agreeing to those licence terms. Review them before publishing the
|
||||
image.
|
||||
</Callout>
|
||||
|
||||
Build and publish the image to a registry you control:
|
||||
|
||||
```bash
|
||||
docker build -t registry.example.com/documenso/gotenberg:8 \
|
||||
-f Dockerfile.gotenberg .
|
||||
docker push registry.example.com/documenso/gotenberg:8
|
||||
```
|
||||
|
||||
If you do not need extra fonts, skip the build step entirely and reference `gotenberg/gotenberg:8-libreoffice` directly in the next section.
|
||||
|
||||
## Deploy the Service
|
||||
|
||||
The Gotenberg service should run **alongside** your Documenso container, not exposed to the public internet. The conversion service has no built-in authorisation beyond HTTP Basic auth, so it should sit on a private network or behind your existing reverse proxy.
|
||||
|
||||
<Tabs items={['Docker Compose', 'Kubernetes', 'External Instance']}>
|
||||
<Tab value="Docker Compose">
|
||||
|
||||
Add a `gotenberg` service to the `compose.yml` you use for Documenso:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gotenberg:
|
||||
image: registry.example.com/documenso/gotenberg:8
|
||||
# Or use upstream directly:
|
||||
# image: gotenberg/gotenberg:8-libreoffice
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_USERNAME}
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_PASSWORD}
|
||||
command:
|
||||
- gotenberg
|
||||
- --api-enable-basic-auth
|
||||
- --libreoffice-deny-private-ips
|
||||
- --api-timeout=500s
|
||||
- --libreoffice-auto-start
|
||||
- --libreoffice-start-timeout=300s
|
||||
- --pdfengines-disable-routes
|
||||
- --webhook-disable
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-fsS', 'http://localhost:3000/health']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
documenso:
|
||||
# existing config
|
||||
environment:
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL: http://gotenberg:3000
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME: ${GOTENBERG_USERNAME}
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD: ${GOTENBERG_PASSWORD}
|
||||
depends_on:
|
||||
gotenberg:
|
||||
condition: service_healthy
|
||||
```
|
||||
|
||||
Do **not** publish Gotenberg's port (`3000`) to the host. Documenso reaches it over the internal Docker network using the service name (`http://gotenberg:3000`).
|
||||
|
||||
</Tab>
|
||||
<Tab value="Kubernetes">
|
||||
|
||||
Create a Deployment, Service, and Secret. Example manifests:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: gotenberg-auth
|
||||
namespace: documenso
|
||||
stringData:
|
||||
username: documenso
|
||||
password: replace-me-with-a-strong-password
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gotenberg
|
||||
namespace: documenso
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels: { app: gotenberg }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: gotenberg }
|
||||
spec:
|
||||
containers:
|
||||
- name: gotenberg
|
||||
image: registry.example.com/documenso/gotenberg:8
|
||||
args:
|
||||
- gotenberg
|
||||
- --api-enable-basic-auth
|
||||
- --libreoffice-deny-private-ips
|
||||
- --api-timeout=500s
|
||||
- --libreoffice-auto-start
|
||||
- --libreoffice-start-timeout=300s
|
||||
- --pdfengines-disable-routes
|
||||
- --webhook-disable
|
||||
env:
|
||||
- name: GOTENBERG_API_BASIC_AUTH_USERNAME
|
||||
valueFrom: { secretKeyRef: { name: gotenberg-auth, key: username } }
|
||||
- name: GOTENBERG_API_BASIC_AUTH_PASSWORD
|
||||
valueFrom: { secretKeyRef: { name: gotenberg-auth, key: password } }
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
readinessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
livenessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
initialDelaySeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gotenberg
|
||||
namespace: documenso
|
||||
spec:
|
||||
selector: { app: gotenberg }
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
```
|
||||
|
||||
Then reference the in-cluster URL from Documenso's environment:
|
||||
|
||||
```
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=http://gotenberg.documenso.svc.cluster.local:3000
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value="External Instance">
|
||||
|
||||
Documenso does not have to colocate with Gotenberg. You can point it at any reachable Gotenberg deployment: a managed instance, a shared internal service, or a Gotenberg-compatible API.
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=https://gotenberg.internal.example.com
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
|
||||
```
|
||||
|
||||
The remote instance must:
|
||||
|
||||
- Expose the LibreOffice route `/forms/libreoffice/convert`.
|
||||
- Be reachable from the Documenso container with low enough latency that the 30 second per-request timeout is comfortable.
|
||||
- Be on a private network or require authentication. Uploaded documents are sent to it as multipart form data and may contain sensitive content.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Recommended Gotenberg Flags
|
||||
|
||||
The flags in the examples above are not arbitrary. Each one matters for a production deployment.
|
||||
|
||||
| Flag | Why it matters |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--api-enable-basic-auth` | Requires HTTP Basic credentials on every API route. Without this, anyone with network access to the container can convert arbitrary documents. |
|
||||
| `--libreoffice-deny-private-ips` | Rejects any outbound fetch LibreOffice tries to make to private, loopback, link-local, or cloud-metadata addresses while processing a document. Mitigates SSRF via malicious `.docx` files that embed `TargetMode="External"` references. Requires Gotenberg 8.32.0. |
|
||||
| `--api-timeout=500s` | Server-side request ceiling. Documenso aborts at 30 s by default, so this is a safety net for very large documents. |
|
||||
| `--libreoffice-auto-start` | Starts LibreOffice at container boot so the first request is not slow. |
|
||||
| `--libreoffice-start-timeout=300s`| Allows LibreOffice up to 5 minutes to come up under load. |
|
||||
| `--pdfengines-disable-routes` | Disables the PDF engines routes Documenso does not use. Shrinks the attack surface. |
|
||||
| `--webhook-disable` | Disables webhook callbacks. Documenso uses synchronous requests only. |
|
||||
|
||||
## Configure Documenso
|
||||
|
||||
Set the following environment variables on the Documenso container and restart it.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL`| Base URL of the Gotenberg service (e.g., `http://gotenberg:3000`). Leave unset to disable the feature. |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------------------- | ------- | -------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` | | HTTP Basic auth username. Set when Gotenberg runs with `--api-enable-basic-auth`. |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD` | | HTTP Basic auth password. Set together with the username. |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS`| `30000` | Per-request timeout in milliseconds. Increase for very large documents. |
|
||||
|
||||
<Callout type="info">
|
||||
When `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is set, the public flag
|
||||
`NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED` is derived automatically on server start. You do not
|
||||
need to set it yourself, and setting it manually has no effect.
|
||||
</Callout>
|
||||
|
||||
### Example `.env` Snippet
|
||||
|
||||
```bash
|
||||
# Document conversion (DOCX -> PDF)
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=http://gotenberg:3000
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS=60000
|
||||
```
|
||||
|
||||
## Verify the Setup
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Restart the Documenso container
|
||||
|
||||
Restart so the new environment variables are picked up.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Confirm Gotenberg is healthy
|
||||
|
||||
From a shell inside the Documenso container or another container on the same network:
|
||||
|
||||
```bash
|
||||
curl -fsS http://gotenberg:3000/health
|
||||
```
|
||||
|
||||
The endpoint is exempt from basic auth and should return `200 OK`.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Upload a test DOCX
|
||||
|
||||
In the Documenso web UI, open **Documents** and try uploading a small `.docx` file. The upload dropzone should accept it, and after a few seconds the editor should open with the converted PDF.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Check the server logs
|
||||
|
||||
Successful conversions log a `document_conversion_attempt` event with `result: "success"`, the duration, and the file size. Failures log the same event with `result: "error"` and an error code (`CONVERSION_SERVICE_UNAVAILABLE`, `CONVERSION_FAILED`, or `UNSUPPORTED_FILE_TYPE`).
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Treat the conversion service as untrusted internal infrastructure.** Documents pass through Gotenberg in plain form. Run it on a private network and require HTTP Basic auth.
|
||||
- **Run with `--libreoffice-deny-private-ips`.** Without this flag, a malicious `.docx` can trigger LibreOffice to fetch URLs from your internal network (SSRF).
|
||||
- **Disable unused routes.** `--pdfengines-disable-routes` and `--webhook-disable` reduce attack surface. Documenso only uses the LibreOffice convert route.
|
||||
- **Do not expose Gotenberg to the public internet.** Even with basic auth, this is a document-processing service with a non-trivial CPU and memory footprint; exposing it invites abuse.
|
||||
- **Rotate credentials.** Rotating the basic auth secret is a config change in both Gotenberg and Documenso, followed by a restart of each.
|
||||
|
||||
## Resource Sizing
|
||||
|
||||
Conversion is CPU- and memory-bound on LibreOffice. As a starting point:
|
||||
|
||||
| Workload | Suggested resources |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
| Light (a few DOCX per minute) | 1 vCPU, 1 GB RAM |
|
||||
| Moderate (sustained uploads) | 2 vCPU, 2 GB RAM |
|
||||
| Heavy / multi-tenant | Horizontally scale Gotenberg replicas behind a load balancer |
|
||||
|
||||
Gotenberg is stateless. Each container handles one or more concurrent requests independently. Scale horizontally rather than vertically once a single replica is saturated.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordions type="multiple">
|
||||
<Accordion title="DOCX uploads are rejected with 'Only PDF and DOCX files are allowed'">
|
||||
The Documenso server does not see `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL`. Check the value is set
|
||||
on the running container (`docker exec documenso printenv | grep DOCUMENT_CONVERSION`) and
|
||||
restart after changing it.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Uploads fail with 'Document conversion service is currently unavailable'">
|
||||
Documenso could not reach Gotenberg. Verify:
|
||||
|
||||
- The URL in `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is resolvable from the Documenso container
|
||||
(use the Docker service name or in-cluster DNS, not `localhost`).
|
||||
- Gotenberg's `/health` endpoint returns `200`.
|
||||
- Basic auth credentials match between the two services.
|
||||
|
||||
After repeated failures, an internal circuit breaker opens for 30 seconds. Subsequent uploads
|
||||
will fail fast during that window; this is intentional and self-recovers.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Uploads fail with 'Failed to convert document to PDF'">
|
||||
Gotenberg was reachable but returned a non-2xx response. Check the Gotenberg container logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f gotenberg
|
||||
```
|
||||
|
||||
Common causes: corrupted `.docx` file, exotic embedded objects LibreOffice cannot render, or a
|
||||
file that genuinely exceeded the conversion timeout. Increase
|
||||
`NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS` for very large documents.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Converted PDFs look different from the Word document">
|
||||
LibreOffice is not byte-identical to Microsoft Word. Layout, font metrics, and complex elements
|
||||
(Charts, SmartArt, ActiveX controls) may differ. To improve fidelity:
|
||||
|
||||
- Use the custom Dockerfile in this guide to install Microsoft Core Fonts and additional
|
||||
language fonts.
|
||||
- Make sure any custom fonts referenced by your documents are installed in the Gotenberg image.
|
||||
- For pixel-perfect output, ask users to export to PDF from Word before uploading.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Form controls in the DOCX appear blank or missing">
|
||||
Documenso disables Gotenberg's `exportFormFields` flag during conversion. Word content controls
|
||||
(`<w:sdt>`) become static graphics in the output PDF, which prevents Documenso's later
|
||||
flattening step from making them invisible. This is intentional. Use Documenso fields
|
||||
(signature, text, date, etc.) for anything that needs to be filled in by signers.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Conversion is slow on the first request">
|
||||
LibreOffice starts lazily by default. Pass `--libreoffice-auto-start` to Gotenberg so it warms
|
||||
up at container boot. Allow up to a minute on first start before considering the service
|
||||
unhealthy.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="The circuit breaker keeps opening">
|
||||
Repeated failures open an in-process circuit breaker for 30 seconds. If you see this in
|
||||
production, the underlying problem is the Gotenberg service. Check its logs, resource usage,
|
||||
and connectivity. The breaker is per-process and resets on restart.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Upload Documents (User Guide)](/docs/users/documents/upload) - End-user view of DOCX uploads
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) - Full configuration reference
|
||||
- [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose) - Compose-based deployment patterns
|
||||
- [Gotenberg Documentation](https://gotenberg.dev/docs/getting-started/introduction) - Upstream Gotenberg docs
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Advanced
|
||||
description: Optional configuration for OAuth providers, AI features, and other advanced settings.
|
||||
description: Optional configuration for OAuth providers, AI features, document conversion, and other advanced settings.
|
||||
---
|
||||
|
||||
<Cards>
|
||||
@@ -14,4 +14,9 @@ description: Optional configuration for OAuth providers, AI features, and other
|
||||
description="Enable AI-powered recipient and field detection."
|
||||
href="/docs/self-hosting/configuration/advanced/ai-features"
|
||||
/>
|
||||
<Card
|
||||
title="Document Conversion"
|
||||
description="Accept DOCX uploads by running a Gotenberg sidecar that converts Word documents to PDF."
|
||||
href="/docs/self-hosting/configuration/advanced/document-conversion"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Advanced",
|
||||
"pages": ["oauth-providers", "ai-features"]
|
||||
"pages": ["oauth-providers", "document-conversion", "ai-features"]
|
||||
}
|
||||
|
||||
@@ -224,28 +224,41 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||
| Variable | Description | Default |
|
||||
| -------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Disable all signup methods application-wide | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only. SSO signup is unaffected | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google. Existing Google-linked users can still sign in | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||
|
||||
### Signup Restrictions
|
||||
|
||||
You can control who is allowed to create accounts on your instance using two environment variables:
|
||||
You can control who is allowed to create accounts on your instance with the following environment variables:
|
||||
|
||||
- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups.
|
||||
- **`NEXT_PUBLIC_DISABLE_SIGNUP`** (master switch): Set to `true` to block all new signups across every method (email/password, Google, Microsoft, OIDC). When set, this also blocks new-account creation through the organisation OIDC authentication portal.
|
||||
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`**: Set to `true` to disable email/password signup only. SSO signup is still allowed.
|
||||
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`**: Set to `true` to block brand-new account creation through the matching SSO provider. Existing users with the provider already linked can still sign in, and existing users can still link the provider to their account. `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` also blocks new-account creation through the organisation authentication portal.
|
||||
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
|
||||
|
||||
Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
|
||||
Sign-in for existing users is never affected, only the creation of brand-new accounts.
|
||||
|
||||
When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list.
|
||||
Both the master switch and the domain allowlist apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
|
||||
|
||||
When both the master switch and the domain allowlist are set, the master switch takes precedence. Signups are blocked regardless of the domain list.
|
||||
|
||||
```bash
|
||||
# Allow signups only from specific domains
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
|
||||
# Allow OIDC signup only; block email/password, Google, Microsoft
|
||||
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true"
|
||||
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true"
|
||||
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
||||
|
||||
# Or disable signups entirely
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
```
|
||||
@@ -266,6 +279,23 @@ AI features must also be enabled in organisation/team settings after configurati
|
||||
|
||||
---
|
||||
|
||||
## Document Conversion
|
||||
|
||||
Documenso can accept `.docx` uploads by sending them to a [Gotenberg](https://gotenberg.dev) service that converts them to PDF. When `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is unset, DOCX uploads are rejected and only PDFs are accepted.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` | Base URL of the Gotenberg service (e.g., `http://gotenberg:3000`). Unset disables the feature. | |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` | HTTP Basic auth username. Required when Gotenberg runs with `--api-enable-basic-auth`. | |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD` | HTTP Basic auth password. Set together with the username. | |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS` | Per-request timeout in milliseconds. Increase for very large documents. | `30000` |
|
||||
|
||||
The public flag `NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED` is derived automatically from `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` on server start. Do not set it manually.
|
||||
|
||||
For setup, image-build instructions, and security recommendations, see [Document Conversion](/docs/self-hosting/configuration/advanced/document-conversion).
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs
|
||||
|
||||
Documenso supports multiple background job providers for processing emails, documents, webhooks, and scheduled tasks.
|
||||
@@ -329,7 +359,7 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
|
||||
|
||||
## Enterprise Features
|
||||
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed authoring, 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.
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------ | ------------------------------------------------ |
|
||||
@@ -371,6 +401,10 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
||||
|
||||
# Signup restrictions (optional)
|
||||
# NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
```
|
||||
|
||||
|
||||
@@ -155,7 +155,13 @@ PORT=3000
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
|
||||
|
||||
# Signup restrictions (optional)
|
||||
# Master switch — disables every signup method
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=false
|
||||
# Per-method switches (optional). Each disables brand-new account creation through that method.
|
||||
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=true
|
||||
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=true
|
||||
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
|
||||
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
||||
```
|
||||
|
||||
@@ -252,7 +258,10 @@ Navigate to the signup page and create your account. Verify your email address
|
||||
<Callout type="info">
|
||||
All accounts created through signup are regular user accounts. Admin access must be granted
|
||||
directly in the database. Once your accounts are set up, consider disabling public signups by
|
||||
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with
|
||||
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`. For finer control, use the per-method switches
|
||||
`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`, `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`,
|
||||
`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`, `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`, or restrict
|
||||
signups to specific email domains with
|
||||
`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`.
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -100,7 +100,11 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch — disable all signup methods | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
|
||||
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
||||
|
||||
@@ -153,7 +153,11 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------- | ---------------------------------- | ------- |
|
||||
| `PORT` | Application port | `3000` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch — disable all signup methods | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
||||
|
||||
@@ -122,7 +122,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
|
||||
|
||||
## Enterprise Edition
|
||||
|
||||
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed authoring 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.
|
||||
|
||||
|
||||
@@ -11,16 +11,41 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
| Limitation | Value |
|
||||
| ----------------------- | ----------------------------------- |
|
||||
| Supported format | PDF only |
|
||||
| Supported formats | PDF, DOCX |
|
||||
| Maximum file size | 50MB (configurable for self-hosted) |
|
||||
| Encrypted PDFs | Not supported |
|
||||
| Password-protected PDFs | Not supported |
|
||||
| Legacy `.doc` files | Not supported (convert to DOCX) |
|
||||
|
||||
<Callout type="warn">
|
||||
Documenso does not support password-protected or encrypted PDF files. Remove encryption before
|
||||
uploading.
|
||||
</Callout>
|
||||
|
||||
## Supported Formats
|
||||
|
||||
Documenso accepts two file formats:
|
||||
|
||||
- **PDF** (`.pdf`): used as-is. **Recommended.**
|
||||
- **Word** (`.docx`): converted to PDF on the server during upload. The converted PDF is what recipients sign.
|
||||
|
||||
Other formats (`.doc`, `.odt`, `.rtf`, images) are not supported. Convert them to PDF or DOCX before uploading.
|
||||
|
||||
<Callout type="warn">
|
||||
**Upload a PDF whenever you can.** DOCX files are converted to PDF using LibreOffice, which is not
|
||||
byte-identical to Microsoft Word. Spacing, line breaks, fonts, and complex elements (tables,
|
||||
charts, headers, footers) can shift in the converted PDF. For the final document to look exactly
|
||||
the way you designed it, export to PDF from Word, Google Docs, or Pages and upload the PDF
|
||||
directly.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
DOCX support requires the document conversion service. It is enabled on
|
||||
[documenso.com](https://app.documenso.com). Self-hosted instances must
|
||||
[configure it](/docs/self-hosting/configuration/advanced/document-conversion) before DOCX uploads
|
||||
are accepted.
|
||||
</Callout>
|
||||
|
||||
## Upload Methods
|
||||
|
||||

|
||||
@@ -38,15 +63,15 @@ You can upload documents in two ways:
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Drag and drop your PDF
|
||||
### Drag and drop your file
|
||||
|
||||
Drag a PDF file from your computer and drop it anywhere on the page.
|
||||
Drag a PDF or DOCX file from your computer and drop it anywhere on the page.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Wait for the upload to complete
|
||||
|
||||
The document will process and the editor will open when ready.
|
||||
The document will process and the editor will open when ready. DOCX files take a few extra seconds while they are converted to PDF.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -70,7 +95,7 @@ You can upload documents in two ways:
|
||||
<Step>
|
||||
### Select your file
|
||||
|
||||
Choose a PDF file from your computer.
|
||||
Choose a PDF or DOCX file from your computer.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
@@ -81,16 +106,32 @@ You can upload documents in two ways:
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## DOCX Conversion
|
||||
|
||||
We always recommend uploading a PDF rather than a DOCX. If you have the original document open in Word, Google Docs, or Pages, export to PDF from there and upload the PDF. The result is guaranteed to match what you see on screen.
|
||||
|
||||
If you do upload a `.docx` file, Documenso converts it to PDF before adding it to the envelope. The original `.docx` is discarded. Only the converted PDF is stored, signed, and downloaded.
|
||||
|
||||
Things to keep in mind when uploading DOCX:
|
||||
|
||||
- **The converted PDF will not be pixel-identical to your Word document.** Conversion uses LibreOffice, which renders most documents faithfully but differs from Microsoft Word in subtle ways. Spacing, font metrics, line breaks, and complex layout features can shift.
|
||||
- **Always review the converted PDF before adding fields or sending.** Open the document in the editor and scroll through every page to confirm it looks the way you expect.
|
||||
- **Form controls are flattened.** Word content controls (drop-downs, date pickers, checkboxes) become static text or graphics. Use Documenso fields for anything that needs to be filled in.
|
||||
- **Fonts not installed on the server fall back to substitutes.** On documenso.com, common fonts (Calibri, Arial, Times New Roman, etc.) are installed. On self-hosted instances, font fidelity depends on the operator's setup.
|
||||
- **Tracked changes and comments are preserved as they appear in Word.** Accept or reject changes and remove comments before uploading if you do not want them in the final document.
|
||||
|
||||
If the converted PDF does not match what you expect, export the document to PDF from Word, Google Docs, or another tool and upload the PDF directly.
|
||||
|
||||
## Uploading Multiple Documents
|
||||
|
||||
You can upload multiple PDF files at once to create a single envelope containing multiple documents. The number of files you can upload per envelope depends on your plan.
|
||||
You can upload multiple files at once to create a single envelope containing multiple documents. The number of files you can upload per envelope depends on your plan.
|
||||
|
||||
To upload multiple files:
|
||||
|
||||
- Select multiple PDF files when using the file picker, or
|
||||
- Drag and drop multiple PDF files at once
|
||||
- Select multiple PDF or DOCX files when using the file picker, or
|
||||
- Drag and drop multiple files at once
|
||||
|
||||
All files in the same upload become part of the same envelope and share the same recipients and signing workflow.
|
||||
You can mix PDF and DOCX files in the same upload. All files become part of the same envelope and share the same recipients and signing workflow.
|
||||
|
||||
<Callout type="info">
|
||||
If you need separate signing workflows for each document, upload them individually.
|
||||
@@ -114,15 +155,37 @@ The document remains in `Draft` status until you send it. You can close the edit
|
||||
<Accordion title="File is larger than 50MB">
|
||||
Reduce the file size before uploading:
|
||||
|
||||
- Compress images within the PDF
|
||||
- Compress images within the document
|
||||
- Remove unnecessary pages
|
||||
- Use a PDF compression tool
|
||||
- Use a PDF compression tool (for PDFs) or save with images downsampled (for DOCX)
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Only PDF files are allowed">
|
||||
Convert your document to PDF before uploading. Most applications (Word, Google Docs, etc.) can
|
||||
export to PDF format.
|
||||
<Accordion title="Only PDF and DOCX files are allowed">
|
||||
Documenso accepts PDF and DOCX. For other formats (`.doc`, `.odt`, `.rtf`, etc.), export to PDF
|
||||
from your editor (Word, Google Docs, Pages) and upload the PDF.
|
||||
|
||||
If you are self-hosted and DOCX is rejected, the [document conversion
|
||||
service](/docs/self-hosting/configuration/advanced/document-conversion) is not configured on your
|
||||
instance.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="DOCX upload fails with a conversion error">
|
||||
The document conversion service was reachable but could not convert the file. Common causes:
|
||||
|
||||
- The `.docx` file is corrupted. Open it in Word, save a new copy, and try again.
|
||||
- The file uses very unusual fonts or embedded objects that LibreOffice cannot render.
|
||||
- The file is unusually large or complex and exceeded the conversion timeout.
|
||||
|
||||
If the problem persists, export the document to PDF from Word and upload the PDF directly.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="DOCX upload fails with 'conversion service unavailable'">
|
||||
The document conversion service is down or temporarily unreachable. Try again in a minute. If you
|
||||
self-host, check the [document conversion
|
||||
service](/docs/self-hosting/configuration/advanced/document-conversion) logs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="You cannot upload encrypted PDFs">
|
||||
|
||||
@@ -134,6 +134,13 @@ Leave empty to allow any domain authenticated by your identity provider.
|
||||
team.
|
||||
</Callout>
|
||||
|
||||
### Allow Personal Organisations
|
||||
|
||||
Controls whether users signing in via SSO for the first time also receive their own personal organisation in addition to joining your organisation.
|
||||
|
||||
- **Enabled**: New SSO users get a personal organisation where they can create and manage their own documents independently.
|
||||
- **Disabled**: New SSO users only join your organisation and do not receive a personal organisation.
|
||||
|
||||
## User Provisioning
|
||||
|
||||
When a user signs in through your SSO portal for the first time:
|
||||
|
||||
@@ -296,12 +296,27 @@ const config = {
|
||||
},
|
||||
{
|
||||
source: '/developers/embedding/authoring',
|
||||
destination: '/docs/developers/embedding/authoring',
|
||||
destination: '/docs/developers/embedding/editor',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/developers/embedding/authoring/:path*',
|
||||
destination: '/docs/developers/embedding/editor/:path*',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/developers/embedded-authoring',
|
||||
destination: '/docs/developers/embedding/authoring',
|
||||
destination: '/docs/developers/embedding/editor',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/docs/developers/embedding/authoring',
|
||||
destination: '/docs/developers/embedding/editor',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/docs/developers/embedding/authoring/:path*',
|
||||
destination: '/docs/developers/embedding/editor/:path*',
|
||||
permanent: true,
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AdminOrganisationDeleteDialogProps = {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const AdminOrganisationDeleteDialog = ({
|
||||
organisationId,
|
||||
organisationName,
|
||||
trigger,
|
||||
}: AdminOrganisationDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const deleteMessage = t`delete ${organisationName}`;
|
||||
|
||||
const ZAdminDeleteOrganisationFormSchema = z.object({
|
||||
organisationName: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: t`You must enter '${deleteMessage}' to proceed` }),
|
||||
}),
|
||||
sendEmailToOwner: z.boolean(),
|
||||
});
|
||||
|
||||
type TAdminDeleteOrganisationFormSchema = z.infer<typeof ZAdminDeleteOrganisationFormSchema>;
|
||||
|
||||
const form = useForm<TAdminDeleteOrganisationFormSchema>({
|
||||
resolver: zodResolver(ZAdminDeleteOrganisationFormSchema),
|
||||
defaultValues: {
|
||||
organisationName: '',
|
||||
sendEmailToOwner: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteOrganisation } = trpc.admin.organisation.delete.useMutation();
|
||||
|
||||
const onFormSubmit = async (values: TAdminDeleteOrganisationFormSchema) => {
|
||||
try {
|
||||
await deleteOrganisation({
|
||||
organisationId,
|
||||
organisationName,
|
||||
sendEmailToOwner: values.sendEmailToOwner,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Deletion scheduled`,
|
||||
description: t`The organisation will be deleted in the background. Documents will be orphaned, not deleted.`,
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: t`We encountered an error while attempting to delete this organisation. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Delete organisation</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are about to delete <span className="font-semibold">{organisationName}</span>. This action is not
|
||||
reversible. All teams will be removed and all documents will be orphaned to the deleted-account service
|
||||
account.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this
|
||||
deletion.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organisationName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendEmailToOwner"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="admin-delete-organisation-send-email"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<label
|
||||
htmlFor="admin-delete-organisation-send-email"
|
||||
className="font-normal text-muted-foreground text-sm leading-snug"
|
||||
>
|
||||
<Trans>Email the organisation owner to notify them of the deletion.</Trans>
|
||||
</label>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -14,10 +14,10 @@ export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const handleRangeChange = (value: string) => {
|
||||
const handleRangeChange = (value: DateRange) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
dateRange: value as DateRange,
|
||||
dateRange: value,
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
@@ -15,6 +19,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
@@ -28,17 +33,20 @@ const ZBrandingPreferencesFormSchema = z.object({
|
||||
.nullish(),
|
||||
brandingUrl: z.string().url().optional().or(z.literal('')),
|
||||
brandingCompanyDetails: z.string().max(500).optional(),
|
||||
brandingColors: ZCssVarsSchema.default({}),
|
||||
brandingCss: z.string().max(10_000).default(''),
|
||||
});
|
||||
|
||||
export type TBrandingPreferencesFormSchema = z.infer<typeof ZBrandingPreferencesFormSchema>;
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
TeamGlobalSettings,
|
||||
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails'
|
||||
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails' | 'brandingColors' | 'brandingCss'
|
||||
>;
|
||||
|
||||
export type BrandingPreferencesFormProps = {
|
||||
canInherit?: boolean;
|
||||
hasAdvancedBranding: boolean;
|
||||
settings: SettingsSubset;
|
||||
onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise<void>;
|
||||
context: 'Team' | 'Organisation';
|
||||
@@ -46,11 +54,13 @@ export type BrandingPreferencesFormProps = {
|
||||
|
||||
export function BrandingPreferencesForm({
|
||||
canInherit = false,
|
||||
hasAdvancedBranding,
|
||||
settings,
|
||||
onFormSubmit,
|
||||
context,
|
||||
}: BrandingPreferencesFormProps) {
|
||||
const { t } = useLingui();
|
||||
const nonce = useCspNonce();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
@@ -58,12 +68,17 @@ export function BrandingPreferencesForm({
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
|
||||
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
||||
const initialColors = parsedColors.success ? parsedColors.data : {};
|
||||
|
||||
const form = useForm<TBrandingPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
values: {
|
||||
brandingEnabled: settings.brandingEnabled ?? null,
|
||||
brandingUrl: settings.brandingUrl ?? '',
|
||||
brandingLogo: undefined,
|
||||
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
|
||||
brandingColors: initialColors,
|
||||
brandingCss: settings.brandingCss ?? '',
|
||||
},
|
||||
resolver: zodResolver(ZBrandingPreferencesFormSchema),
|
||||
});
|
||||
@@ -304,6 +319,225 @@ export function BrandingPreferencesForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasAdvancedBranding && (
|
||||
<div className="relative flex w-full flex-col gap-y-6">
|
||||
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Brand Colours</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormDescription className="mt-1 mb-4">
|
||||
<Trans>Customise the colours used on your signing pages.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingColors.background"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Background</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>Base background colour.</Trans>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.background}
|
||||
onChange={(color) => field.onChange(color)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingColors.foreground"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Foreground</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>Base text colour.</Trans>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.foreground}
|
||||
onChange={(color) => field.onChange(color)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingColors.primary"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Primary</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>Primary action colour.</Trans>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.primary}
|
||||
onChange={(color) => field.onChange(color)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingColors.primaryForeground"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Primary Foreground</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>Text colour on primary buttons.</Trans>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
|
||||
onChange={(color) => field.onChange(color)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingColors.border"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Border</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>Default border colour.</Trans>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.border}
|
||||
onChange={(color) => field.onChange(color)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingColors.ring"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Ring</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>Focus ring colour.</Trans>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
nonce={nonce}
|
||||
value={field.value ?? ''}
|
||||
defaultValue={DEFAULT_BRAND_COLORS.ring}
|
||||
onChange={(color) => field.onChange(color)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingColors.radius"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Border Radius</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={DEFAULT_BRAND_RADIUS}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Border radius size in REM units (e.g. 0.5rem).</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="custom-css" className="border-none">
|
||||
<AccordionTrigger className="rounded border px-3 py-2 text-left text-foreground hover:bg-muted/40 hover:no-underline">
|
||||
<Trans>Advanced — Custom CSS</Trans>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="-mx-1 px-1 pt-4 text-muted-foreground text-sm leading-relaxed">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingCss"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={`/* Write CSS targeting your signing pages. Selectors are scoped automatically. */
|
||||
.my-button {
|
||||
background: red;
|
||||
}`}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and
|
||||
pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be
|
||||
shown after you save.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
@@ -58,18 +58,20 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||
export type SignUpFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
isEmailPasswordSignupEnabled?: boolean;
|
||||
isGoogleSignupEnabled?: boolean;
|
||||
isMicrosoftSignupEnabled?: boolean;
|
||||
isOidcSignupEnabled?: boolean;
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
export const SignUpForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
isEmailPasswordSignupEnabled = true,
|
||||
isGoogleSignupEnabled,
|
||||
isMicrosoftSignupEnabled,
|
||||
isOidcSignupEnabled,
|
||||
returnTo,
|
||||
}: SignUpFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
@@ -86,7 +88,7 @@ export const SignUpForm = ({
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
const hasSocialAuthEnabled = isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
@@ -145,7 +147,7 @@ export const SignUpForm = ({
|
||||
const onSignUpWithGoogleClick = async () => {
|
||||
try {
|
||||
await authClient.google.signIn();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
|
||||
@@ -157,7 +159,7 @@ export const SignUpForm = ({
|
||||
const onSignUpWithMicrosoftClick = async () => {
|
||||
try {
|
||||
await authClient.microsoft.signIn();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
|
||||
@@ -169,7 +171,7 @@ export const SignUpForm = ({
|
||||
const onSignUpWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
|
||||
@@ -235,72 +237,80 @@ export const SignUpForm = ({
|
||||
<Form {...form}>
|
||||
<form className="flex w-full flex-1 flex-col gap-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Full Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isEmailPasswordSignupEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Full Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signature"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Sign Here</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<SignaturePadDialog disabled={isSubmitting} value={value} onChange={(v) => onChange(v ?? '')} />
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signature"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Sign Here</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<SignaturePadDialog
|
||||
disabled={isSubmitting}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
@@ -325,7 +335,7 @@ export const SignUpForm = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
{isGoogleSignupEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
@@ -339,7 +349,7 @@ export const SignUpForm = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
{isMicrosoftSignupEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
@@ -353,7 +363,7 @@ export const SignUpForm = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
{isOidcSignupEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
@@ -377,9 +387,11 @@ export const SignUpForm = ({
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<Button loading={form.formState.isSubmitting} type="submit" size="lg" className="mt-6 w-full">
|
||||
<Trans>Create account</Trans>
|
||||
</Button>
|
||||
{isEmailPasswordSignupEnabled && (
|
||||
<Button loading={form.formState.isSubmitting} type="submit" size="lg" className="mt-6 w-full">
|
||||
<Trans>Create account</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
<p className="mt-6 text-muted-foreground text-xs">
|
||||
|
||||
@@ -140,6 +140,15 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||
() => msg`You have reached the limit of the number of files per envelope.`,
|
||||
)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => msg`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
@@ -115,6 +116,15 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred during upload.`);
|
||||
|
||||
toast({
|
||||
@@ -158,9 +168,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
});
|
||||
};
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
accept: getAllowedUploadMimeTypes(),
|
||||
multiple: true,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
maxFiles: maximumEnvelopeItemCount,
|
||||
@@ -183,7 +191,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-md text-muted-foreground">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
<Trans>Drag and drop your document here</Trans>
|
||||
</p>
|
||||
|
||||
{isUploadDisabled && IS_BILLING_ENABLED() && (
|
||||
|
||||
@@ -119,6 +119,15 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { toNativeCssVarsString } from '~/utils/css-vars';
|
||||
|
||||
export type RecipientBrandingPayload = {
|
||||
allowCustomBranding: boolean;
|
||||
colors?: TCssVarsSchema | null;
|
||||
css?: string | null;
|
||||
};
|
||||
|
||||
export type RecipientBrandingProps = {
|
||||
branding: RecipientBrandingPayload | null | undefined;
|
||||
cspNonce: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a `<style nonce>` block for a recipient route, scoped to the
|
||||
* `.documenso-branded` wrapper rendered in `_recipient+/_layout.tsx`.
|
||||
*
|
||||
* Both the CSS variables (from `branding.colors`) and the user's custom CSS
|
||||
* (from `branding.css`) are emitted inside a single nested rule so the user
|
||||
* doesn't need to scope their own selectors — native CSS nesting handles it:
|
||||
*
|
||||
* .documenso-branded {
|
||||
* --background: ...;
|
||||
* .my-class { color: red; }
|
||||
* }
|
||||
*
|
||||
* Equivalent to `.documenso-branded .my-class { color: red; }` after expansion.
|
||||
*
|
||||
* The user's CSS is sanitised at write time (`sanitizeBrandingCss`) and stored
|
||||
* in the DB as-is — no per-render parsing.
|
||||
*
|
||||
* Why both SSR `<style>` and a `useEffect` injection?
|
||||
*
|
||||
* The rendered `<style>` covers the initial server render so the first paint
|
||||
* already has the branding applied — without it, the page would flash the
|
||||
* default theme before hydration.
|
||||
*
|
||||
* The `useEffect` covers in-app client-side navigations. When the user
|
||||
* navigates between recipient routes via the router, the server render
|
||||
* doesn't run again, so React reconciles the existing DOM. If the loader
|
||||
* data changes (e.g. a different recipient with different branding), the
|
||||
* SSR'd `<style>` from the previous page may persist or be reused, leading
|
||||
* to stale or inconsistent branding. Appending a fresh `<style>` to
|
||||
* `document.head` and removing it on cleanup guarantees the active branding
|
||||
* matches the current route on both initial load and subsequent navigations.
|
||||
*/
|
||||
export const RecipientBranding = ({ branding, cspNonce }: RecipientBrandingProps) => {
|
||||
const varsString = toNativeCssVarsString(branding?.colors ?? {});
|
||||
|
||||
const userCss = branding?.css ?? '';
|
||||
|
||||
const hasVars = varsString.trim().length > 0;
|
||||
const hasUserCss = userCss.trim().length > 0;
|
||||
|
||||
const innerBody = `${hasVars ? `${varsString}\n` : ''}${hasUserCss ? userCss : ''}`.trim();
|
||||
const css = `.documenso-branded { ${innerBody} }`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!branding?.allowCustomBranding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasVars && !hasUserCss) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('nonce', cspNonce ?? '');
|
||||
style.textContent = css;
|
||||
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, [branding, cspNonce, css, hasUserCss, hasVars]);
|
||||
|
||||
if (!branding?.allowCustomBranding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasVars && !hasUserCss) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <style nonce={cspNonce}>{css}</style>;
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { detect, fromHtmlTag } from '@lingui/detect-locale';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import posthog from 'posthog-js';
|
||||
import { StrictMode, startTransition, useEffect } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
@@ -15,9 +14,11 @@ function PosthogInit() {
|
||||
|
||||
useEffect(() => {
|
||||
if (postHogConfig) {
|
||||
posthog.init(postHogConfig.key, {
|
||||
api_host: postHogConfig.host,
|
||||
capture_exceptions: true,
|
||||
void import('posthog-js').then(({ default: posthog }) => {
|
||||
posthog.init(postHogConfig.key, {
|
||||
api_host: postHogConfig.host,
|
||||
capture_exceptions: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useLoaderData,
|
||||
useMatches,
|
||||
} from 'react-router';
|
||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||
|
||||
@@ -110,6 +111,13 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const [theme] = useTheme();
|
||||
|
||||
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
||||
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
||||
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
||||
// route tree, attached directly to document.body).
|
||||
const matches = useMatches();
|
||||
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
|
||||
|
||||
return (
|
||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
|
||||
<head>
|
||||
@@ -137,7 +145,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
||||
<script nonce={nonce(cspNonce)}>0</script>
|
||||
</head>
|
||||
<body>
|
||||
<body className={isRecipientRoute ? 'documenso-branded' : undefined}>
|
||||
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
|
||||
{/* {licenseStatus === '?' && (
|
||||
<div className="bg-destructive text-destructive-foreground">
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Link, useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog';
|
||||
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
@@ -64,9 +65,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
const organisationId = params.id;
|
||||
|
||||
const { data: organisation, isLoading: isLoadingOrganisation } = trpc.admin.organisation.get.useQuery({
|
||||
organisationId,
|
||||
});
|
||||
const { data: organisation, isLoading: isLoadingOrganisation } = trpc.admin.organisation.get.useQuery(
|
||||
{
|
||||
organisationId,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: createStripeCustomer, isPending: isCreatingStripeCustomer } =
|
||||
trpc.admin.stripe.createCustomer.useMutation({
|
||||
@@ -398,6 +404,31 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsHeader
|
||||
title={t`Danger Zone`}
|
||||
subtitle={t`Irreversible actions for this organisation`}
|
||||
className="mt-16"
|
||||
/>
|
||||
|
||||
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="destructive">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Delete organisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible
|
||||
via the deleted-account service account.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<AdminOrganisationDeleteDialog organisationId={organisation.id} organisationName={organisation.name} />
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useRevalidator } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import type { Route } from './+types/site-settings';
|
||||
|
||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
||||
@@ -45,6 +45,8 @@ export async function loader() {
|
||||
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||
const { banner } = loaderData;
|
||||
|
||||
const nonce = useCspNonce();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
@@ -142,7 +144,7 @@ export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
<ColorPicker {...field} nonce={nonce} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
@@ -162,7 +164,7 @@ export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
<ColorPicker {...field} nonce={nonce} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import {
|
||||
@@ -35,7 +37,13 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
|
||||
|
||||
const {
|
||||
data: organisationWithSettings,
|
||||
isLoading: isLoadingOrganisation,
|
||||
refetch: refetchOrganisation,
|
||||
} = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
@@ -43,7 +51,7 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||
|
||||
let uploadedBrandingLogo: string | undefined;
|
||||
|
||||
@@ -56,20 +64,40 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateOrganisationSettings({
|
||||
const result = await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
brandingEnabled: brandingEnabled ?? undefined,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors,
|
||||
brandingCss,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
// Refetch so the form re-syncs with the sanitised CSS that was
|
||||
// actually persisted (sanitiser may have dropped rules).
|
||||
await refetchOrganisation();
|
||||
|
||||
const warnings = result?.cssWarnings ?? [];
|
||||
setCssWarnings(warnings);
|
||||
|
||||
if (warnings.length > 0) {
|
||||
toast({
|
||||
title: t`Branding preferences updated with warnings`,
|
||||
description: plural(warnings.length, {
|
||||
one: '# CSS rule was dropped during sanitisation.',
|
||||
other: '# CSS rules were dropped during sanitisation.',
|
||||
}),
|
||||
duration: 8000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
@@ -103,9 +131,36 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
context="Organisation"
|
||||
hasAdvancedBranding={
|
||||
organisationWithSettings.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED()
|
||||
}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
{cssWarnings.length > 0 && (
|
||||
<Alert variant="warning" className="mt-6">
|
||||
<AlertTitle>
|
||||
<Trans>CSS rules were dropped during sanitisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-5">
|
||||
{cssWarnings.map((warning, index) => (
|
||||
<li key={index}>
|
||||
{warning.detail}
|
||||
{warning.line !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
<Trans>(line {warning.line})</Trans>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
BrandingPreferencesForm,
|
||||
@@ -11,27 +16,32 @@ import {
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Branding Preferences`);
|
||||
}
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
|
||||
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
|
||||
|
||||
const {
|
||||
data: teamWithSettings,
|
||||
isLoading: isLoadingTeam,
|
||||
refetch: refetchTeam,
|
||||
} = trpc.team.get.useQuery({
|
||||
teamReference: team.id,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
|
||||
const canCustomBranding =
|
||||
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||
|
||||
let uploadedBrandingLogo: string | undefined;
|
||||
|
||||
@@ -44,20 +54,40 @@ export default function TeamsSettingsPage() {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateTeamSettings({
|
||||
const result = await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl: brandingUrl || null,
|
||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||
brandingColors,
|
||||
brandingCss,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
// Refetch so the form re-syncs with the sanitised CSS that was
|
||||
// actually persisted (sanitiser may have dropped rules).
|
||||
await refetchTeam();
|
||||
|
||||
const warnings = result?.cssWarnings ?? [];
|
||||
setCssWarnings(warnings);
|
||||
|
||||
if (warnings.length > 0) {
|
||||
toast({
|
||||
title: t`Branding preferences updated with warnings`,
|
||||
description: plural(warnings.length, {
|
||||
one: '# CSS rule was dropped during sanitisation.',
|
||||
other: '# CSS rules were dropped during sanitisation.',
|
||||
}),
|
||||
duration: 8000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
@@ -85,10 +115,35 @@ export default function TeamsSettingsPage() {
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
canInherit={true}
|
||||
hasAdvancedBranding={canCustomBranding}
|
||||
context="Team"
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
{cssWarnings.length > 0 && (
|
||||
<Alert variant="warning" className="mt-6">
|
||||
<AlertTitle>
|
||||
<Trans>CSS rules were dropped during sanitisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-5">
|
||||
{cssWarnings.map((warning, index) => (
|
||||
<li key={index}>
|
||||
{warning.detail}
|
||||
{warning.line !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
<Trans>(line {warning.line})</Trans>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: i18n._(msg`Sign Document - Documenso`) },
|
||||
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet, noimageindex' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout to handle scenarios where the user is a recipient of a given resource
|
||||
* where we do not care whether they are authenticated or not.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -20,6 +21,8 @@ import { DocumentSigningAuthProvider } from '~/components/general/document-signi
|
||||
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/_index';
|
||||
@@ -125,6 +128,7 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
},
|
||||
select: {
|
||||
internalVersion: true,
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,12 +136,17 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({
|
||||
teamId: directEnvelope.teamId,
|
||||
});
|
||||
|
||||
if (directEnvelope.internalVersion === 2) {
|
||||
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 2,
|
||||
payload: payloadV2,
|
||||
branding,
|
||||
} as const);
|
||||
}
|
||||
|
||||
@@ -146,17 +155,20 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
return superLoaderJson({
|
||||
version: 1,
|
||||
payload: payloadV1,
|
||||
branding,
|
||||
} as const);
|
||||
}
|
||||
|
||||
export default function DirectTemplatePage() {
|
||||
const data = useSuperLoaderData<typeof loader>();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
if (data.version === 2) {
|
||||
return <DirectSigningPageV2 data={data.payload} />;
|
||||
}
|
||||
|
||||
return <DirectSigningPageV1 data={data.payload} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={data.branding} cspNonce={cspNonce} />
|
||||
{data.version === 2 ? <DirectSigningPageV2 data={data.payload} /> : <DirectSigningPageV1 data={data.payload} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
@@ -35,6 +36,8 @@ import { DocumentSigningPageViewV1 } from '~/components/general/document-signing
|
||||
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/_index';
|
||||
@@ -272,6 +275,7 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
envelope: {
|
||||
select: {
|
||||
internalVersion: true,
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -281,12 +285,17 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({
|
||||
teamId: foundRecipient.envelope.teamId,
|
||||
});
|
||||
|
||||
if (foundRecipient.envelope.internalVersion === 2) {
|
||||
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 2,
|
||||
payload: payloadV2,
|
||||
branding,
|
||||
} as const);
|
||||
}
|
||||
|
||||
@@ -295,17 +304,20 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
return superLoaderJson({
|
||||
version: 1,
|
||||
payload: payloadV1,
|
||||
branding,
|
||||
} as const);
|
||||
}
|
||||
|
||||
export default function SigningPage() {
|
||||
const data = useSuperLoaderData<typeof loader>();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
if (data.version === 2) {
|
||||
return <SigningPageV2 data={data.payload} />;
|
||||
}
|
||||
|
||||
return <SigningPageV1 data={data.payload} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={data.branding} cspNonce={cspNonce} />
|
||||
{data.version === 2 ? <SigningPageV2 data={data.payload} /> : <SigningPageV1 data={data.payload} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
@@ -8,7 +10,6 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
|
||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
@@ -25,6 +26,8 @@ import { match } from 'ts-pattern';
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { ClaimAccount } from '~/components/general/claim-account';
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
import type { Route } from './+types/complete';
|
||||
|
||||
@@ -46,6 +49,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
@@ -66,6 +71,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientEmail: recipient.email,
|
||||
branding,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -77,7 +83,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const recipientName =
|
||||
recipient.name || fields.find((field) => field.type === FieldType.NAME)?.customText || recipient.email;
|
||||
|
||||
const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true';
|
||||
const canSignUp = !isExistingUser && isSignupEnabledForProvider('email');
|
||||
|
||||
const canRedirectToFolder = user && document.userId === user.id && document.folderId && document.team?.url;
|
||||
|
||||
@@ -92,6 +98,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
document,
|
||||
recipient,
|
||||
returnToHomePath,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +107,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const {
|
||||
isDocumentAccessValid,
|
||||
@@ -110,6 +118,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
recipient,
|
||||
recipientEmail,
|
||||
returnToHomePath,
|
||||
branding,
|
||||
} = loaderData;
|
||||
|
||||
// Poll signing status every few seconds
|
||||
@@ -131,154 +140,163 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
const signingStatus = signingStatusData?.status ?? 'PENDING';
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<DocumentSigningAuthPageView email={recipientEmail} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28', {
|
||||
'pt-0 lg:pt-0 xl:pt-0': canSignUp,
|
||||
})}
|
||||
>
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div
|
||||
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||
canSignUp,
|
||||
})}
|
||||
className={cn(
|
||||
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28',
|
||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('flex flex-col items-center', {
|
||||
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||
canSignUp,
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||
{document.title}
|
||||
</span>
|
||||
</Badge>
|
||||
<div
|
||||
className={cn('flex flex-col items-center', {
|
||||
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||
{document.title}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Document Signed</Trans>}
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>Document Viewed</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
|
||||
</h2>
|
||||
|
||||
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||
.with({ status: 'COMPLETED' }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-documenso-700">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Everyone has signed</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.with({ status: 'PROCESSING' }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-orange-600">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<span className="text-sm">
|
||||
<Trans>Processing document</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Waiting for others to sign</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Document no longer available to sign</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||
.with({ status: 'COMPLETED' }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>Everyone has signed! You will receive an email copy of the signed document.</Trans>
|
||||
</p>
|
||||
))
|
||||
.with({ status: 'PROCESSING' }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>
|
||||
All recipients have signed. The document is being processed and you will receive an email copy
|
||||
shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>You will receive an email copy of the signed document once everyone has signed.</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>
|
||||
This document has been cancelled by the owner and is no longer available for others to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={recipient.token}
|
||||
className="w-full max-w-none md:flex-1"
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
{isDocumentCompleted(document) && (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={document.envelopeId}
|
||||
envelopeStatus={document.status}
|
||||
envelopeItems={document.envelopeItems}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Document Signed</Trans>}
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>Document Viewed</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
|
||||
</h2>
|
||||
|
||||
{user && (
|
||||
<Button asChild>
|
||||
<Link to={returnToHomePath}>
|
||||
<Trans>Go Back Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||
.with({ status: 'COMPLETED' }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-documenso-700">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Everyone has signed</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.with({ status: 'PROCESSING' }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-orange-600">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<span className="text-sm">
|
||||
<Trans>Processing document</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Waiting for others to sign</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Document no longer available to sign</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||
.with({ status: 'COMPLETED' }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>Everyone has signed! You will receive an email copy of the signed document.</Trans>
|
||||
</p>
|
||||
))
|
||||
.with({ status: 'PROCESSING' }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>
|
||||
All recipients have signed. The document is being processed and you will receive an email copy
|
||||
shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>You will receive an email copy of the signed document once everyone has signed.</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>
|
||||
This document has been cancelled by the owner and is no longer available for others to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={recipient.token}
|
||||
className="w-full max-w-none md:flex-1"
|
||||
/>
|
||||
|
||||
{isDocumentCompleted(document) && (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={document.envelopeId}
|
||||
envelopeStatus={document.status}
|
||||
envelopeItems={document.envelopeItems}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<Button asChild>
|
||||
<Link to={returnToHomePath}>
|
||||
<Trans>Go Back Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
{canSignUp && (
|
||||
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
|
||||
<h2 className="mt-8 text-center font-semibold text-xl md:mt-0">
|
||||
<Trans>Need to sign documents?</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 max-w-[55ch] text-center text-muted-foreground/60 leading-normal">
|
||||
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
|
||||
</p>
|
||||
|
||||
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
{canSignUp && (
|
||||
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
|
||||
<h2 className="mt-8 text-center font-semibold text-xl md:mt-0">
|
||||
<Trans>Need to sign documents?</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 max-w-[55ch] text-center text-muted-foreground/60 leading-normal">
|
||||
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
|
||||
</p>
|
||||
|
||||
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
@@ -10,6 +11,8 @@ import { TimerOffIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import { truncateTitle } from '~/utils/truncate-title';
|
||||
|
||||
import type { Route } from './+types/expired';
|
||||
@@ -32,6 +35,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
|
||||
|
||||
const title = document.title;
|
||||
|
||||
const recipient = await getRecipientByToken({ token }).catch(() => null);
|
||||
@@ -54,55 +59,66 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
isDocumentAccessValid: true,
|
||||
recipientEmail,
|
||||
title,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientEmail,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpiredSigningPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const { isDocumentAccessValid, recipientEmail, title } = loaderData;
|
||||
const { isDocumentAccessValid, recipientEmail, title, branding } = loaderData;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<DocumentSigningAuthPageView email={recipientEmail} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" title={title} className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncateTitle(title ?? '')}
|
||||
</Badge>
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" title={title} className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncateTitle(title ?? '')}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<TimerOffIcon className="h-10 w-10 text-orange-500" />
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<TimerOffIcon className="h-10 w-10 text-orange-500" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing Deadline Expired</Trans>
|
||||
</h2>
|
||||
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing Deadline Expired</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The signing deadline for this document has passed. Please contact the document owner if you need a new
|
||||
copy to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The signing deadline for this document has passed. Please contact the document owner if you need a new copy
|
||||
to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
@@ -12,6 +13,8 @@ import { XCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import { truncateTitle } from '~/utils/truncate-title';
|
||||
|
||||
import type { Route } from './+types/rejected';
|
||||
@@ -34,6 +37,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
@@ -60,6 +65,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
isDocumentAccessValid: true,
|
||||
recipientReference,
|
||||
truncatedTitle,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,57 +73,67 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientReference,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
export default function RejectedSigningPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const { isDocumentAccessValid, recipientReference, truncatedTitle } = loaderData;
|
||||
const { isDocumentAccessValid, recipientReference, truncatedTitle, branding } = loaderData;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientReference} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<DocumentSigningAuthPageView email={recipientReference} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<XCircle className="h-10 w-10 text-destructive" />
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<XCircle className="h-10 w-10 text-destructive" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</h2>
|
||||
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center text-center text-destructive text-sm">
|
||||
<Trans>You have rejected this document</Trans>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The document owner has been notified of your decision. They may contact you with further instructions if
|
||||
necessary.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>No further action is required from you at this time.</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center text-center text-destructive text-sm">
|
||||
<Trans>You have rejected this document</Trans>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The document owner has been notified of your decision. They may contact you with further instructions if
|
||||
necessary.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>No further action is required from you at this time.</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
@@ -10,6 +11,9 @@ import type { Team } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
import type { Route } from './+types/waiting';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
@@ -61,48 +65,55 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
||||
const documentPathForEditing = isOwnerOrTeamMember && team ? formatDocumentsPath(team.url) + '/' + document.id : null;
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
|
||||
|
||||
return {
|
||||
documentPathForEditing,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
export default function WaitingForTurnToSignPage({ loaderData }: Route.ComponentProps) {
|
||||
const { documentPathForEditing } = loaderData;
|
||||
const { documentPathForEditing, branding } = loaderData;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h2 className="font-bold text-3xl tracking-tigh">
|
||||
<Trans>Waiting for Your Turn</Trans>
|
||||
</h2>
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h2 className="font-bold text-3xl tracking-tigh">
|
||||
<Trans>Waiting for Your Turn</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
It's currently not your turn to sign. You will receive an email with instructions once it's your turn to
|
||||
sign the document.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
It's currently not your turn to sign. You will receive an email with instructions once it's your turn to
|
||||
sign the document.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-muted-foreground text-sm">
|
||||
<Trans>Please check your email for updates.</Trans>
|
||||
</p>
|
||||
<p className="mt-4 text-muted-foreground text-sm">
|
||||
<Trans>Please check your email for updates.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
{documentPathForEditing ? (
|
||||
<Button variant="link" asChild>
|
||||
<Link to={documentPathForEditing}>
|
||||
<Trans>Were you trying to edit this document instead?</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="link" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{documentPathForEditing ? (
|
||||
<Button variant="link" asChild>
|
||||
<Link to={documentPathForEditing}>
|
||||
<Trans>Were you trying to edit this document instead?</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="link" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
isSignupEnabledForProvider,
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -32,6 +32,11 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||
const isSignupEnabled =
|
||||
isSignupEnabledForProvider('email') ||
|
||||
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
|
||||
(IS_MICROSOFT_SSO_ENABLED && isSignupEnabledForProvider('microsoft')) ||
|
||||
(IS_OIDC_SSO_ENABLED && isSignupEnabledForProvider('oidc'));
|
||||
|
||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||
|
||||
@@ -45,13 +50,15 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
isSignupEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel, returnTo } = loaderData;
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } =
|
||||
loaderData;
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
@@ -95,7 +102,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
|
||||
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||
{!isEmbeddedRedirect && isSignupEnabled && (
|
||||
<p className="mt-6 text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Don't have an account?{' '}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { IS_GOOGLE_SSO_ENABLED, IS_MICROSOFT_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
isSignupEnabledForProvider,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { redirect } from 'react-router';
|
||||
@@ -14,14 +18,15 @@ export function meta() {
|
||||
}
|
||||
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
const isEmailPasswordSignupEnabled = isSignupEnabledForProvider('email');
|
||||
const isGoogleSignupEnabled = IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google');
|
||||
const isMicrosoftSignupEnabled = IS_MICROSOFT_SSO_ENABLED && isSignupEnabledForProvider('microsoft');
|
||||
const isOidcSignupEnabled = IS_OIDC_SSO_ENABLED && isSignupEnabledForProvider('oidc');
|
||||
|
||||
// SSR env variables.
|
||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||
const isAnySignupEnabled =
|
||||
isEmailPasswordSignupEnabled || isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
|
||||
|
||||
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
if (!isAnySignupEnabled) {
|
||||
throw redirect('/signin');
|
||||
}
|
||||
|
||||
@@ -30,22 +35,30 @@ export function loader({ request }: Route.LoaderArgs) {
|
||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
isEmailPasswordSignupEnabled,
|
||||
isGoogleSignupEnabled,
|
||||
isMicrosoftSignupEnabled,
|
||||
isOidcSignupEnabled,
|
||||
returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
|
||||
const {
|
||||
isEmailPasswordSignupEnabled,
|
||||
isGoogleSignupEnabled,
|
||||
isMicrosoftSignupEnabled,
|
||||
isOidcSignupEnabled,
|
||||
returnTo,
|
||||
} = loaderData;
|
||||
|
||||
return (
|
||||
<SignUpForm
|
||||
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
isEmailPasswordSignupEnabled={isEmailPasswordSignupEnabled}
|
||||
isGoogleSignupEnabled={isGoogleSignupEnabled}
|
||||
isMicrosoftSignupEnabled={isMicrosoftSignupEnabled}
|
||||
isOidcSignupEnabled={isOidcSignupEnabled}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { CSS_LENGTH_REGEX, type TCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { colord } from 'colord';
|
||||
import { toKebabCase } from 'remeda';
|
||||
|
||||
@@ -12,23 +12,50 @@ export const toNativeCssVars = (vars: TCssVarsSchema) => {
|
||||
const color = colord(value);
|
||||
const { h, s, l } = color.toHsl();
|
||||
|
||||
cssVars[`--${toKebabCase(key)}`] = `${h} ${s} ${l}`;
|
||||
// Tailwind's theme.css consumes these via `hsl(var(--token))`. CSS
|
||||
// Color 4 space-separated `hsl()` requires `%` on saturation and
|
||||
// lightness — without it, the function is invalid and the property
|
||||
// falls back to its initial value (which is why bare numeric output
|
||||
// here used to silently break customer colours).
|
||||
cssVars[`--${toKebabCase(key)}`] = `${h} ${s}% ${l}%`;
|
||||
}
|
||||
}
|
||||
|
||||
if (radius) {
|
||||
cssVars[`--radius`] = `${radius}`;
|
||||
// Defence in depth: radius is interpolated raw into the rendered <style>
|
||||
// block, so anything outside the length pattern is a CSS-injection vector.
|
||||
// The Zod schema rejects bad values at the API boundary; this re-check
|
||||
// protects against schema drift and any path that bypasses validation.
|
||||
if (radius && CSS_LENGTH_REGEX.test(radius)) {
|
||||
cssVars[`--radius`] = radius;
|
||||
}
|
||||
|
||||
return cssVars;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure-string sibling of `toNativeCssVars` — returns the same set of CSS custom
|
||||
* property declarations as a single string suitable for SSR inlining inside a
|
||||
* rule block. Does not touch the DOM.
|
||||
*
|
||||
* Example: { background: '#111', radius: '0.5rem' }
|
||||
* -> "--background: 0 0% 6.7%; --radius: 0.5rem;"
|
||||
*
|
||||
* Saturation and lightness include the `%` suffix that
|
||||
* `hsl(var(--token))` requires under CSS Color 4 space-separated syntax.
|
||||
*/
|
||||
export const toNativeCssVarsString = (vars: TCssVarsSchema): string => {
|
||||
const map = toNativeCssVars(vars);
|
||||
return Object.entries(map)
|
||||
.map(([k, v]) => `${k}: ${v};`)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => {
|
||||
const { css, cssVars } = options;
|
||||
|
||||
if (css) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = css;
|
||||
style.textContent = css;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
|
||||
/**
|
||||
* Returns the supplied CSP nonce only when rendering on the server.
|
||||
*
|
||||
@@ -19,3 +21,18 @@
|
||||
* scripts inherit trust via `'strict-dynamic'`.
|
||||
*/
|
||||
export const nonce = (value: string | undefined): string | undefined => (typeof window === 'undefined' ? value : '');
|
||||
|
||||
/**
|
||||
* Reads the per-request CSP nonce surfaced by the root loader. Use this
|
||||
* inside any non-root route component that needs to render a `<style>`,
|
||||
* `<script>`, or other element that the CSP gates by nonce.
|
||||
*
|
||||
* Centralised here so the cast is in one place — if the root loader's
|
||||
* `nonce` field is ever renamed/removed, only this function needs updating
|
||||
* (and TypeScript will catch it at the cast site).
|
||||
*/
|
||||
export const useCspNonce = (): string | undefined => {
|
||||
const rootData = useRouteLoaderData('root') as { nonce?: string } | undefined;
|
||||
|
||||
return rootData?.nonce;
|
||||
};
|
||||
|
||||
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.10.1"
|
||||
"version": "2.11.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /sign/
|
||||
Disallow: /d/
|
||||
Disallow: /embed/
|
||||
+5
-1
@@ -253,5 +253,9 @@ Here's a markdown table documenting all the provided environment variables:
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Set to `true` to disable all signup methods (incl. organisation OIDC portal). |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Set to `true` to disable email/password signup only. SSO signup is unaffected. |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Set to `true` to block new accounts via Google. Existing Google-linked users can still sign in. |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Set to `true` to block new accounts via Microsoft. Existing linked users can still sign in. |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Set to `true` to block new accounts via OIDC (incl. organisation portal). Existing users unaffected.|
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
FROM gotenberg/gotenberg:8-libreoffice
|
||||
|
||||
# Install Microsoft Core Fonts (Arial, Times New Roman, Courier New, Verdana,
|
||||
# Georgia, Comic Sans MS, Trebuchet MS, Impact, Andale Mono, Webdings) so that
|
||||
# LibreOffice can render typical Word documents faithfully. The default image
|
||||
# only ships metric-compatible substitutes (Carlito for Calibri, Liberation for
|
||||
# Arial/Times/Courier) which preserve layout widths but look noticeably wrong.
|
||||
#
|
||||
# `ttf-mscorefonts-installer` lives in the non-free repo and requires accepting
|
||||
# the Microsoft EULA, which we do non-interactively via debconf-set-selections.
|
||||
USER root
|
||||
|
||||
RUN echo "deb http://deb.debian.org/debian trixie contrib non-free" \
|
||||
> /etc/apt/sources.list.d/contrib.list \
|
||||
&& echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" \
|
||||
| debconf-set-selections \
|
||||
&& apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates \
|
||||
wget \
|
||||
unzip \
|
||||
culmus \
|
||||
ttf-mscorefonts-installer \
|
||||
fonts-symbola \
|
||||
fonts-noto-extra \
|
||||
fonts-hosny-amiri \
|
||||
fonts-thai-tlwg \
|
||||
fonts-sil-padauk \
|
||||
fonts-sarai \
|
||||
fonts-samyak-taml \
|
||||
libfribidi0 \
|
||||
libharfbuzz0b \
|
||||
&& fc-cache -f \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
USER gotenberg
|
||||
@@ -48,6 +48,52 @@ services:
|
||||
entrypoint: sh
|
||||
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
||||
|
||||
gotenberg:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.gotenberg
|
||||
image: documenso-dev-gotenberg:latest
|
||||
container_name: gotenberg
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3005:3000
|
||||
environment:
|
||||
# Basic auth credentials Gotenberg checks when `--api-enable-basic-auth`
|
||||
# is passed. Dev defaults are non-secret — match
|
||||
# `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` / `_PASSWORD` in `.env`.
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME: documenso
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD: password
|
||||
command:
|
||||
- gotenberg
|
||||
# Require basic auth on every API route — prevents anyone with network
|
||||
# access to the container from invoking conversions.
|
||||
- --api-enable-basic-auth
|
||||
# SSRF defence in depth: reject any outbound fetch LibreOffice tries to
|
||||
# make to a private/loopback/link-local/cloud-metadata address while
|
||||
# processing an uploaded document. Mitigates CVE-2026-42591 (malicious
|
||||
# docx files embedding `TargetMode="External"` references to internal
|
||||
# services). Added in Gotenberg 8.32.0.
|
||||
- --libreoffice-deny-private-ips
|
||||
# Generous server-side timeout; the Node client aborts at 30 s by
|
||||
# default, so this is just a safety net.
|
||||
- --api-timeout=500s
|
||||
# Pre-warm LibreOffice at boot so the first request isn't cold.
|
||||
- --libreoffice-auto-start
|
||||
- --libreoffice-start-timeout=300s
|
||||
# Disable surfaces we don't use to shrink the attack surface.
|
||||
- --pdfengines-disable-routes
|
||||
- --webhook-disable
|
||||
# Verbose logs for the dev compose only.
|
||||
- --log-level=debug
|
||||
healthcheck:
|
||||
# `/health` is exempt from `--api-enable-basic-auth` so the check
|
||||
# doesn't need to authenticate.
|
||||
test: ['CMD', 'curl', '-fsS', 'http://localhost:3000/health']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
minio:
|
||||
redis:
|
||||
|
||||
@@ -59,6 +59,10 @@ services:
|
||||
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
|
||||
- NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP}
|
||||
- NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP}
|
||||
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
|
||||
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
|
||||
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
|
||||
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
|
||||
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
|
||||
|
||||
Generated
+18
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -349,20 +349,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"apps/docs/node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"apps/docs/node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
@@ -420,7 +406,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -30684,6 +30670,8 @@
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
@@ -30701,6 +30689,19 @@
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"packages/lib/node_modules/postcss-selector-parser": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"packages/prettier-config": {
|
||||
"name": "@documenso/prettier-config",
|
||||
"version": "0.0.0",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { BackgroundJobStatus, DocumentStatus, EnvelopeType, Role } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout } from '../../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Helper that polls until the `admin.organisation.delete` background job for the
|
||||
* supplied organisation has finished (status COMPLETED). Returns the org id.
|
||||
*/
|
||||
const waitForOrganisationDeletionJob = async (organisationId: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
// payload is JSON; match the organisationId field.
|
||||
payload: {
|
||||
path: ['organisationId'],
|
||||
equals: organisationId,
|
||||
},
|
||||
},
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
});
|
||||
|
||||
return job?.status ?? null;
|
||||
},
|
||||
{
|
||||
message: `Background deletion job for organisation ${organisationId} did not complete in time`,
|
||||
timeout: 30_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(BackgroundJobStatus.COMPLETED);
|
||||
};
|
||||
|
||||
const waitForOrganisationToBeGone = async (organisationId: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const org = await prisma.organisation.findUnique({
|
||||
where: { id: organisationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return org === null;
|
||||
},
|
||||
{
|
||||
message: `Organisation ${organisationId} was not removed`,
|
||||
timeout: 30_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// ─── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: admin can delete an organisation via the dialog', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Danger Zone' })).toBeVisible();
|
||||
|
||||
// Open the dialog
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The Delete submit button is initially enabled but submission should fail
|
||||
// until the confirmation text matches. Type it now.
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
|
||||
// The "send email to owner" checkbox should be checked by default.
|
||||
const emailCheckbox = dialog.getByRole('checkbox');
|
||||
await expect(emailCheckbox).toBeChecked();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
// Dialog closes on success
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Background job completes and the org is removed
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
});
|
||||
|
||||
// ─── Confirmation text validation ────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: typing the wrong confirmation text prevents deletion', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Type something that does NOT match.
|
||||
await dialog.getByRole('textbox').fill('delete wrong-name');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
// Validation message should appear and the dialog should stay open.
|
||||
await expect(dialog.getByText(/You must enter/)).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Org is still there.
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: empty confirmation text prevents deletion', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
await expect(dialog.getByText(/You must enter/)).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Cancel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: clicking Cancel closes the dialog without deleting', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Fill in the correct text but cancel anyway.
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// Org still there.
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Email checkbox ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: email checkbox can be unchecked, payload reflects choice', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const emailCheckbox = dialog.getByRole('checkbox');
|
||||
|
||||
// Default is checked.
|
||||
await expect(emailCheckbox).toBeChecked();
|
||||
|
||||
// Uncheck it.
|
||||
await emailCheckbox.click();
|
||||
await expect(emailCheckbox).not.toBeChecked();
|
||||
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Verify the enqueued job payload has sendEmailToOwner=false.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = job.payload as { sendEmailToOwner?: boolean };
|
||||
return payload.sendEmailToOwner;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
});
|
||||
|
||||
// ─── Documents are orphaned, not deleted ─────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: envelopes authored by owner and members are orphaned, drafts are removed', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// Add two organisation members who will author their own envelopes.
|
||||
const [memberUser, managerUser] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }, { organisationRole: 'MANAGER' }],
|
||||
});
|
||||
|
||||
// ── Owner-authored envelopes ──────────────────────────────────────────────
|
||||
const ownerCompleted = await seedBlankDocument(owner, team.id, { key: 'owner-completed' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: ownerCompleted.id },
|
||||
data: { status: DocumentStatus.COMPLETED },
|
||||
});
|
||||
|
||||
const ownerPending = await seedBlankDocument(owner, team.id, { key: 'owner-pending' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: ownerPending.id },
|
||||
data: { status: DocumentStatus.PENDING },
|
||||
});
|
||||
|
||||
const ownerDraft = await seedBlankDocument(owner, team.id, { key: 'owner-draft' });
|
||||
|
||||
// ── Member-authored envelopes ─────────────────────────────────────────────
|
||||
const memberCompleted = await seedBlankDocument(memberUser, team.id, { key: 'member-completed' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: memberCompleted.id },
|
||||
data: { status: DocumentStatus.COMPLETED },
|
||||
});
|
||||
|
||||
const memberPending = await seedBlankDocument(memberUser, team.id, { key: 'member-pending' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: memberPending.id },
|
||||
data: { status: DocumentStatus.PENDING },
|
||||
});
|
||||
|
||||
const memberDraft = await seedBlankDocument(memberUser, team.id, { key: 'member-draft' });
|
||||
|
||||
// ── Manager-authored envelope (third author for good measure) ─────────────
|
||||
const managerRejected = await seedBlankDocument(managerUser, team.id, { key: 'manager-rejected' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: managerRejected.id },
|
||||
data: { status: DocumentStatus.REJECTED },
|
||||
});
|
||||
|
||||
// Sanity check: before deletion all 7 envelopes belong to the team and
|
||||
// retain their original authors.
|
||||
const beforeCount = await prisma.envelope.count({ where: { teamId: team.id } });
|
||||
expect(beforeCount).toBe(7);
|
||||
|
||||
// ── Trigger the deletion via the admin UI ─────────────────────────────────
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// The deleted-account service account is where orphaned envelopes land.
|
||||
const deletedAccount = await prisma.user.findFirstOrThrow({
|
||||
where: { email: { startsWith: 'deleted-account@' } },
|
||||
select: { id: true, ownedOrganisations: { select: { teams: { select: { id: true } } } } },
|
||||
});
|
||||
const deletedAccountTeamId = deletedAccount.ownedOrganisations[0].teams[0].id;
|
||||
|
||||
// ── Owner-authored envelopes ──────────────────────────────────────────────
|
||||
// Completed/pending: orphaned (reparented to service account + deletedAt set).
|
||||
for (const original of [ownerCompleted, ownerPending]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(after, `owner envelope ${original.id} should survive as orphan`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(deletedAccountTeamId);
|
||||
expect(after?.userId).toBe(deletedAccount.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
}
|
||||
|
||||
// Draft: hard-deleted because orphan only re-parents PENDING/REJECTED/COMPLETED.
|
||||
const ownerDraftAfter = await prisma.envelope.findUnique({
|
||||
where: { id: ownerDraft.id },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(ownerDraftAfter, 'owner draft should be hard-deleted').toBeNull();
|
||||
|
||||
// ── Member-authored envelopes (the critical case) ─────────────────────────
|
||||
// The orphan logic filters by teamId only — NOT by userId — so member-authored
|
||||
// envelopes must be orphaned just like the owner's.
|
||||
for (const original of [memberCompleted, memberPending]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(after, `member envelope ${original.id} should survive as orphan`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(deletedAccountTeamId);
|
||||
expect(after?.userId).toBe(deletedAccount.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
}
|
||||
|
||||
const memberDraftAfter = await prisma.envelope.findUnique({
|
||||
where: { id: memberDraft.id },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(memberDraftAfter, 'member draft should be hard-deleted').toBeNull();
|
||||
|
||||
// ── Manager-authored rejected envelope: also orphaned ─────────────────────
|
||||
const managerRejectedAfter = await prisma.envelope.findUnique({
|
||||
where: { id: managerRejected.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(managerRejectedAfter).not.toBeNull();
|
||||
expect(managerRejectedAfter?.teamId).toBe(deletedAccountTeamId);
|
||||
expect(managerRejectedAfter?.userId).toBe(deletedAccount.id);
|
||||
|
||||
// ── Original team is gone, member users still exist ───────────────────────
|
||||
const teamAfter = await prisma.team.findUnique({ where: { id: team.id } });
|
||||
expect(teamAfter).toBeNull();
|
||||
|
||||
// No envelope should reference the now-deleted team.
|
||||
const orphanedToOldTeam = await prisma.envelope.count({ where: { teamId: team.id } });
|
||||
expect(orphanedToOldTeam).toBe(0);
|
||||
|
||||
// The owner and members survive — only the org is deleted, not the users.
|
||||
const ownerAfter = await prisma.user.findUnique({ where: { id: owner.id } });
|
||||
const memberAfter = await prisma.user.findUnique({ where: { id: memberUser.id } });
|
||||
const managerAfter = await prisma.user.findUnique({ where: { id: managerUser.id } });
|
||||
expect(ownerAfter).not.toBeNull();
|
||||
expect(memberAfter).not.toBeNull();
|
||||
expect(managerAfter).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Owner can no longer access the deleted organisation ─────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// Sign in as the original owner and confirm they can no longer reach the
|
||||
// organisation settings page.
|
||||
await apiSignout({ page });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
// They should NOT see the organisation settings heading for this org.
|
||||
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Access control: UI ──────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: non-admin user cannot access /admin/organisations/$id', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: nonAdminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
// The admin layout loader redirects non-admins to "/". They must not see the
|
||||
// admin panel or any Delete affordance.
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).not.toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible();
|
||||
|
||||
// The org must still exist.
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: unauthenticated user cannot access /admin/organisations/$id', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// No apiSignin call. Navigate directly.
|
||||
await page.goto(`/admin/organisations/${organisation.id}`);
|
||||
|
||||
// Unauthenticated requests should be redirected away from any /admin/* route.
|
||||
await expect(page).not.toHaveURL(new RegExp(`/admin/organisations/${organisation.id}`));
|
||||
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Belt-and-braces: organisation owner (without admin role) can't use it ──
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: an organisation owner without admin role cannot reach the admin delete UI', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// Confirm the owner is NOT an admin (sanity check on the seed).
|
||||
expect(owner.roles).not.toContain(Role.ADMIN);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible();
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Org with multiple members triggers email to the OWNER only ─────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: job payload targets the organisation owner for the email notification', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }, { organisationRole: 'ADMIN' }, { organisationRole: 'MANAGER' }],
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The job payload should record the admin who requested the delete and
|
||||
// sendEmailToOwner=true. (Verifying the actual email send is out of scope
|
||||
// for this test; we verify the payload only.)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = job.payload as {
|
||||
sendEmailToOwner?: boolean;
|
||||
requestedByUserId?: number;
|
||||
};
|
||||
|
||||
return payload;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toMatchObject({
|
||||
sendEmailToOwner: true,
|
||||
requestedByUserId: adminUser.id,
|
||||
});
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// Owner user record itself is NOT deleted — only the org.
|
||||
const ownerStillExists = await prisma.user.findUnique({ where: { id: owner.id } });
|
||||
expect(ownerStillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── EnvelopeType.TEMPLATE is also cleaned up via orphan flow ───────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: template envelopes are removed (not orphaned)', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// Create a TEMPLATE envelope. orphanEnvelopes only re-parents DOCUMENT
|
||||
// envelopes; templates fall into the "deleteMany" path.
|
||||
const draftDoc = await seedBlankDocument(owner, team.id, { key: 'tmpl' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: draftDoc.id },
|
||||
data: { type: EnvelopeType.TEMPLATE },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const templateAfter = await prisma.envelope.findUnique({
|
||||
where: { id: draftDoc.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
expect(templateAfter).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const callDeleteOrganisation = async (
|
||||
page: Page,
|
||||
input: {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
sendEmailToOwner: boolean;
|
||||
},
|
||||
) => {
|
||||
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.organisation.delete`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: unauthenticated request is rejected with 401', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// No sign-in.
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
// Org must still exist.
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
|
||||
// No deletion job must have been enqueued.
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: non-admin authenticated user is rejected with 401', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: organisation owner (non-admin) cannot delete their own org via admin route', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Owners can delete via the regular organisation.delete endpoint, but the
|
||||
// ADMIN endpoint must reject them too.
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: admin call with mismatched name is rejected and org is preserved', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: `${organisation.name}-WRONG`,
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
// Body should contain INVALID_REQUEST error.
|
||||
const body = await res.text();
|
||||
expect(body).toContain('does not match');
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
|
||||
// Most importantly: no job has been enqueued for this org.
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: admin call against non-existent organisation returns NOT_FOUND', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: 'org_does-not-exist-1234567890',
|
||||
organisationName: 'Anything',
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const body = await res.text();
|
||||
expect(body).toContain('Organisation not found');
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: zod schema rejects malformed input', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Missing organisationName and sendEmailToOwner.
|
||||
const res = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.organisation.delete`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: { organisationId: 'whatever' } }),
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
// Zod validation failures surface as 400 from tRPC.
|
||||
expect([400, 422]).toContain(res.status());
|
||||
});
|
||||
|
||||
// ─── Happy path via tRPC (admin) ────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: admin can delete via the tRPC endpoint directly', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: false,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
// Background job should be enqueued; wait for it to complete then verify
|
||||
// the org is gone.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
return job?.status ?? null;
|
||||
},
|
||||
{ timeout: 30_000, intervals: [250, 500, 1000] },
|
||||
)
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const org = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(org).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Idempotency: calling delete twice does not throw ───────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: a second delete call after deletion is harmless (NOT_FOUND or no-op)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// First call succeeds.
|
||||
const first = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: false,
|
||||
});
|
||||
expect(first.ok()).toBeTruthy();
|
||||
|
||||
// Wait for the deletion to actually happen.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const org = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
return org === null;
|
||||
},
|
||||
{ timeout: 30_000, intervals: [250, 500, 1000] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Second call: the org no longer exists, so the route should fail with
|
||||
// NOT_FOUND. It must NOT 500.
|
||||
const second = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: false,
|
||||
});
|
||||
expect(second.ok()).toBeFalsy();
|
||||
expect(second.status()).not.toBe(500);
|
||||
|
||||
const body = await second.text();
|
||||
expect(body).toContain('Organisation not found');
|
||||
});
|
||||
@@ -32,10 +32,14 @@ const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
|
||||
* Expected HSL values after conversion by `toNativeCssVars`:
|
||||
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
|
||||
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
|
||||
*
|
||||
* The `%` on saturation and lightness is required: theme.css consumes these
|
||||
* via `hsl(var(--token))`, and CSS Color 4 space-separated `hsl()` rejects
|
||||
* bare numbers there. See `apps/remix/app/utils/css-vars.ts`.
|
||||
*/
|
||||
const EXPECTED_CSS_VARS = {
|
||||
'--background': '0 100 50',
|
||||
'--primary': '120 100 50',
|
||||
'--background': '0 100% 50%',
|
||||
'--primary': '120 100% 50%',
|
||||
'--radius': '1rem',
|
||||
};
|
||||
|
||||
@@ -64,7 +68,7 @@ const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
|
||||
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
|
||||
|
||||
/**
|
||||
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
|
||||
* When `--background` is set to `0 100% 50%` (hsl(0, 100%, 50%)) the body background
|
||||
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
|
||||
*/
|
||||
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
|
||||
|
||||
@@ -41,7 +41,7 @@ export default defineConfig({
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:3000',
|
||||
baseURL: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'retain-on-failure',
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 MiB After Width: | Height: | Size: 3.9 MiB |
@@ -1,10 +1,9 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
|
||||
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
|
||||
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
@@ -115,8 +114,8 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
return c.redirect(redirectPath, 302);
|
||||
}
|
||||
|
||||
// Check if signups are disabled.
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
// Check if signups are disabled for this provider.
|
||||
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
@@ -65,6 +66,14 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
|
||||
// Handle new user.
|
||||
if (!userToLink) {
|
||||
if (!isSignupEnabledForProvider('oidc')) {
|
||||
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
userToLink = await prisma.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
|
||||
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
@@ -27,7 +27,6 @@ import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/serv
|
||||
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
|
||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
@@ -184,7 +183,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
if (!isSignupEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type OrganisationDeleteEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
organisationName: string;
|
||||
/**
|
||||
* Whether the deletion was performed by an administrator (as opposed to the owner).
|
||||
* Slightly changes the wording in the email.
|
||||
*/
|
||||
deletedByAdmin?: boolean;
|
||||
};
|
||||
|
||||
export const OrganisationDeleteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
organisationName = 'Organisation Name Placeholder',
|
||||
deletedByAdmin = false,
|
||||
}: OrganisationDeleteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Your organisation has been deleted`;
|
||||
|
||||
const title = msg`Your organisation has been deleted`;
|
||||
|
||||
const description = deletedByAdmin
|
||||
? msg`The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data.`
|
||||
: msg`The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data.`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" staticAsset="logo.png" />
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">{_(description)}</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
{organisationName}
|
||||
</div>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganisationDeleteEmailTemplate;
|
||||
@@ -1,5 +1,14 @@
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import { posthog } from 'posthog-js';
|
||||
|
||||
let posthogPromise: Promise<typeof import('posthog-js')> | null = null;
|
||||
|
||||
const getPosthog = async () => {
|
||||
if (!posthogPromise) {
|
||||
posthogPromise = import('posthog-js');
|
||||
}
|
||||
|
||||
return posthogPromise;
|
||||
};
|
||||
|
||||
export function useAnalytics() {
|
||||
// const featureFlags = useFeatureFlags();
|
||||
@@ -16,7 +25,9 @@ export function useAnalytics() {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.capture(event, properties);
|
||||
void getPosthog().then(({ default: posthog }) => {
|
||||
posthog.capture(event, properties);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,7 +41,9 @@ export function useAnalytics() {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.captureException(error, properties);
|
||||
void getPosthog().then(({ default: posthog }) => {
|
||||
posthog.captureException(error, properties);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,3 +119,22 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
|
||||
|
||||
return allowedDomains.includes(emailDomain);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if signup is enabled for the given provider.
|
||||
* The master switch takes precedence over the per-provider flags.
|
||||
*/
|
||||
export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flagMap = {
|
||||
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP',
|
||||
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP',
|
||||
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP',
|
||||
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNUP',
|
||||
} as const;
|
||||
|
||||
return env(flagMap[provider]) !== 'true';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Maximum length (in characters) of the user-supplied custom CSS for branding.
|
||||
* Bound enforced at the TRPC request boundary on both the organisation and
|
||||
* team settings update routes. The sanitiser is run after this check; this
|
||||
* limit is purely a request-size guard.
|
||||
*
|
||||
* 256 KB — generous enough for hand-written branding CSS and the occasional
|
||||
* compiled-from-Tailwind-or-similar paste, while still keeping a request
|
||||
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
||||
*/
|
||||
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
export const DOCUMENT_CONVERSION_MIME_TYPE_DOCX =
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
const DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Returns whether the document conversion feature is enabled.
|
||||
*
|
||||
* Platform-aware:
|
||||
* - On the server, checks the private URL is configured.
|
||||
* - On the client, reads the derived public flag injected via `window.__ENV__`.
|
||||
*/
|
||||
export const IS_DOCUMENT_CONVERSION_ENABLED = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return !!env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
|
||||
}
|
||||
|
||||
return env('NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the configured conversion service base URL as supplied via env, or
|
||||
* `undefined` if not configured.
|
||||
*
|
||||
* Server-side only.
|
||||
*/
|
||||
export const DOCUMENT_CONVERSION_URL = (): string | undefined => {
|
||||
return env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns HTTP Basic auth credentials for the conversion service, or
|
||||
* `undefined` if either env var is missing. When Gotenberg is started with
|
||||
* `--api-enable-basic-auth`, every request must carry these credentials.
|
||||
*
|
||||
* Server-side only.
|
||||
*/
|
||||
export const DOCUMENT_CONVERSION_AUTH = (): { username: string; password: string } | undefined => {
|
||||
const username = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME');
|
||||
const password = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD');
|
||||
|
||||
if (!username || !password) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { username, password };
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the per-request timeout for conversion calls in milliseconds.
|
||||
*
|
||||
* Falls back to a 30 second default when the env value is missing or
|
||||
* unparseable.
|
||||
*/
|
||||
export const DOCUMENT_CONVERSION_TIMEOUT_MS = (): number => {
|
||||
const raw = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS');
|
||||
|
||||
if (!raw) {
|
||||
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const parsed = parseInt(raw, 10);
|
||||
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the mime type -> extensions map that should be passed to the
|
||||
* dropzone `accept` config and used for server-side validation.
|
||||
*
|
||||
* Always includes PDF; only includes DOCX when the conversion feature is
|
||||
* enabled.
|
||||
*/
|
||||
export const getAllowedUploadMimeTypes = (): Record<string, string[]> => {
|
||||
const base: Record<string, string[]> = {
|
||||
'application/pdf': ['.pdf'],
|
||||
};
|
||||
|
||||
if (IS_DOCUMENT_CONVERSION_ENABLED()) {
|
||||
base[DOCUMENT_CONVERSION_MIME_TYPE_DOCX] = ['.docx'];
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { TCssVarsSchema } from '../types/css-vars';
|
||||
|
||||
/**
|
||||
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
*
|
||||
* KEEP THIS FILE IN SYNC WITH `packages/ui/styles/theme.css`.
|
||||
*
|
||||
* These are the light-mode default values for the CSS custom properties
|
||||
* defined under `:root` in the theme stylesheet, exposed here as hex strings
|
||||
* so they can be used as defaults for colour-picker UI components and other
|
||||
* places that don't render through CSS variables.
|
||||
*
|
||||
* If you change a value in `theme.css`, update it here too. There is NO
|
||||
* automated check linking the two files; they have drifted historically
|
||||
* and will drift again unless you update both.
|
||||
*
|
||||
* Computed via `colord({ h, s, l }).toHex()` — see the inline HSL comments
|
||||
* for the source-of-truth values from `theme.css`.
|
||||
*
|
||||
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
*/
|
||||
export const DEFAULT_BRAND_COLORS = {
|
||||
background: '#ffffff', // 0 0% 100%
|
||||
foreground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
muted: '#f1f5f9', // 210 40% 96.1%
|
||||
mutedForeground: '#64748b', // 215.4 16.3% 46.9%
|
||||
popover: '#ffffff', // 0 0% 100%
|
||||
popoverForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
card: '#ffffff', // 0 0% 100%
|
||||
cardBorder: '#e2e8f0', // 214.3 31.8% 91.4%
|
||||
cardForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
fieldCard: '#e2f8d3', // 95 74% 90%
|
||||
fieldCardBorder: '#a2e771', // 95.08 71.08% 67.45%
|
||||
fieldCardForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
widget: '#f7f7f7', // 0 0% 97%
|
||||
widgetForeground: '#f2f2f2', // 0 0% 95%
|
||||
border: '#e2e8f0', // 214.3 31.8% 91.4%
|
||||
input: '#e2e8f0', // 214.3 31.8% 91.4%
|
||||
primary: '#a2e771', // 95.08 71.08% 67.45%
|
||||
primaryForeground: '#162c07', // 95.08 71.08% 10%
|
||||
secondary: '#f1f5f9', // 210 40% 96.1%
|
||||
secondaryForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
accent: '#f1f5f9', // 210 40% 96.1%
|
||||
accentForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
destructive: '#ff0000', // 0 100% 50%
|
||||
destructiveForeground: '#f8fafc', // 210 40% 98%
|
||||
ring: '#a2e771', // 95.08 71.08% 67.45%
|
||||
warning: '#e1cb05', // 54 96% 45%
|
||||
envelopeEditorBackground: '#f8fafc', //210 40% 98.04%
|
||||
// `cardBorderTint` is intentionally excluded from the colour-picker UI:
|
||||
// unlike the rest of these tokens it is consumed via `rgb(var(--token))`
|
||||
// (not `hsl(...)`) and stored as raw RGB triplets in `theme.css`. It does
|
||||
// not flow through `toNativeCssVars` and is not user-customisable from the
|
||||
// branding form. `radius` is a length, not a colour, so it lives in
|
||||
// `DEFAULT_BRAND_RADIUS` below.
|
||||
} as const satisfies Record<keyof Omit<TCssVarsSchema, 'radius' | 'cardBorderTint'>, string>;
|
||||
|
||||
export const DEFAULT_BRAND_RADIUS = '0.5rem';
|
||||
@@ -10,8 +10,10 @@ import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
@@ -49,6 +51,8 @@ export const jobsClient = new JobClient([
|
||||
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../../constants/organisations';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { orphanEnvelopes } from '../../../server-only/envelope/orphan-envelopes';
|
||||
import { sendOrganisationDeleteEmail } from '../../../server-only/organisation/delete-organisation-email';
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TAdminDeleteOrganisationJobDefinition } from './admin-delete-organisation';
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJobDefinition; io: JobRunIO }) => {
|
||||
const { organisationId, sendEmailToOwner, requestedByUserId } = payload;
|
||||
|
||||
// Get/store the organisation in a task so it can be accessed by subsequent tasks.
|
||||
const organisation = await io.runTask('get-organisation', async () => {
|
||||
io.logger.info(`User ${requestedByUserId} is deleting organisation ${organisationId}`);
|
||||
|
||||
return await prisma.organisation.findUnique({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
subscription: {
|
||||
select: {
|
||||
planId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
// The organisation may have already been deleted by a prior run / another
|
||||
// pathway. Treat as a no-op so the job doesn't retry forever.
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerEmail = organisation.owner.email;
|
||||
|
||||
const emailContext = await io.runTask('get-email-context', async () => {
|
||||
return await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 1. Orphan all envelopes for every team.
|
||||
for (const team of organisation.teams) {
|
||||
await io.runTask(`orphan-envelopes--team-${team.id}`, async () => {
|
||||
await orphanEnvelopes({ teamId: team.id });
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Delete the organisation. Matches the transaction in organisation-router/delete-organisation.ts.
|
||||
await io.runTask('delete-organisation', async () => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.deleteMany({
|
||||
where: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Send the owner notification.
|
||||
if (sendEmailToOwner) {
|
||||
await io.runTask('send-organisation-deleted-email', async () => {
|
||||
await sendOrganisationDeleteEmail({
|
||||
email: ownerEmail,
|
||||
organisationName: organisation.name,
|
||||
deletedByAdmin: true,
|
||||
emailContext,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 4. If the organisation has a Stripe subscription, schedule it to be cancelled at the end of the current billing period.
|
||||
if (organisation.subscription) {
|
||||
const stripeSubscriptionId = organisation.subscription.planId;
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.cancel-organisation-subscription',
|
||||
payload: {
|
||||
stripeSubscriptionId,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID = 'internal.admin-delete-organisation';
|
||||
|
||||
const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA = z.object({
|
||||
organisationId: z.string(),
|
||||
/**
|
||||
* Whether to email the organisation owner notifying them of the deletion.
|
||||
*/
|
||||
sendEmailToOwner: z.boolean(),
|
||||
/**
|
||||
* The id of the admin user who requested the deletion (for audit/logging).
|
||||
*/
|
||||
requestedByUserId: z.number(),
|
||||
});
|
||||
|
||||
export type TAdminDeleteOrganisationJobDefinition = z.infer<typeof ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA>;
|
||||
|
||||
export const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION = {
|
||||
id: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
|
||||
name: 'Admin Delete Organisation',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
|
||||
schema: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./admin-delete-organisation.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
|
||||
TAdminDeleteOrganisationJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Stripe, stripe } from '../../../server-only/stripe';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TCancelOrganisationSubscriptionJobDefinition } from './cancel-organisation-subscription';
|
||||
|
||||
/**
|
||||
* Marks the given Stripe subscription for cancellation at the end of the
|
||||
* current billing period.
|
||||
*
|
||||
* Idempotent: calling this on an already-cancel-at-period-end subscription is
|
||||
* a no-op for Stripe and returns the same shape, so re-running the job after
|
||||
* a partial failure is safe.
|
||||
*
|
||||
* If the subscription no longer exists in Stripe (`resource_missing`), the
|
||||
* job treats it as a no-op rather than retrying forever \u2014 nothing further
|
||||
* can be done.
|
||||
*/
|
||||
export const run = async ({ payload }: { payload: TCancelOrganisationSubscriptionJobDefinition; io: JobRunIO }) => {
|
||||
const { stripeSubscriptionId } = payload;
|
||||
|
||||
try {
|
||||
await stripe.subscriptions.update(stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
} catch (error) {
|
||||
// Subscription no longer exists in Stripe \u2014 nothing to cancel. Treat as
|
||||
// success so the job doesn't retry indefinitely.
|
||||
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
|
||||
console.warn(
|
||||
`[CANCEL_ORGANISATION_SUBSCRIPTION] Stripe subscription ${stripeSubscriptionId} no longer exists; skipping.`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Anything else: rethrow so the job runner retries.
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID = 'internal.cancel-organisation-subscription';
|
||||
|
||||
const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA = z.object({
|
||||
/**
|
||||
* The Stripe subscription id (Subscription.planId in our schema).
|
||||
*
|
||||
* This must be captured before the local organisation row is deleted,
|
||||
* because the Subscription row cascades away when the organisation is
|
||||
* removed.
|
||||
*/
|
||||
stripeSubscriptionId: z.string(),
|
||||
/**
|
||||
* The organisation id, for logging only. The organisation may no longer
|
||||
* exist by the time this job runs.
|
||||
*/
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export type TCancelOrganisationSubscriptionJobDefinition = z.infer<
|
||||
typeof CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION = {
|
||||
id: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
|
||||
name: 'Cancel Organisation Subscription',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
|
||||
schema: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./cancel-organisation-subscription.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
|
||||
TCancelOrganisationSubscriptionJobDefinition
|
||||
>;
|
||||
@@ -57,6 +57,8 @@
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import type { TCssVarsSchema } from '../../types/css-vars';
|
||||
import { ZCssVarsSchema } from '../../types/css-vars';
|
||||
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
export type RecipientBrandingPayload = {
|
||||
allowCustomBranding: boolean;
|
||||
hidePoweredBy: boolean;
|
||||
colors: TCssVarsSchema | null;
|
||||
css: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the branding payload for a recipient-facing route, given the team
|
||||
* the envelope/document belongs to. Reads inherited team-or-org branding settings,
|
||||
* checks the org's claim flags, and returns a payload safe to send to the client.
|
||||
*
|
||||
* Returns a minimal disabled payload if the team is not on a plan that allows
|
||||
* custom branding.
|
||||
*/
|
||||
export const loadRecipientBrandingByTeamId = async ({
|
||||
teamId,
|
||||
}: {
|
||||
teamId: number;
|
||||
}): Promise<RecipientBrandingPayload> => {
|
||||
const billingEnabled = IS_BILLING_ENABLED();
|
||||
|
||||
const [settings, claim] = await Promise.all([
|
||||
getTeamSettings({ teamId }),
|
||||
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
|
||||
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
|
||||
|
||||
if (!allowCustomBranding) {
|
||||
return {
|
||||
allowCustomBranding: false,
|
||||
hidePoweredBy,
|
||||
colors: null,
|
||||
css: null,
|
||||
};
|
||||
}
|
||||
|
||||
// brandingColors is stored as JSON; parse defensively. Drop unknown keys via Zod.
|
||||
const parsedColors = settings.brandingColors ? ZCssVarsSchema.safeParse(settings.brandingColors) : null;
|
||||
|
||||
return {
|
||||
allowCustomBranding: true,
|
||||
hidePoweredBy,
|
||||
colors: parsedColors?.success ? parsedColors.data : null,
|
||||
css: settings.brandingCss && settings.brandingCss.length > 0 ? settings.brandingCss : null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* In-process circuit breaker for the document conversion service.
|
||||
*
|
||||
* Behaviour: any failure opens the circuit for `COOLDOWN_MS`. While open,
|
||||
* callers should fail fast without hitting the network. The first request
|
||||
* after the cooldown is allowed through and either closes the circuit (on
|
||||
* success) or re-opens it for another cooldown window (on failure).
|
||||
*
|
||||
* State is stored on `globalThis` so it survives Vite/Remix HMR in dev and
|
||||
* is unambiguously process-wide. This module is intentionally pure and
|
||||
* synchronous: no I/O, no logger import — callers handle observability.
|
||||
*/
|
||||
|
||||
const COOLDOWN_MS = 30_000;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __documensoConversionCircuitOpenedAt: number | null | undefined;
|
||||
}
|
||||
|
||||
export const isCircuitOpen = (): boolean => {
|
||||
const openedAt = globalThis.__documensoConversionCircuitOpenedAt;
|
||||
|
||||
if (!openedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() - openedAt < COOLDOWN_MS;
|
||||
};
|
||||
|
||||
export const recordSuccess = (): void => {
|
||||
globalThis.__documensoConversionCircuitOpenedAt = null;
|
||||
};
|
||||
|
||||
export const recordFailure = (): void => {
|
||||
globalThis.__documensoConversionCircuitOpenedAt = Date.now();
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import {
|
||||
DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
|
||||
IS_DOCUMENT_CONVERSION_ENABLED,
|
||||
} from '../../constants/document-conversion';
|
||||
import { isCircuitOpen, recordFailure, recordSuccess } from './circuit-breaker';
|
||||
import { convertDocxToPdfViaGotenberg } from './gotenberg';
|
||||
|
||||
type ConvertDocxToPdfOptions = {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
const NOT_CONFIGURED_USER_MESSAGE = "Document conversion isn't enabled on this instance. Please upload a PDF.";
|
||||
|
||||
const UNAVAILABLE_USER_MESSAGE =
|
||||
'Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.';
|
||||
|
||||
/**
|
||||
* Converts a DOCX buffer to a PDF buffer via the configured Gotenberg
|
||||
* conversion service. Guards on feature-enabled and circuit-open state,
|
||||
* and emits a structured log line for each attempt.
|
||||
*/
|
||||
export const convertDocxToPdf = async (
|
||||
{ buffer, filename }: ConvertDocxToPdfOptions,
|
||||
logger?: Logger,
|
||||
): Promise<Buffer> => {
|
||||
if (!IS_DOCUMENT_CONVERSION_ENABLED()) {
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: 'Conversion service not configured',
|
||||
userMessage: NOT_CONFIGURED_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCircuitOpen()) {
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: 'Conversion circuit is open; failing fast',
|
||||
userMessage: UNAVAILABLE_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const outputBuffer = await convertDocxToPdfViaGotenberg({ buffer, filename });
|
||||
|
||||
recordSuccess();
|
||||
|
||||
logger?.info({
|
||||
event: 'document_conversion_attempt',
|
||||
filename,
|
||||
sourceMimeType: DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
|
||||
durationMs: Date.now() - startedAt,
|
||||
inputBytes: buffer.byteLength,
|
||||
outputBytes: outputBuffer.byteLength,
|
||||
});
|
||||
|
||||
return outputBuffer;
|
||||
} catch (err) {
|
||||
recordFailure();
|
||||
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
const errCode = err instanceof AppError ? err.code : 'UNKNOWN';
|
||||
|
||||
const logData = {
|
||||
event: 'document_conversion_attempt',
|
||||
filename,
|
||||
sourceMimeType: DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
|
||||
durationMs: Date.now() - startedAt,
|
||||
inputBytes: buffer.byteLength,
|
||||
failed: true,
|
||||
errorCode: errCode,
|
||||
error: errMessage,
|
||||
};
|
||||
|
||||
// A non-2xx from the conversion service surfaces as CONVERSION_FAILED.
|
||||
// We log those at `error` level (status + truncated body live in the
|
||||
// AppError message). All other failures stay at `info` to avoid noisy
|
||||
// logs from transient network blips that the breaker already handles.
|
||||
if (errCode === 'CONVERSION_FAILED') {
|
||||
logger?.error(logData);
|
||||
} else {
|
||||
logger?.info(logData);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import {
|
||||
DOCUMENT_CONVERSION_AUTH,
|
||||
DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
|
||||
DOCUMENT_CONVERSION_TIMEOUT_MS,
|
||||
DOCUMENT_CONVERSION_URL,
|
||||
} from '../../constants/document-conversion';
|
||||
|
||||
type ConvertDocxToPdfViaGotenbergOptions = {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
const UNAVAILABLE_USER_MESSAGE =
|
||||
'Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.';
|
||||
|
||||
const NOT_CONFIGURED_USER_MESSAGE = "Document conversion isn't enabled on this instance. Please upload a PDF.";
|
||||
|
||||
const CONVERSION_FAILED_USER_MESSAGE =
|
||||
"We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.";
|
||||
|
||||
const MAX_ERROR_BODY_CHARS = 500;
|
||||
|
||||
/**
|
||||
* Posts a DOCX file to the configured Gotenberg-compatible conversion
|
||||
* service and returns the resulting PDF bytes.
|
||||
*
|
||||
* Throws an `AppError` for all failure modes:
|
||||
* - `CONVERSION_SERVICE_UNAVAILABLE` for missing config, timeout, or
|
||||
* network errors.
|
||||
* - `CONVERSION_FAILED` for non-2xx responses from the service.
|
||||
*/
|
||||
export const convertDocxToPdfViaGotenberg = async ({
|
||||
buffer,
|
||||
filename,
|
||||
}: ConvertDocxToPdfViaGotenbergOptions): Promise<Buffer> => {
|
||||
const url = DOCUMENT_CONVERSION_URL();
|
||||
|
||||
if (!url) {
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: 'Conversion service URL is not configured',
|
||||
userMessage: NOT_CONFIGURED_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([buffer], { type: DOCUMENT_CONVERSION_MIME_TYPE_DOCX });
|
||||
|
||||
formData.append('files', blob, filename);
|
||||
|
||||
// Tell LibreOffice NOT to export Word content controls (`<w:sdt>`) as PDF
|
||||
// AcroForm fields. By default Gotenberg renders the field values into form
|
||||
// appearance streams that reference unembedded base fonts (Times-Roman,
|
||||
// Times-Bold). Our downstream `normalizePdf` flattens the form, but the
|
||||
// pdf-lib flattening drops those appearance streams, so every SDT-bound
|
||||
// string (i.e. virtually all of the body text in Office resume / cover-
|
||||
// letter templates) ends up invisible in the final PDF. Disabling form
|
||||
// export makes LibreOffice render those strings as regular text in the
|
||||
// page content stream, with all glyphs embedded.
|
||||
formData.append('exportFormFields', 'false');
|
||||
|
||||
// When the service is launched with `--api-enable-basic-auth`, every
|
||||
// route (including `/health` and `/forms/libreoffice/convert`) requires
|
||||
// HTTP Basic credentials. When auth env vars are not configured we send
|
||||
// no header and rely on the service running without auth enabled.
|
||||
const auth = DOCUMENT_CONVERSION_AUTH();
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (auth) {
|
||||
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
|
||||
headers.Authorization = `Basic ${encoded}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(() => controller.abort(), DOCUMENT_CONVERSION_TIMEOUT_MS());
|
||||
|
||||
const convertEndpoint = new URL('/forms/libreoffice/convert', url).toString();
|
||||
|
||||
try {
|
||||
const response = await fetch(convertEndpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let body = '';
|
||||
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch {
|
||||
body = '';
|
||||
}
|
||||
|
||||
const truncatedBody = body.length > MAX_ERROR_BODY_CHARS ? `${body.slice(0, MAX_ERROR_BODY_CHARS)}...` : body;
|
||||
|
||||
throw new AppError('CONVERSION_FAILED', {
|
||||
message: `Conversion service returned ${response.status}: ${truncatedBody}`,
|
||||
userMessage: CONVERSION_FAILED_USER_MESSAGE,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const isAbortError = err instanceof Error && err.name === 'AbortError';
|
||||
|
||||
if (isAbortError) {
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: 'Conversion service timed out',
|
||||
userMessage: UNAVAILABLE_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: `Conversion service request failed: ${errMessage}`,
|
||||
userMessage: UNAVAILABLE_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import { DOCUMENT_CONVERSION_MIME_TYPE_DOCX } from '../../constants/document-conversion';
|
||||
import { convertDocxToPdf } from './docx-to-pdf';
|
||||
|
||||
// We should work on unifying these later on.
|
||||
type FileInput = {
|
||||
name: string;
|
||||
type: string;
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_USER_MESSAGE = "This file type isn't supported. Please upload a PDF or Word document.";
|
||||
|
||||
/**
|
||||
* Entry point for upload routes. Returns a PDF buffer for any supported
|
||||
* input file:
|
||||
*
|
||||
* - PDF in → PDF out (no conversion, no network call).
|
||||
* - DOCX in → converted PDF out via the configured conversion service.
|
||||
* - Any other mime type → throws `UNSUPPORTED_FILE_TYPE`.
|
||||
*
|
||||
* To support new source formats (PowerPoint, HTML, ...), add a new
|
||||
* `<format>-to-pdf.ts` sibling and dispatch to it from here.
|
||||
*/
|
||||
export const convertToPdf = async (file: FileInput, logger?: Logger): Promise<Buffer> => {
|
||||
if (file.type === 'application/pdf') {
|
||||
return Buffer.from(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
if (file.type === DOCUMENT_CONVERSION_MIME_TYPE_DOCX) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
return convertDocxToPdf({ buffer, filename: file.name }, logger);
|
||||
}
|
||||
|
||||
throw new AppError('UNSUPPORTED_FILE_TYPE', {
|
||||
message: `Unsupported file type: ${file.type}`,
|
||||
userMessage: UNSUPPORTED_USER_MESSAGE,
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
@@ -61,7 +61,7 @@ type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
|
||||
|
||||
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
|
||||
|
||||
type EmailContextResponse = {
|
||||
export type EmailContextResponse = {
|
||||
allowedEmails: OrganisationEmail[];
|
||||
branding: BrandingSettings;
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationDeleteEmailTemplate } from '@documenso/email/templates/organisation-delete';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import type { EmailContextResponse } from '../email/get-email-context';
|
||||
|
||||
export type SendOrganisationDeleteEmailOptions = {
|
||||
email: string;
|
||||
organisationName: string;
|
||||
deletedByAdmin?: boolean;
|
||||
emailContext: EmailContextResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends an "organisation deleted" notification email.
|
||||
*/
|
||||
export const sendOrganisationDeleteEmail = async ({
|
||||
email,
|
||||
organisationName,
|
||||
deletedByAdmin = false,
|
||||
emailContext,
|
||||
}: SendOrganisationDeleteEmailOptions) => {
|
||||
const template = createElement(OrganisationDeleteEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
organisationName,
|
||||
deletedByAdmin,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = emailContext;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`Organisation "${organisationName}" has been deleted`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@@ -39,6 +39,8 @@ export const getTeamSettings = async ({ userId, teamId }: GetTeamSettingsOptions
|
||||
teamSettings.brandingLogo = organisationSettings.brandingLogo;
|
||||
teamSettings.brandingUrl = organisationSettings.brandingUrl;
|
||||
teamSettings.brandingCompanyDetails = organisationSettings.brandingCompanyDetails;
|
||||
teamSettings.brandingColors = organisationSettings.brandingColors;
|
||||
teamSettings.brandingCss = organisationSettings.brandingCss;
|
||||
}
|
||||
|
||||
return extractDerivedTeamSettings(organisationSettings, teamSettings);
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: de\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-07 05:08\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -61,6 +61,12 @@ msgstr "\"{title}\" wurde erfolgreich ausgeblendet"
|
||||
msgid "\"Team Name\" has invited you to sign \"example document\"."
|
||||
msgstr "\"Team Name\" hat Sie eingeladen, das \"Beispieldokument\" zu unterschreiben."
|
||||
|
||||
#. placeholder {0}: warning.line
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "(line {0})"
|
||||
msgstr "(Zeile {0})"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
|
||||
@@ -90,6 +96,12 @@ msgstr "{0, plural, one {# Zeichen über dem Limit} other {# Zeichen über dem L
|
||||
msgid "{0, plural, one {# character remaining} other {# characters remaining}}"
|
||||
msgstr "{0, plural, one {# Zeichen verbleibend} other {# Zeichen verbleibend}}"
|
||||
|
||||
#. placeholder {0}: warnings.length
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "{0, plural, one {# CSS rule was dropped during sanitisation.} other {# CSS rules were dropped during sanitisation.}}"
|
||||
msgstr "{0, plural, one {# CSS-Regel wurde während der Bereinigung entfernt.} other {# CSS-Regeln wurden während der Bereinigung entfernt.}}"
|
||||
|
||||
#. placeholder {0}: folder._count.documents
|
||||
#: apps/remix/app/components/general/folder/folder-card.tsx
|
||||
msgid "{0, plural, one {# document} other {# documents}}"
|
||||
@@ -1426,6 +1438,10 @@ msgstr "Admin-Panel"
|
||||
msgid "Admins only"
|
||||
msgstr "Nur Admins"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Advanced — Custom CSS"
|
||||
msgstr "Erweitert — Benutzerdefiniertes CSS"
|
||||
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
msgid "Advanced Options"
|
||||
@@ -1605,6 +1621,7 @@ msgstr "Eine E-Mail mit einer Einladung wird an jedes Mitglied gesendet."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Eine E-Mail mit dieser Adresse existiert bereits."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2170,6 +2187,10 @@ msgstr "Zurück"
|
||||
msgid "Back home"
|
||||
msgstr "Zurück nach Hause"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Background"
|
||||
msgstr "Hintergrund"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "Background Color"
|
||||
msgstr "Hintergrundfarbe"
|
||||
@@ -2191,6 +2212,14 @@ msgstr "Backup-Codes"
|
||||
msgid "Banner Updated"
|
||||
msgstr "Banner aktualisiert"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Base background colour."
|
||||
msgstr "Standard-Hintergrundfarbe."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Base text colour."
|
||||
msgstr "Standard-Textfarbe."
|
||||
|
||||
#: packages/email/template-components/template-confirmation-email.tsx
|
||||
msgid "Before you get started, please confirm your email address by clicking the button below:"
|
||||
msgstr "Bitte bestätige vor dem Start deine E-Mail-Adresse, indem du auf den Button unten klickst:"
|
||||
@@ -2213,10 +2242,26 @@ msgstr "Schwarz"
|
||||
msgid "Blue"
|
||||
msgstr "Blau"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border"
|
||||
msgstr "Rahmen"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border Radius"
|
||||
msgstr "Eckenradius"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border radius size in REM units (e.g. 0.5rem)."
|
||||
msgstr "Eckenradius-Größe in REM-Einheiten (z. B. 0.5rem)."
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx
|
||||
msgid "Bottom"
|
||||
msgstr "Unten"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Brand Colours"
|
||||
msgstr "Markenfarben"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Brand Details"
|
||||
msgstr "Markendetails"
|
||||
@@ -2249,7 +2294,6 @@ msgstr "Branding-Logo"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Markenpräferenzen"
|
||||
|
||||
@@ -2258,6 +2302,11 @@ msgstr "Markenpräferenzen"
|
||||
msgid "Branding preferences updated"
|
||||
msgstr "Markenpräferenzen aktualisiert"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding preferences updated with warnings"
|
||||
msgstr "Branding-Einstellungen mit Warnungen aktualisiert"
|
||||
|
||||
#: apps/remix/app/components/general/admin-global-settings-section.tsx
|
||||
msgid "Branding URL"
|
||||
msgstr "Branding-URL"
|
||||
@@ -2340,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Person nicht gefunden?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2793,7 +2843,7 @@ msgstr "Vorlage konfigurieren"
|
||||
msgid "Configure Template"
|
||||
msgstr "Vorlage konfigurieren"
|
||||
|
||||
#. placeholder {0}: parseMessageDescriptor( _, FRIENDLY_FIELD_TYPE[currentField.type], )
|
||||
#. placeholder {0}: parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@@ -2823,6 +2873,7 @@ msgstr "Konfigurieren Sie, wann und wie oft Erinnerungs-E-Mails an Empfänger ge
|
||||
msgid "Confirm"
|
||||
msgstr "Bestätigen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3286,6 +3337,11 @@ msgstr "Dokument wird erstellt"
|
||||
msgid "Creating Template"
|
||||
msgstr "Vorlage wird erstellt"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "CSS rules were dropped during sanitisation"
|
||||
msgstr "CSS-Regeln wurden während der Bereinigung entfernt"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
msgid "CSV Structure"
|
||||
msgstr "CSV-Struktur"
|
||||
@@ -3327,6 +3383,10 @@ msgstr "Derzeit können E-Mail-Domains nur für Plattform- und höhere Pläne ko
|
||||
msgid "Custom {0} MB file"
|
||||
msgstr "Benutzerdefinierte {0} MB-Datei"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be shown after you save."
|
||||
msgstr "Benutzerdefiniertes CSS wird beim Speichern bereinigt. Layout-zerschießende Eigenschaften, entfernte URLs und Pseudo-Elemente werden automatisch entfernt. Alle Regeln, die während der Bereinigung verworfen werden, werden nach dem Speichern angezeigt."
|
||||
|
||||
#: packages/ui/components/document/expiration-period-picker.tsx
|
||||
msgid "Custom duration"
|
||||
msgstr "Benutzerdefinierte Dauer"
|
||||
@@ -3339,6 +3399,14 @@ msgstr "Benutzerdefiniertes Intervall"
|
||||
msgid "Custom Organisation Groups"
|
||||
msgstr "Benutzerdefinierte Organisationsgruppen"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Passen Sie die Farben an, die auf Ihren Signaturseiten verwendet werden."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Gefahrenbereich"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Dunkelmodus"
|
||||
@@ -3388,6 +3456,10 @@ msgstr "David ist der Mitarbeiter, Lucas ist der Manager"
|
||||
msgid "Decline"
|
||||
msgstr "Ablehnen"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Default border colour."
|
||||
msgstr "Standardrahmenfarbe."
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
msgid "Default Date Format"
|
||||
msgstr "Standard-Datumsformat"
|
||||
@@ -3467,6 +3539,8 @@ msgstr "Dokumenteigentum delegieren"
|
||||
msgid "delete"
|
||||
msgstr "löschen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3516,6 +3590,10 @@ msgstr "löschen {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "löschen {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "{organisationName} löschen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "löschen {teamName}"
|
||||
@@ -3568,6 +3646,8 @@ msgstr "Umschlag löschen"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Ordner löschen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Organisation löschen"
|
||||
@@ -3630,6 +3710,10 @@ msgstr "Gelöscht"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Konto wird gelöscht..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Löschung geplant"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Ziel"
|
||||
@@ -3924,6 +4008,12 @@ msgstr "E-Mail zum Abschluss des Dokuments"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Dokument abgeschlossen!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "Die Dokumentkonvertierung ist vorübergehend nicht verfügbar. Bitte versuchen Sie es in Kürze erneut oder laden Sie ein PDF hoch."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Dokument erstellt"
|
||||
@@ -4365,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Entwurfte Dokumente"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Ziehen Sie Ihr PDF hierher."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Ziehen Sie Ihr Dokument hierher."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Ziehen Sie die Datei hierher oder klicken Sie, um hochzuladen"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Ziehen Sie Ihre PDF-Datei hierher"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Ziehen Sie Ihr Dokument hierher"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4674,6 +4764,10 @@ msgstr "E-Mail gesendet!"
|
||||
msgid "Email Settings"
|
||||
msgstr "E-Mail-Einstellungen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Senden Sie eine E-Mail an den/die Organisationsinhaber(in), um ihn/sie über die Löschung zu informieren."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Den Eigentümer per E-Mail benachrichtigen, wenn ein Dokument aus einer direkten Vorlage erstellt wird"
|
||||
@@ -5125,7 +5219,7 @@ msgstr "Läuft ab"
|
||||
msgid "Expires {0}"
|
||||
msgstr "Läuft ab am {0}"
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat('mm:ss')
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
msgstr "Läuft ab in {0}"
|
||||
@@ -5356,6 +5450,10 @@ msgstr "Füllen Sie die Details aus, um einen neuen Abonnementsanspruch zu erste
|
||||
msgid "Filter by status"
|
||||
msgstr "Nach Status filtern"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Focus ring colour."
|
||||
msgstr "Fokus-Ringfarbe."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx
|
||||
@@ -5414,6 +5512,10 @@ msgstr "Für jeden Empfänger geben Sie dessen E-Mail (erforderlich) und Namen (
|
||||
msgid "For example, if the claim has a new flag \"FLAG_1\" set to true, then this organisation will get that flag added."
|
||||
msgstr "Wenn der Anspruch zum Beispiel das neue Flag 'FLAG_1' hat, das auf wahr gesetzt ist, wird dieser Organisation dieses Flag hinzugefügt."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Foreground"
|
||||
msgstr "Vordergrund"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/check-email.tsx
|
||||
msgid "Forgot password"
|
||||
msgstr "Passwort vergessen"
|
||||
@@ -6049,6 +6151,10 @@ msgstr "Rechnung"
|
||||
msgid "IP Address"
|
||||
msgstr "IP-Adresse"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Irreversible Aktionen für diese Organisation"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "Herausgeber-URL"
|
||||
@@ -7232,6 +7338,10 @@ msgstr "Oder fahren Sie fort mit"
|
||||
msgid "Organisation"
|
||||
msgstr "Organisation"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "Organisation \"{organisationName}\" wurde gelöscht"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Organisationsadministrator"
|
||||
@@ -7597,6 +7707,10 @@ msgstr "pro Monat"
|
||||
msgid "per year"
|
||||
msgstr "pro Jahr"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Diese Organisation dauerhaft löschen. Dokumente werden verwaist (nicht gelöscht), damit sie weiterhin über das Servicekonto für gelöschte Konten zugänglich bleiben."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -7915,6 +8029,18 @@ msgstr "Dokument vor dem Versand überprüfen"
|
||||
msgid "Preview what the signed document will look like with placeholder data"
|
||||
msgstr "Dokumentenvorschau mit Platzhaltern anzeigen"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary"
|
||||
msgstr "Primär"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary action colour."
|
||||
msgstr "Primäre Aktionsfarbe."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary Foreground"
|
||||
msgstr "Primärer Vordergrund"
|
||||
|
||||
#: apps/remix/app/components/general/template/template-type.tsx
|
||||
#: apps/remix/app/components/tables/templates-table.tsx
|
||||
#: packages/ui/components/template/template-type-select.tsx
|
||||
@@ -8667,6 +8793,10 @@ msgstr "Alle Sitzungen widerrufen"
|
||||
msgid "Right"
|
||||
msgstr "Rechts"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Ring"
|
||||
msgstr "Ring"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
@@ -9247,6 +9377,10 @@ msgstr "Dokument unterschreiben"
|
||||
msgid "Sign Document"
|
||||
msgstr "Dokument signieren"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/_layout.tsx
|
||||
msgid "Sign Document - Documenso"
|
||||
msgstr "Dokument unterzeichnen - Documenso"
|
||||
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
|
||||
msgid "Sign Documents"
|
||||
msgstr "Dokumente unterschreiben"
|
||||
@@ -10216,6 +10350,10 @@ msgstr "Textausrichtung"
|
||||
msgid "Text Color"
|
||||
msgstr "Textfarbe"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Text colour on primary buttons."
|
||||
msgstr "Textfarbe auf primären Schaltflächen."
|
||||
|
||||
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
|
||||
msgid "Text is required"
|
||||
msgstr "Text ist erforderlich"
|
||||
@@ -10268,6 +10406,10 @@ msgstr "Der Inhalt, der im Banne rgezeig wird, HTML ist erlaubt"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "Die Standard-E-Mail, die beim Versenden von E-Mails an Empfänger verwendet wird"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "Die Löschung wird im Hintergrund ausgeführt und kann bis zu einigen Minuten dauern. Führen Sie diese Löschung nicht erneut aus."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10393,6 +10535,14 @@ msgstr "Der Ordner, in den Sie die Vorlage verschieben möchten, existiert nicht
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Die folgenden Fehler sind aufgetreten:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "Die folgende Organisation wurde von einem Administrator gelöscht. Sie und Ihre Mitglieder können nicht mehr auf diese Organisation, ihre Teams oder ihre zugehörigen Daten zugreifen."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "Die folgende Organisation wurde gelöscht. Sie und Ihre Mitglieder können nicht mehr auf diese Organisation, ihre Teams oder ihre zugehörigen Daten zugreifen."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "Für die folgenden Empfänger wird eine E‑Mail‑Adresse benötigt:"
|
||||
@@ -10431,6 +10581,10 @@ msgstr "Die gesuchte Organisationsgruppe wurde möglicherweise entfernt, umbenan
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "Die Organisationsrolle, die auf alle Mitglieder in dieser Gruppe angewendet wird."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "Die Organisation wird im Hintergrund gelöscht. Dokumente werden verwaist und nicht gelöscht."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10819,6 +10973,12 @@ msgstr "Diese Funktion ist in Ihrem aktuellen Tarif nicht verfügbar."
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Dieses Feld kann nicht geändert oder gelöscht werden. Wenn Sie den direkten Link dieser Vorlage teilen oder zu Ihrem öffentlichen Profil hinzufügen, kann jeder, der darauf zugreift, seinen Namen und seine E-Mail-Adresse eingeben und die ihm zugewiesenen Felder ausfüllen."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Dieser Dateityp wird nicht unterstützt. Bitte laden Sie ein PDF- oder Word-Dokument hoch."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Dieser Ordner enthält mehrere Elemente. Wenn Sie ihn löschen, werden alle Unterordner entfernt und alle darin enthaltenen Dokumente und Vorlagen in den Stammordner verschoben."
|
||||
@@ -11077,7 +11237,6 @@ msgstr "Um dieses Dokument als angesehen zu markieren, müssen Sie als <0>{0}</0
|
||||
msgid "To mark this document as viewed, you need to be logged in."
|
||||
msgstr "Um dieses Dokument als angesehen zu markieren, müssen Sie angemeldet sein."
|
||||
|
||||
#. placeholder {0}: emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'
|
||||
#. placeholder {0}: emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@@ -12091,6 +12250,12 @@ msgstr "Wir können diesen Schlüssel im Moment nicht entfernen. Bitte versuchen
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "Wir können diesen Schlüssel im Moment nicht aktualisieren. Bitte versuchen Sie es später erneut."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "Wir konnten diese Datei nicht konvertieren. Bitte prüfen Sie, ob es sich um ein gültiges Word-Dokument handelt, oder laden Sie stattdessen ein PDF hoch."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Wir konnten keinen Stripe-Kunden erstellen. Bitte versuchen Sie es erneut."
|
||||
@@ -12117,6 +12282,10 @@ msgstr "Wir konnten die Organisation nicht aktualisieren. Bitte versuchen Sie es
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "Der Anbieter konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Beim Versuch, diese Organisation zu löschen, ist ein Fehler aufgetreten. Bitte versuche es später noch einmal."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12620,6 +12789,10 @@ msgstr "Sie sind dabei, <0>\"{title}\"</0> zu löschen"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Sie sind dabei, <0>{0}</0> zu löschen. Alle mit dieser Organisation verbundenen Daten wie Teams, Dokumente und alle anderen Ressourcen werden gelöscht. Diese Aktion ist nicht umkehrbar."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Du bist dabei, <0>{organisationName}</0> zu löschen. Diese Aktion kann nicht rückgängig gemacht werden. Alle Teams werden entfernt und alle Dokumente werden dem Servicekonto des gelöschten Kontos zugeordnet."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Sie stehen kurz davor, die folgende Team-E-Mail von <0>{teamName}</0> zu löschen."
|
||||
@@ -13102,6 +13275,7 @@ msgstr "Sie haben Ihre E-Mail-Adresse für <0>{0}</0> bestätigt."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Sie haben das Dokument in das Team verschoben"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13547,6 +13721,11 @@ msgstr "Ihr neues Passwort darf nicht mit Ihrem alten Passwort identisch sein."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Ihre Organisation wurde erstellt."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Deine Organisation wurde gelöscht"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Ihre Organisation wurde erfolgreich gelöscht."
|
||||
|
||||
@@ -56,6 +56,12 @@ msgstr "\"{title}\" has been successfully hidden"
|
||||
msgid "\"Team Name\" has invited you to sign \"example document\"."
|
||||
msgstr "\"Team Name\" has invited you to sign \"example document\"."
|
||||
|
||||
#. placeholder {0}: warning.line
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "(line {0})"
|
||||
msgstr "(line {0})"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
|
||||
@@ -85,6 +91,12 @@ msgstr "{0, plural, one {# character over the limit} other {# characters over th
|
||||
msgid "{0, plural, one {# character remaining} other {# characters remaining}}"
|
||||
msgstr "{0, plural, one {# character remaining} other {# characters remaining}}"
|
||||
|
||||
#. placeholder {0}: warnings.length
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "{0, plural, one {# CSS rule was dropped during sanitisation.} other {# CSS rules were dropped during sanitisation.}}"
|
||||
msgstr "{0, plural, one {# CSS rule was dropped during sanitisation.} other {# CSS rules were dropped during sanitisation.}}"
|
||||
|
||||
#. placeholder {0}: folder._count.documents
|
||||
#: apps/remix/app/components/general/folder/folder-card.tsx
|
||||
msgid "{0, plural, one {# document} other {# documents}}"
|
||||
@@ -1421,6 +1433,10 @@ msgstr "Admin Panel"
|
||||
msgid "Admins only"
|
||||
msgstr "Admins only"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Advanced — Custom CSS"
|
||||
msgstr "Advanced — Custom CSS"
|
||||
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
msgid "Advanced Options"
|
||||
@@ -1600,6 +1616,7 @@ msgstr "An email containing an invitation will be sent to each member."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "An email with this address already exists."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2165,6 +2182,10 @@ msgstr "Back"
|
||||
msgid "Back home"
|
||||
msgstr "Back home"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Background"
|
||||
msgstr "Background"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "Background Color"
|
||||
msgstr "Background Color"
|
||||
@@ -2186,6 +2207,14 @@ msgstr "Backup codes"
|
||||
msgid "Banner Updated"
|
||||
msgstr "Banner Updated"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Base background colour."
|
||||
msgstr "Base background colour."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Base text colour."
|
||||
msgstr "Base text colour."
|
||||
|
||||
#: packages/email/template-components/template-confirmation-email.tsx
|
||||
msgid "Before you get started, please confirm your email address by clicking the button below:"
|
||||
msgstr "Before you get started, please confirm your email address by clicking the button below:"
|
||||
@@ -2208,10 +2237,26 @@ msgstr "Black"
|
||||
msgid "Blue"
|
||||
msgstr "Blue"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border"
|
||||
msgstr "Border"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border Radius"
|
||||
msgstr "Border Radius"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border radius size in REM units (e.g. 0.5rem)."
|
||||
msgstr "Border radius size in REM units (e.g. 0.5rem)."
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx
|
||||
msgid "Bottom"
|
||||
msgstr "Bottom"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Brand Colours"
|
||||
msgstr "Brand Colours"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Brand Details"
|
||||
msgstr "Brand Details"
|
||||
@@ -2244,7 +2289,6 @@ msgstr "Branding Logo"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Branding Preferences"
|
||||
|
||||
@@ -2253,6 +2297,11 @@ msgstr "Branding Preferences"
|
||||
msgid "Branding preferences updated"
|
||||
msgstr "Branding preferences updated"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding preferences updated with warnings"
|
||||
msgstr "Branding preferences updated with warnings"
|
||||
|
||||
#: apps/remix/app/components/general/admin-global-settings-section.tsx
|
||||
msgid "Branding URL"
|
||||
msgstr "Branding URL"
|
||||
@@ -2335,6 +2384,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Can't find someone?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2788,7 +2838,7 @@ msgstr "Configure template"
|
||||
msgid "Configure Template"
|
||||
msgstr "Configure Template"
|
||||
|
||||
#. placeholder {0}: parseMessageDescriptor( _, FRIENDLY_FIELD_TYPE[currentField.type], )
|
||||
#. placeholder {0}: parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@@ -2818,6 +2868,7 @@ msgstr "Configure when and how often reminder emails are sent to recipients who
|
||||
msgid "Confirm"
|
||||
msgstr "Confirm"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3281,6 +3332,11 @@ msgstr "Creating Document"
|
||||
msgid "Creating Template"
|
||||
msgstr "Creating Template"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "CSS rules were dropped during sanitisation"
|
||||
msgstr "CSS rules were dropped during sanitisation"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
msgid "CSV Structure"
|
||||
msgstr "CSV Structure"
|
||||
@@ -3322,6 +3378,10 @@ msgstr "Currently email domains can only be configured for Platform and above pl
|
||||
msgid "Custom {0} MB file"
|
||||
msgstr "Custom {0} MB file"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be shown after you save."
|
||||
msgstr "Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be shown after you save."
|
||||
|
||||
#: packages/ui/components/document/expiration-period-picker.tsx
|
||||
msgid "Custom duration"
|
||||
msgstr "Custom duration"
|
||||
@@ -3334,6 +3394,14 @@ msgstr "Custom interval"
|
||||
msgid "Custom Organisation Groups"
|
||||
msgstr "Custom Organisation Groups"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Customise the colours used on your signing pages."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Danger Zone"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Dark Mode"
|
||||
@@ -3383,6 +3451,10 @@ msgstr "David is the Employee, Lucas is the Manager"
|
||||
msgid "Decline"
|
||||
msgstr "Decline"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Default border colour."
|
||||
msgstr "Default border colour."
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
msgid "Default Date Format"
|
||||
msgstr "Default Date Format"
|
||||
@@ -3462,6 +3534,8 @@ msgstr "Delegate Document Ownership"
|
||||
msgid "delete"
|
||||
msgstr "delete"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3511,6 +3585,10 @@ msgstr "delete {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "delete {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "delete {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "delete {teamName}"
|
||||
@@ -3563,6 +3641,8 @@ msgstr "Delete Envelope"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Delete Folder"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Delete organisation"
|
||||
@@ -3625,6 +3705,10 @@ msgstr "Deleted"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Deleting account..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Deletion scheduled"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Destination"
|
||||
@@ -3919,6 +4003,12 @@ msgstr "Document completed email"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Document Completed!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Document created"
|
||||
@@ -4360,16 +4450,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Drafted Documents"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Drag & drop your PDF here."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Drag & drop your document here."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Drag and drop or click to upload"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Drag and drop your PDF file here"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Drag and drop your document here"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4669,6 +4759,10 @@ msgstr "Email sent!"
|
||||
msgid "Email Settings"
|
||||
msgstr "Email Settings"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Email the organisation owner to notify them of the deletion."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Email the owner when a document is created from a direct template"
|
||||
@@ -5120,7 +5214,7 @@ msgstr "Expires"
|
||||
msgid "Expires {0}"
|
||||
msgstr "Expires {0}"
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat('mm:ss')
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
msgstr "Expires in {0}"
|
||||
@@ -5351,6 +5445,10 @@ msgstr "Fill in the details to create a new subscription claim."
|
||||
msgid "Filter by status"
|
||||
msgstr "Filter by status"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Focus ring colour."
|
||||
msgstr "Focus ring colour."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx
|
||||
@@ -5409,6 +5507,10 @@ msgstr "For each recipient, provide their email (required) and name (optional) i
|
||||
msgid "For example, if the claim has a new flag \"FLAG_1\" set to true, then this organisation will get that flag added."
|
||||
msgstr "For example, if the claim has a new flag \"FLAG_1\" set to true, then this organisation will get that flag added."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Foreground"
|
||||
msgstr "Foreground"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/check-email.tsx
|
||||
msgid "Forgot password"
|
||||
msgstr "Forgot password"
|
||||
@@ -6044,6 +6146,10 @@ msgstr "Invoice"
|
||||
msgid "IP Address"
|
||||
msgstr "IP Address"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Irreversible actions for this organisation"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "Issuer URL"
|
||||
@@ -6333,6 +6439,8 @@ msgstr "Loading suggestions..."
|
||||
#: apps/remix/app/components/embed/embed-client-loading.tsx
|
||||
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
|
||||
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
|
||||
msgid "Loading..."
|
||||
msgstr "Loading..."
|
||||
@@ -6909,10 +7017,18 @@ msgstr "No folders yet."
|
||||
msgid "No further action is required from you at this time."
|
||||
msgstr "No further action is required from you at this time."
|
||||
|
||||
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
|
||||
msgid "No groups found"
|
||||
msgstr "No groups found"
|
||||
|
||||
#: apps/remix/app/components/general/admin-license-card.tsx
|
||||
msgid "No License Configured"
|
||||
msgstr "No License Configured"
|
||||
|
||||
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
|
||||
msgid "No members found"
|
||||
msgstr "No members found"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
|
||||
msgid "No members selected"
|
||||
msgstr "No members selected"
|
||||
@@ -7217,6 +7333,10 @@ msgstr "Or continue with"
|
||||
msgid "Organisation"
|
||||
msgstr "Organisation"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "Organisation \"{organisationName}\" has been deleted"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Organisation Admin"
|
||||
@@ -7582,6 +7702,10 @@ msgstr "per month"
|
||||
msgid "per year"
|
||||
msgstr "per year"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -7900,6 +8024,18 @@ msgstr "Preview the document before sending"
|
||||
msgid "Preview what the signed document will look like with placeholder data"
|
||||
msgstr "Preview what the signed document will look like with placeholder data"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary"
|
||||
msgstr "Primary"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary action colour."
|
||||
msgstr "Primary action colour."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary Foreground"
|
||||
msgstr "Primary Foreground"
|
||||
|
||||
#: apps/remix/app/components/general/template/template-type.tsx
|
||||
#: apps/remix/app/components/tables/templates-table.tsx
|
||||
#: packages/ui/components/template/template-type-select.tsx
|
||||
@@ -8652,6 +8788,10 @@ msgstr "Revoke all sessions"
|
||||
msgid "Right"
|
||||
msgstr "Right"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Ring"
|
||||
msgstr "Ring"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
@@ -8772,10 +8912,18 @@ msgstr "Search documents..."
|
||||
msgid "Search folders..."
|
||||
msgstr "Search folders..."
|
||||
|
||||
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
|
||||
msgid "Search groups by name"
|
||||
msgstr "Search groups by name"
|
||||
|
||||
#: packages/ui/components/common/language-switcher-dialog.tsx
|
||||
msgid "Search languages..."
|
||||
msgstr "Search languages..."
|
||||
|
||||
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
|
||||
msgid "Search members by name or email"
|
||||
msgstr "Search members by name or email"
|
||||
|
||||
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
|
||||
msgid "Secret"
|
||||
msgstr "Secret"
|
||||
@@ -8895,7 +9043,8 @@ msgstr "Select default role"
|
||||
msgid "Select direction"
|
||||
msgstr "Select direction"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
|
||||
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
|
||||
msgid "Select groups"
|
||||
msgstr "Select groups"
|
||||
|
||||
@@ -8907,9 +9056,8 @@ msgstr "Select groups of members to add to the team."
|
||||
msgid "Select groups to add to this team"
|
||||
msgstr "Select groups to add to this team"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
|
||||
msgid "Select members"
|
||||
msgstr "Select members"
|
||||
|
||||
@@ -9224,6 +9372,10 @@ msgstr "Sign document"
|
||||
msgid "Sign Document"
|
||||
msgstr "Sign Document"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/_layout.tsx
|
||||
msgid "Sign Document - Documenso"
|
||||
msgstr "Sign Document - Documenso"
|
||||
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
|
||||
msgid "Sign Documents"
|
||||
msgstr "Sign Documents"
|
||||
@@ -10193,6 +10345,10 @@ msgstr "Text Align"
|
||||
msgid "Text Color"
|
||||
msgstr "Text Color"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Text colour on primary buttons."
|
||||
msgstr "Text colour on primary buttons."
|
||||
|
||||
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
|
||||
msgid "Text is required"
|
||||
msgstr "Text is required"
|
||||
@@ -10245,6 +10401,10 @@ msgstr "The content to show in the banner, HTML is allowed"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "The default email to use when sending emails to recipients"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10370,6 +10530,14 @@ msgstr "The folder you are trying to move the template to does not exist."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "The following errors occurred:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "The following recipients require an email address:"
|
||||
@@ -10408,6 +10576,10 @@ msgstr "The organisation group you are looking for may have been removed, rename
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "The organisation role that will be applied to all members in this group."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10796,6 +10968,12 @@ msgstr "This feature is not available on your current plan"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "This file type isn't supported. Please upload a PDF or Word document."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
@@ -11054,7 +11232,6 @@ msgstr "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
|
||||
msgid "To mark this document as viewed, you need to be logged in."
|
||||
msgstr "To mark this document as viewed, you need to be logged in."
|
||||
|
||||
#. placeholder {0}: emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'
|
||||
#. placeholder {0}: emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@@ -12068,6 +12245,12 @@ msgstr "We are unable to remove this passkey at the moment. Please try again lat
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "We are unable to update this passkey at the moment. Please try again later."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "We couldn't create a Stripe customer. Please try again."
|
||||
@@ -12094,6 +12277,10 @@ msgstr "We couldn't update the organisation. Please try again."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "We couldn't update the provider. Please try again."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12597,6 +12784,10 @@ msgstr "You are about to delete <0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
@@ -13079,6 +13270,7 @@ msgstr "You have verified your email address for <0>{0}</0>."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "You moved the document to team"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13524,6 +13716,11 @@ msgstr "Your new password cannot be the same as your old password."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Your organisation has been created."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Your organisation has been deleted"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Your organisation has been successfully deleted."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: es\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-07 05:08\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -61,6 +61,12 @@ msgstr "\"{title}\" ha sido ocultado con éxito"
|
||||
msgid "\"Team Name\" has invited you to sign \"example document\"."
|
||||
msgstr "\"Team Name\" te ha invitado a firmar \"example document\"."
|
||||
|
||||
#. placeholder {0}: warning.line
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "(line {0})"
|
||||
msgstr "(línea {0})"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
|
||||
@@ -90,6 +96,12 @@ msgstr "{0, plural, one {# carácter sobre el límite} other {# caracteres sobre
|
||||
msgid "{0, plural, one {# character remaining} other {# characters remaining}}"
|
||||
msgstr "{0, plural, one {# carácter restante} other {# caracteres restantes}}"
|
||||
|
||||
#. placeholder {0}: warnings.length
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "{0, plural, one {# CSS rule was dropped during sanitisation.} other {# CSS rules were dropped during sanitisation.}}"
|
||||
msgstr "{0, plural, one {Se descartó # regla CSS durante la sanitización.} other {Se descartaron # reglas CSS durante la sanitización.}}"
|
||||
|
||||
#. placeholder {0}: folder._count.documents
|
||||
#: apps/remix/app/components/general/folder/folder-card.tsx
|
||||
msgid "{0, plural, one {# document} other {# documents}}"
|
||||
@@ -1426,6 +1438,10 @@ msgstr "Panel de Administración"
|
||||
msgid "Admins only"
|
||||
msgstr "Solo para administradores"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Advanced — Custom CSS"
|
||||
msgstr "Avanzado — CSS personalizado"
|
||||
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
msgid "Advanced Options"
|
||||
@@ -1605,6 +1621,7 @@ msgstr "Un correo electrónico que contiene una invitación se enviará a cada m
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Ya existe un correo electrónico con esta dirección."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2170,6 +2187,10 @@ msgstr "Atrás"
|
||||
msgid "Back home"
|
||||
msgstr "Volver a inicio"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Background"
|
||||
msgstr "Fondo"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "Background Color"
|
||||
msgstr "Color de Fondo"
|
||||
@@ -2191,6 +2212,14 @@ msgstr "Códigos de respaldo"
|
||||
msgid "Banner Updated"
|
||||
msgstr "Banner actualizado"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Base background colour."
|
||||
msgstr "Color de fondo base."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Base text colour."
|
||||
msgstr "Color de texto base."
|
||||
|
||||
#: packages/email/template-components/template-confirmation-email.tsx
|
||||
msgid "Before you get started, please confirm your email address by clicking the button below:"
|
||||
msgstr "Antes de comenzar, por favor confirma tu dirección de correo electrónico haciendo clic en el botón de abajo:"
|
||||
@@ -2213,10 +2242,26 @@ msgstr "Negro"
|
||||
msgid "Blue"
|
||||
msgstr "Azul"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border"
|
||||
msgstr "Borde"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border Radius"
|
||||
msgstr "Radio del borde"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border radius size in REM units (e.g. 0.5rem)."
|
||||
msgstr "Tamaño del radio del borde en unidades REM (p. ej., 0.5rem)."
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx
|
||||
msgid "Bottom"
|
||||
msgstr "Fondo"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Brand Colours"
|
||||
msgstr "Colores de la marca"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Brand Details"
|
||||
msgstr "Detalles de la Marca"
|
||||
@@ -2249,7 +2294,6 @@ msgstr "Logotipo de Marca"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Preferencias de marca"
|
||||
|
||||
@@ -2258,6 +2302,11 @@ msgstr "Preferencias de marca"
|
||||
msgid "Branding preferences updated"
|
||||
msgstr "Preferencias de marca actualizadas"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding preferences updated with warnings"
|
||||
msgstr "Preferencias de marca actualizadas con advertencias"
|
||||
|
||||
#: apps/remix/app/components/general/admin-global-settings-section.tsx
|
||||
msgid "Branding URL"
|
||||
msgstr "URL de la marca"
|
||||
@@ -2340,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "¿No puedes encontrar a alguien?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2793,7 +2843,7 @@ msgstr "Configurar plantilla"
|
||||
msgid "Configure Template"
|
||||
msgstr "Configurar plantilla"
|
||||
|
||||
#. placeholder {0}: parseMessageDescriptor( _, FRIENDLY_FIELD_TYPE[currentField.type], )
|
||||
#. placeholder {0}: parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@@ -2823,6 +2873,7 @@ msgstr "Configura cuándo y con qué frecuencia se envían correos electrónicos
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmar"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3286,6 +3337,11 @@ msgstr "Creando documento"
|
||||
msgid "Creating Template"
|
||||
msgstr "Creando plantilla"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "CSS rules were dropped during sanitisation"
|
||||
msgstr "Se descartaron reglas CSS durante la sanitización"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
msgid "CSV Structure"
|
||||
msgstr "Estructura CSV"
|
||||
@@ -3327,6 +3383,10 @@ msgstr "Actualmente los dominios de correo electrónico solo se pueden configura
|
||||
msgid "Custom {0} MB file"
|
||||
msgstr "Archivo personalizado de {0} MB"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be shown after you save."
|
||||
msgstr "El CSS personalizado se desinfecta al guardar. Las propiedades que rompen el diseño, las URL remotas y los pseudo-elementos se eliminan automáticamente. Cualquier regla descartada durante la desinfección se mostrará después de guardar."
|
||||
|
||||
#: packages/ui/components/document/expiration-period-picker.tsx
|
||||
msgid "Custom duration"
|
||||
msgstr "Duración personalizada"
|
||||
@@ -3339,6 +3399,14 @@ msgstr "Intervalo personalizado"
|
||||
msgid "Custom Organisation Groups"
|
||||
msgstr "Grupos de Organización Personalizados"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Personaliza los colores utilizados en tus páginas de firma."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Zona de peligro"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Modo Oscuro"
|
||||
@@ -3388,6 +3456,10 @@ msgstr "David es el empleado, Lucas es el gerente"
|
||||
msgid "Decline"
|
||||
msgstr "Rechazar"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Default border colour."
|
||||
msgstr "Color de borde predeterminado."
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
msgid "Default Date Format"
|
||||
msgstr "Formato de fecha predeterminado"
|
||||
@@ -3467,6 +3539,8 @@ msgstr "Delegar la propiedad del documento"
|
||||
msgid "delete"
|
||||
msgstr "eliminar"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3516,6 +3590,10 @@ msgstr "eliminar {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "eliminar {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "eliminar {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "eliminar {teamName}"
|
||||
@@ -3568,6 +3646,8 @@ msgstr "Eliminar sobre"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Eliminar Carpeta"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Eliminar organización"
|
||||
@@ -3630,6 +3710,10 @@ msgstr "Eliminado"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Eliminando cuenta..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Eliminación programada"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Destino"
|
||||
@@ -3924,6 +4008,12 @@ msgstr "Correo electrónico de documento completado"
|
||||
msgid "Document Completed!"
|
||||
msgstr "¡Documento completado!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "La conversión de documentos no está disponible temporalmente. Vuelve a intentarlo en unos momentos o sube un PDF."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Documento creado"
|
||||
@@ -4365,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Documentos redactados"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Arrastre y suelte su PDF aquí."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Arrastra y suelta tu documento aquí."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Arrastra y suelta o haz clic para cargar"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Arrastra y suelta tu archivo PDF aquí"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Arrastra y suelta tu documento aquí"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4674,6 +4764,10 @@ msgstr "¡Correo electrónico enviado!"
|
||||
msgid "Email Settings"
|
||||
msgstr "Configuración de Correo Electrónico"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Envía un correo electrónico al propietario de la organización para informarle de la eliminación."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Enviar un correo electrónico al propietario cuando se cree un documento a partir de una plantilla directa"
|
||||
@@ -5125,7 +5219,7 @@ msgstr "Vence"
|
||||
msgid "Expires {0}"
|
||||
msgstr "Expira el {0}"
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat('mm:ss')
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
msgstr "Expira en {0}"
|
||||
@@ -5356,6 +5450,10 @@ msgstr "Rellena los detalles para crear una nueva reclamación de suscripción."
|
||||
msgid "Filter by status"
|
||||
msgstr "Filtrar por estado"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Focus ring colour."
|
||||
msgstr "Color del anillo de enfoque."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx
|
||||
@@ -5414,6 +5512,10 @@ msgstr "Para cada destinatario, proporciona su correo electrónico (obligatorio)
|
||||
msgid "For example, if the claim has a new flag \"FLAG_1\" set to true, then this organisation will get that flag added."
|
||||
msgstr "Por ejemplo, si la reclamación tiene un nuevo flag \"FLAG_1\" establecido en verdadero, entonces esta organización obtendrá ese flag añadido."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Foreground"
|
||||
msgstr "Primer plano"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/check-email.tsx
|
||||
msgid "Forgot password"
|
||||
msgstr "¿Olvidaste tu contraseña?"
|
||||
@@ -6049,6 +6151,10 @@ msgstr "Factura"
|
||||
msgid "IP Address"
|
||||
msgstr "Dirección IP"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Acciones irreversibles para esta organización"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "URL de emisor"
|
||||
@@ -7232,6 +7338,10 @@ msgstr "O continúa con"
|
||||
msgid "Organisation"
|
||||
msgstr "Organización"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "La organización \"{organisationName}\" ha sido eliminada"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Administrador de Organización"
|
||||
@@ -7597,6 +7707,10 @@ msgstr "al mes"
|
||||
msgid "per year"
|
||||
msgstr "al año"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Elimina permanentemente esta organización. Los documentos quedarán huérfanos (no se eliminarán) para que sigan siendo accesibles a través de la cuenta de servicio de cuenta eliminada."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -7915,6 +8029,18 @@ msgstr "Vista previa del documento antes de enviar"
|
||||
msgid "Preview what the signed document will look like with placeholder data"
|
||||
msgstr "Vista previa de cómo se verá el documento firmado con datos de marcador de posición"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary"
|
||||
msgstr "Primario"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary action colour."
|
||||
msgstr "Color de la acción principal."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary Foreground"
|
||||
msgstr "Primer plano primario"
|
||||
|
||||
#: apps/remix/app/components/general/template/template-type.tsx
|
||||
#: apps/remix/app/components/tables/templates-table.tsx
|
||||
#: packages/ui/components/template/template-type-select.tsx
|
||||
@@ -8667,6 +8793,10 @@ msgstr "Revocar todas las sesiones"
|
||||
msgid "Right"
|
||||
msgstr "Derecha"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Ring"
|
||||
msgstr "Anillo"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
@@ -9247,6 +9377,10 @@ msgstr "Firmar documento"
|
||||
msgid "Sign Document"
|
||||
msgstr "Firmar Documento"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/_layout.tsx
|
||||
msgid "Sign Document - Documenso"
|
||||
msgstr "Firmar documento - Documenso"
|
||||
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
|
||||
msgid "Sign Documents"
|
||||
msgstr "Firmar documentos"
|
||||
@@ -10216,6 +10350,10 @@ msgstr "Alineación de texto"
|
||||
msgid "Text Color"
|
||||
msgstr "Color de texto"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Text colour on primary buttons."
|
||||
msgstr "Color del texto en los botones primarios."
|
||||
|
||||
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
|
||||
msgid "Text is required"
|
||||
msgstr "Se requiere texto"
|
||||
@@ -10268,6 +10406,10 @@ msgstr "El contenido que se mostrará en el banner, se permite HTML"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "Correo predeterminado para usar al enviar correos a los destinatarios"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "La eliminación se ejecutará en segundo plano y puede tardar hasta unos minutos en completarse. No vuelvas a ejecutar esta eliminación."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10393,6 +10535,14 @@ msgstr "La carpeta a la que intenta mover la plantilla no existe."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Se produjeron los siguientes errores:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "La siguiente organización ha sido eliminada por un administrador. Tú y tus miembros ya no podrán acceder a esta organización, a sus equipos ni a sus datos asociados."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "La siguiente organización ha sido eliminada. Tú y tus miembros ya no podrán acceder a esta organización, a sus equipos ni a sus datos asociados."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "Los siguientes destinatarios requieren una dirección de correo electrónico:"
|
||||
@@ -10431,6 +10581,10 @@ msgstr "El grupo de organizaciones que estás buscando puede haber sido eliminad
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "El rol de organización que se aplicará a todos los miembros de este grupo."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "La organización se eliminará en segundo plano. Los documentos quedarán huérfanos, no se eliminarán."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10819,6 +10973,12 @@ msgstr "Esta función no está disponible en tu plan actual"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Este campo no se puede modificar ni eliminar. Cuando comparta el enlace directo de esta plantilla o lo agregue a su perfil público, cualquiera que acceda podrá ingresar su nombre y correo electrónico, y completar los campos que se le hayan asignado."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Este tipo de archivo no es compatible. Sube un documento PDF o Word."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Esta carpeta contiene múltiples elementos. Eliminarla eliminará todas las subcarpetas y moverá todos los documentos y plantillas anidados a la carpeta raíz."
|
||||
@@ -11077,7 +11237,6 @@ msgstr "Para marcar este documento como visto, debes iniciar sesión como <0>{0}
|
||||
msgid "To mark this document as viewed, you need to be logged in."
|
||||
msgstr "Para marcar este documento como visto, necesitas estar conectado."
|
||||
|
||||
#. placeholder {0}: emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'
|
||||
#. placeholder {0}: emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@@ -12091,6 +12250,12 @@ msgstr "No podemos eliminar esta clave de acceso en este momento. Por favor, int
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "No podemos actualizar esta clave de acceso en este momento. Por favor, inténtalo de nuevo más tarde."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "No hemos podido convertir este archivo. Comprueba que sea un documento de Word válido o sube un PDF en su lugar."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "No pudimos crear un cliente de Stripe. Por favor, intente nuevamente."
|
||||
@@ -12117,6 +12282,10 @@ msgstr "No pudimos actualizar la organización. Por favor, intente nuevamente."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "No se pudo actualizar al proveedor. Por favor, inténtelo de nuevo."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Hemos encontrado un error al intentar eliminar esta organización. Vuelve a intentarlo más tarde."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12620,6 +12789,10 @@ msgstr "Estás a punto de eliminar <0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Estás a punto de eliminar <0>{0}</0>. Todos los datos relacionados con esta organización, como equipos, documentos y todos los demás recursos serán eliminados. Esta acción es irreversible."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Estás a punto de eliminar <0>{organisationName}</0>. Esta acción no se puede deshacer. Todos los equipos se eliminarán y todos los documentos quedarán asignados a la cuenta de servicio de cuenta eliminada."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Estás a punto de eliminar el siguiente correo electrónico del equipo de <0>{teamName}</0>."
|
||||
@@ -13102,6 +13275,7 @@ msgstr "Has verificado tu dirección de correo electrónico para <0>{0}</0>."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Has movido el documento al equipo"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13547,6 +13721,11 @@ msgstr "Tu nueva contraseña no puede ser la misma que tu antigua contraseña."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Tu organización ha sido creada."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Tu organización ha sido eliminada"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Tu organización ha sido eliminada exitosamente."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: fr\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-07 05:08\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
@@ -61,6 +61,12 @@ msgstr "\"{title}\" a été masqué avec succès"
|
||||
msgid "\"Team Name\" has invited you to sign \"example document\"."
|
||||
msgstr "\"Team Name\" vous a invité à signer \"example document\"."
|
||||
|
||||
#. placeholder {0}: warning.line
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "(line {0})"
|
||||
msgstr "(ligne {0})"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
|
||||
@@ -90,6 +96,12 @@ msgstr "{0, plural, one {# caractère au-dessus de la limite} other {# caractèr
|
||||
msgid "{0, plural, one {# character remaining} other {# characters remaining}}"
|
||||
msgstr "{0, plural, one {# caractère restant} other {# caractères restants}}"
|
||||
|
||||
#. placeholder {0}: warnings.length
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "{0, plural, one {# CSS rule was dropped during sanitisation.} other {# CSS rules were dropped during sanitisation.}}"
|
||||
msgstr "{0, plural, one {# règle CSS a été supprimée lors de la sécurisation.} other {# règles CSS ont été supprimées lors de la sécurisation.}}"
|
||||
|
||||
#. placeholder {0}: folder._count.documents
|
||||
#: apps/remix/app/components/general/folder/folder-card.tsx
|
||||
msgid "{0, plural, one {# document} other {# documents}}"
|
||||
@@ -393,11 +405,11 @@ msgstr "{maximumEnvelopeItemCount, plural, one {Vous ne pouvez pas téléverser
|
||||
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-page.tsx
|
||||
msgid "{recipientActionVerb} document"
|
||||
msgstr "{recipientActionVerb} document"
|
||||
msgstr "{recipientActionVerb} le document"
|
||||
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-page.tsx
|
||||
msgid "{recipientActionVerb} the document to complete the process."
|
||||
msgstr "{recipientActionVerb} the document to complete the process."
|
||||
msgstr "{recipientActionVerb} le document pour terminer le processus."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
|
||||
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
|
||||
@@ -903,7 +915,7 @@ msgstr "Un appareil capable d'accéder, d'ouvrir et de lire des documents"
|
||||
|
||||
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
|
||||
msgid "A document was created by your direct template that requires you to {recipientActionVerb} it."
|
||||
msgstr "Un document a été créé par votre modèle direct qui vous oblige à {recipientActionVerb}."
|
||||
msgstr "Un document a été créé par votre modèle direct et vous devez le {recipientActionVerb}."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "A draft document will be created"
|
||||
@@ -1426,6 +1438,10 @@ msgstr "Panneau d'administration"
|
||||
msgid "Admins only"
|
||||
msgstr "Administrateurs uniquement"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Advanced — Custom CSS"
|
||||
msgstr "Avancé — CSS personnalisé"
|
||||
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
msgid "Advanced Options"
|
||||
@@ -1605,6 +1621,7 @@ msgstr "Un e-mail contenant une invitation sera envoyé à chaque membre."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Un email avec cette adresse existe déjà."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2170,6 +2187,10 @@ msgstr "Retour"
|
||||
msgid "Back home"
|
||||
msgstr "Retour au tableau de bord"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Background"
|
||||
msgstr "Arrière-plan"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "Background Color"
|
||||
msgstr "Couleur d'arrière-plan"
|
||||
@@ -2191,6 +2212,14 @@ msgstr "Codes de sauvegarde"
|
||||
msgid "Banner Updated"
|
||||
msgstr "Bannière mise à jour"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Base background colour."
|
||||
msgstr "Couleur d’arrière-plan de base."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Base text colour."
|
||||
msgstr "Couleur de texte de base."
|
||||
|
||||
#: packages/email/template-components/template-confirmation-email.tsx
|
||||
msgid "Before you get started, please confirm your email address by clicking the button below:"
|
||||
msgstr "Avant de commencer, veuillez confirmer votre adresse e-mail en cliquant sur le bouton ci-dessous :"
|
||||
@@ -2213,10 +2242,26 @@ msgstr "Noir"
|
||||
msgid "Blue"
|
||||
msgstr "Bleu"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border"
|
||||
msgstr "Bordure"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border Radius"
|
||||
msgstr "Rayon de la bordure"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Border radius size in REM units (e.g. 0.5rem)."
|
||||
msgstr "Taille du rayon de la bordure en unités REM (par ex. 0.5rem)."
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx
|
||||
msgid "Bottom"
|
||||
msgstr "Bas"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Brand Colours"
|
||||
msgstr "Couleurs de la marque"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Brand Details"
|
||||
msgstr "Détails de la marque"
|
||||
@@ -2249,7 +2294,6 @@ msgstr "Logo de la marque"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding Preferences"
|
||||
msgstr "Préférences de branding"
|
||||
|
||||
@@ -2258,6 +2302,11 @@ msgstr "Préférences de branding"
|
||||
msgid "Branding preferences updated"
|
||||
msgstr "Préférences de branding mises à jour"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "Branding preferences updated with warnings"
|
||||
msgstr "Préférences de branding mises à jour avec des avertissements"
|
||||
|
||||
#: apps/remix/app/components/general/admin-global-settings-section.tsx
|
||||
msgid "Branding URL"
|
||||
msgstr "URL de l’image de marque"
|
||||
@@ -2340,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Vous ne trouvez pas quelqu’un ?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2793,7 +2843,7 @@ msgstr "Configurer le modèle"
|
||||
msgid "Configure Template"
|
||||
msgstr "Configurer le modèle"
|
||||
|
||||
#. placeholder {0}: parseMessageDescriptor( _, FRIENDLY_FIELD_TYPE[currentField.type], )
|
||||
#. placeholder {0}: parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@@ -2823,6 +2873,7 @@ msgstr "Configurer quand et à quelle fréquence des e-mails de rappel sont envo
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmer"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -2896,7 +2947,7 @@ msgstr "Continuer en approuvant le document."
|
||||
#: packages/email/template-components/template-document-invite.tsx
|
||||
#: packages/email/template-components/template-document-reminder.tsx
|
||||
msgid "Continue by assisting with the document."
|
||||
msgstr "Continuez en aidant avec le document."
|
||||
msgstr "Continuer en collaborant sur le document."
|
||||
|
||||
#: packages/email/template-components/template-document-completed.tsx
|
||||
msgid "Continue by downloading the document."
|
||||
@@ -3286,6 +3337,11 @@ msgstr "Création du document"
|
||||
msgid "Creating Template"
|
||||
msgstr "Création du modèle"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
|
||||
msgid "CSS rules were dropped during sanitisation"
|
||||
msgstr "Des règles CSS ont été supprimées lors de la sécurisation"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
msgid "CSV Structure"
|
||||
msgstr "Structure CSV"
|
||||
@@ -3327,6 +3383,10 @@ msgstr "Actuellement, les domaines de messagerie ne peuvent être configurés qu
|
||||
msgid "Custom {0} MB file"
|
||||
msgstr "Fichier personnalisé de {0} Mo"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be shown after you save."
|
||||
msgstr "Le CSS personnalisé est nettoyé lors de l’enregistrement. Les propriétés pouvant casser la mise en page, les URL distantes et les pseudo-éléments sont supprimés automatiquement. Toutes les règles supprimées pendant la sanitisation seront affichées après l’enregistrement."
|
||||
|
||||
#: packages/ui/components/document/expiration-period-picker.tsx
|
||||
msgid "Custom duration"
|
||||
msgstr "Durée personnalisée"
|
||||
@@ -3339,6 +3399,14 @@ msgstr "Intervalle personnalisé"
|
||||
msgid "Custom Organisation Groups"
|
||||
msgstr "Groupes d'organisation personnalisés"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Personnalisez les couleurs utilisées sur vos pages de signature."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Zone de danger"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Mode sombre"
|
||||
@@ -3388,6 +3456,10 @@ msgstr "David est l'employé, Lucas est le manager"
|
||||
msgid "Decline"
|
||||
msgstr "Décliner"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Default border colour."
|
||||
msgstr "Couleur de bordure par défaut."
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
msgid "Default Date Format"
|
||||
msgstr "Format de date par défaut"
|
||||
@@ -3467,6 +3539,8 @@ msgstr "Déléguer la propriété du document"
|
||||
msgid "delete"
|
||||
msgstr "supprimer"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3516,6 +3590,10 @@ msgstr "supprimer {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "supprimer {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "supprimer {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "supprimer {teamName}"
|
||||
@@ -3568,6 +3646,8 @@ msgstr "Supprimer l’enveloppe"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Supprimer le Dossier"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Supprimer l'organisation"
|
||||
@@ -3630,6 +3710,10 @@ msgstr "Supprimé"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Suppression du compte..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Suppression planifiée"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Destination"
|
||||
@@ -3924,6 +4008,12 @@ msgstr "E-mail de document complété"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Document Complété !"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "La conversion de documents est temporairement indisponible. Veuillez réessayer dans quelques instants ou téléverser un PDF."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Document créé"
|
||||
@@ -4365,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Documents brouillon"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Faites glisser et déposez votre PDF ici."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Faites glisser et déposez votre document ici."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Glissez-déposez ou cliquez pour importer"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Faites glisser et déposez votre fichier PDF ici"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Faites glisser et déposez votre document ici"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4674,6 +4764,10 @@ msgstr "Email envoyé !"
|
||||
msgid "Email Settings"
|
||||
msgstr "Paramètres de l'email"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Envoyez un e-mail au propriétaire de l’organisation pour l’informer de la suppression."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Envoyer un e-mail au propriétaire lorsqu’un document est créé à partir d’un modèle direct"
|
||||
@@ -5125,7 +5219,7 @@ msgstr "Expire le"
|
||||
msgid "Expires {0}"
|
||||
msgstr "Expire le {0}"
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat('mm:ss')
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
msgstr "Expire dans {0}"
|
||||
@@ -5356,6 +5450,10 @@ msgstr "Remplissez les détails pour créer une nouvelle réclamation d'abonneme
|
||||
msgid "Filter by status"
|
||||
msgstr "Filtrer par statut"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Focus ring colour."
|
||||
msgstr "Couleur de l’anneau de focus."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx
|
||||
@@ -5414,6 +5512,10 @@ msgstr "Pour chaque destinataire, fournissez son e-mail (obligatoire) et son nom
|
||||
msgid "For example, if the claim has a new flag \"FLAG_1\" set to true, then this organisation will get that flag added."
|
||||
msgstr "Par exemple, si la réclamation a un nouveau drapeau \"FLAG_1\" réglé sur vrai, alors cette organisation obtiendra ce drapeau ajouté."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Foreground"
|
||||
msgstr "Premier plan"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/check-email.tsx
|
||||
msgid "Forgot password"
|
||||
msgstr "Mot de passe oublié"
|
||||
@@ -6049,6 +6151,10 @@ msgstr "Facture"
|
||||
msgid "IP Address"
|
||||
msgstr "Adresse IP"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Actions irréversibles pour cette organisation"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "URL de l'émetteur"
|
||||
@@ -7232,6 +7338,10 @@ msgstr "Ou continuez avec"
|
||||
msgid "Organisation"
|
||||
msgstr "Organisation"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "L’organisation \"{organisationName}\" a été supprimée"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Administrateur de l'organisation"
|
||||
@@ -7597,6 +7707,10 @@ msgstr "par mois"
|
||||
msgid "per year"
|
||||
msgstr "par an"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Supprimez définitivement cette organisation. Les documents seront orphelins (non supprimés) afin de rester accessibles via le compte de service de compte supprimé."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -7915,6 +8029,18 @@ msgstr "Aperçu du document avant l'envoi"
|
||||
msgid "Preview what the signed document will look like with placeholder data"
|
||||
msgstr "Aperçu de l'apparence du document signé avec les données de remplacement"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary"
|
||||
msgstr "Principal"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary action colour."
|
||||
msgstr "Couleur de l’action principale."
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Primary Foreground"
|
||||
msgstr "Premier plan principal"
|
||||
|
||||
#: apps/remix/app/components/general/template/template-type.tsx
|
||||
#: apps/remix/app/components/tables/templates-table.tsx
|
||||
#: packages/ui/components/template/template-type-select.tsx
|
||||
@@ -8667,6 +8793,10 @@ msgstr "Révoquer toutes les sessions"
|
||||
msgid "Right"
|
||||
msgstr "Droit"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Ring"
|
||||
msgstr "Anneau"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
@@ -9247,6 +9377,10 @@ msgstr "Signer le document"
|
||||
msgid "Sign Document"
|
||||
msgstr "Signer le document"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/_layout.tsx
|
||||
msgid "Sign Document - Documenso"
|
||||
msgstr "Signer le document - Documenso"
|
||||
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx
|
||||
msgid "Sign Documents"
|
||||
msgstr "Signer des documents"
|
||||
@@ -10216,6 +10350,10 @@ msgstr "Alignement du texte"
|
||||
msgid "Text Color"
|
||||
msgstr "Couleur du texte"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "Text colour on primary buttons."
|
||||
msgstr "Couleur du texte sur les boutons principaux."
|
||||
|
||||
#: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
|
||||
msgid "Text is required"
|
||||
msgstr "Le texte est requis"
|
||||
@@ -10268,6 +10406,10 @@ msgstr "Le contenu à afficher dans la bannière, le HTML est autorisé"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "L'e-mail par défaut à utiliser lors de l'envoi de courriels aux destinataires"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "La suppression s’exécutera en arrière-plan et peut prendre quelques minutes pour se terminer. Ne relancez pas cette suppression."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10393,6 +10535,14 @@ msgstr "Le dossier vers lequel vous essayez de déplacer le modèle n'existe pas
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Les erreurs suivantes se sont produites :"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "L’organisation suivante a été supprimée par un administrateur. Vous et vos membres ne pourrez plus accéder à cette organisation, à ses équipes ni aux données qui y sont associées."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "L’organisation suivante a été supprimée. Vous et vos membres ne pourrez plus accéder à cette organisation, à ses équipes ni aux données qui y sont associées."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "Les destinataires suivants doivent avoir une adresse e-mail :"
|
||||
@@ -10431,6 +10581,10 @@ msgstr "Le groupe d'organisation que vous recherchez a peut-être été supprim
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "Le rôle d'organisation qui sera appliqué à tous les membres de ce groupe."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "L’organisation sera supprimée en arrière-plan. Les documents seront rendus orphelins, et non supprimés."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10819,6 +10973,12 @@ msgstr "Cette fonctionnalité n’est pas disponible avec votre offre actuelle"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Ce champ ne peut pas être modifié ou supprimé. Lorsque vous partagez le lien direct de ce modèle ou l'ajoutez à votre profil public, toute personne qui y accède peut saisir son nom et son email, et remplir les champs qui lui sont attribués."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Ce type de fichier n’est pas pris en charge. Veuillez téléverser un document PDF ou Word."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Ce dossier contient plusieurs éléments. Le supprimer supprimera tous les sous-dossiers et déplacera tous les documents et modèles intégrés vers le dossier racine."
|
||||
@@ -11077,7 +11237,6 @@ msgstr "Pour marquer ce document comme consulté, vous devez être connecté en
|
||||
msgid "To mark this document as viewed, you need to be logged in."
|
||||
msgstr "Pour marquer ce document comme vu, vous devez être connecté."
|
||||
|
||||
#. placeholder {0}: emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'
|
||||
#. placeholder {0}: emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@@ -11942,15 +12101,15 @@ msgstr "Voir le document"
|
||||
|
||||
#: packages/email/template-components/template-document-invite.tsx
|
||||
msgid "View Document to approve"
|
||||
msgstr "Voir Document à approuver"
|
||||
msgstr "Voir le document à approuver"
|
||||
|
||||
#: packages/email/template-components/template-document-invite.tsx
|
||||
msgid "View Document to assist"
|
||||
msgstr "Voir Document pour assister"
|
||||
msgstr "Voir le document pour collaborer"
|
||||
|
||||
#: packages/email/template-components/template-document-invite.tsx
|
||||
msgid "View Document to sign"
|
||||
msgstr "Voir Document pour signature"
|
||||
msgstr "Voir le document à signer"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
msgid "View documents associated with this email"
|
||||
@@ -12091,6 +12250,12 @@ msgstr "Nous ne pouvons pas supprimer cette clé de passkey pour le moment. Veui
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "Nous ne pouvons pas mettre à jour cette clé de passkey pour le moment. Veuillez réessayer plus tard."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "Nous n’avons pas pu convertir ce fichier. Veuillez vérifier qu’il s’agit d’un document Word valide ou téléverser un fichier PDF à la place."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Nous n'avons pas pu créer un client Stripe. Veuillez réessayer."
|
||||
@@ -12117,6 +12282,10 @@ msgstr "Nous n'avons pas pu mettre à jour l'organisation. Veuillez réessayer."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "Nous n'avons pas pu mettre à jour le fournisseur. Veuillez réessayer."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Nous avons rencontré une erreur lors de la tentative de suppression de cette organisation. Veuillez réessayer plus tard."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12620,6 +12789,10 @@ msgstr "Vous êtes sur le point de supprimer <0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Vous êtes sur le point de supprimer <0>{0}</0>. Toutes les données relatives à cette organisation, telles que les équipes, les documents et toutes les autres ressources, seront supprimées. Cette action est irréversible."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Vous êtes sur le point de supprimer <0>{organisationName}</0>. Cette action est irréversible. Toutes les équipes seront supprimées et tous les documents seront rattachés de manière orpheline au compte de service du compte supprimé."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Vous êtes sur le point de supprimer l'e-mail d'équipe suivant de <0>{teamName}</0>."
|
||||
@@ -12974,7 +13147,7 @@ msgstr "Vous avez refusé l'invitation de <0>{0}</0> à rejoindre leur organisat
|
||||
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
|
||||
#: packages/lib/server-only/document/resend-document.ts
|
||||
msgid "You have initiated the document {0} that requires you to {recipientActionVerb} it."
|
||||
msgstr "Vous avez initié le document {0} qui nécessite que vous {recipientActionVerb} celui-ci."
|
||||
msgstr "Vous avez initié le document {0} et devez le {recipientActionVerb}."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "You have no webhooks yet. Your webhooks will be shown here once you create them."
|
||||
@@ -13034,7 +13207,7 @@ msgstr "Vous avez rejeté ce document"
|
||||
|
||||
#: packages/email/template-components/template-document-self-signed.tsx
|
||||
msgid "You have signed “{documentName}”"
|
||||
msgstr "Vous avez signé “{documentName}”"
|
||||
msgstr "Vous avez signé « {documentName} »"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-leave-dialog.tsx
|
||||
msgid "You have successfully left this organisation."
|
||||
@@ -13102,6 +13275,7 @@ msgstr "Vous avez vérifié votre adresse e-mail pour <0>{0}</0>."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Vous avez déplacé le document vers l'équipe"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13547,6 +13721,11 @@ msgstr "Ton nouveau mot de passe ne peut pas être le même que l'ancien."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Votre organisation a été créée."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Votre organisation a été supprimée"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Votre organisation a été supprimée avec succès."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user