diff --git a/.agents/plans/sharp-gold-mountain-custom-brand-logo-url.md b/.agents/plans/sharp-gold-mountain-custom-brand-logo-url.md new file mode 100644 index 000000000..e1b573c3e --- /dev/null +++ b/.agents/plans/sharp-gold-mountain-custom-brand-logo-url.md @@ -0,0 +1,122 @@ +--- +date: 2026-05-28 +title: Custom Brand Logo Url +--- + +# Problem + +`brandingUrl` (the configured "Brand Website") is persisted and editable in branding +settings, but historically it was never consumed anywhere. It flowed into the database, +the settings form, and the admin read-only view, but never affected any rendered output. + +We want `brandingUrl` to actually do something, with deliberately different behavior per +surface. + +# Relationship we're going for + +`brandingUrl` is an **email-only** linking concept. It is intentionally **not** used on +in-app signing surfaces. + +| Surface | Custom branding logo configured | `brandingUrl` behavior | +| --- | --- | --- | +| Transactional emails (logo) | Logo shown | Logo links to `brandingUrl` when it is a safe http(s) URL; otherwise plain image | +| Transactional emails (footer) | n/a | `brandingUrl` rendered as a link in the footer when it is a safe http(s) URL | +| Signing pages (V1 + V2, normal + direct-template) | Logo shown | Ignored — logo is a plain image with no link | +| Signing pages (no custom logo) | Documenso fallback shown | Fallback keeps its internal `/` link | +| Embedded signing | Logo shown | Ignored (logo not linked) | +| Embedded authoring/editor | Logo shown | Ignored | +| Settings / admin branding previews | n/a | Unchanged (display only) | + +Rationale: + +- On signing pages the recipient is mid-task; sending them off to an external marketing + site via the logo is undesirable, so the custom logo is a plain image there. +- In emails the logo and a footer link to the brand's own site are a normal, expected + pattern and reinforce that the email is legitimately from that brand. + +# Decisions + +## Scope + +- Use `brandingUrl` only in transactional email rendering: + - The shared email logo component links the custom branding logo to `brandingUrl`. + - The shared email footer renders `brandingUrl` as a link. +- On signing surfaces, render a configured custom branding logo as a plain image with no + link wrapper. Leave the Documenso fallback logo's internal `/` link untouched. +- Do not change embedded signing, embedded authoring/editor, or settings/admin previews. +- No Prisma schema or database migration. `brandingUrl` already exists and is editable. + +## URL safety + +Rendering must be defensive because old/imported data can bypass the branding form's URL +validation. Only treat the stored value as a usable Brand Website when it parses as an +absolute `http:` or `https:` URL. + +- Empty, missing, invalid, relative, or non-http(s) values are treated as "no Brand + Website" and produce a plain logo / no footer link. +- Do not mutate stored settings or run a cleanup migration. +- Factored into a single shared helper so both email logo and footer apply identical rules: + - `packages/email/utils/branding-url.ts` -> `getSafeBrandingUrl(value): string | null`. + +## Email rendering + +- New shared component `packages/email/template-components/template-branding-logo.tsx` + (`TemplateBrandingLogo`) renders either: + - the custom branding logo, wrapped in a `Link` to the safe `brandingUrl` with + `target="_blank"` when one exists, or a plain `Img` when not; or + - the Documenso fallback logo (`/static/logo.png`) when custom branding is disabled or + no logo is set. +- This component replaced the duplicated `brandingEnabled && brandingLogo ? : ` + ternary that was copy-pasted across all transactional email templates. +- `packages/email/template-components/template-footer.tsx` renders `brandingUrl` as a + footer link (via `getSafeBrandingUrl`) when branding is enabled and the URL is safe. + +The branding context already exposes `brandingUrl` (`packages/email/providers/branding.tsx`), +populated by `teamGlobalSettingsToBranding` / `organisationGlobalSettingsToBranding` +(which spread `...settings`), so no additional plumbing into the email branding context was +required. + +## Signing rendering + +- `apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx`: + custom logo renders as a bare ``. `brandingUrl` is not read; the local branding type + and loader payload no longer carry it. +- `apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx` (V2, + shared by normal and direct-template signing): custom logo renders as a bare ``; the + Documenso fallback keeps its ``. +- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx`: V1 loader branding payload no + longer includes `brandingUrl`. +- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` and + `get-envelope-for-direct-template-signing.ts`: `brandingUrl` removed from the V2 + `EnvelopeForSigningResponse.settings` schema/payload since it is not consumed there. + +# History + +An earlier iteration of this plan wired `brandingUrl` into the in-app signing pages so a +custom logo linked to the Brand Website (external ``, internal `/` +fallback otherwise) and added `brandingUrl` to the V1/V2 signing payloads. That direction +was reversed: signing-page logos are now plain images and `brandingUrl` is email-only. The +signing payload additions were removed. + +# Test coverage + +`packages/app-tests/e2e/signing-branding.spec.ts`: + +- V1 normal `/sign/:token`: custom logo is a plain image, not inside a link, and no + `brandingUrl` link is present. +- V2 normal `/sign/:token` and V2 direct-template: same plain-image assertions. +- V2 with no custom logo: Documenso fallback still links to `/`. +- Embedded signing: no custom-logo Brand Website link is rendered. + +# Acceptance criteria + +- A custom branding logo on any signing surface (V1, V2 normal, V2 direct-template, embedded) + renders as a plain image with no link, and `brandingUrl` is never rendered as a link there. +- Documenso fallback logos continue linking to `/`. +- In transactional emails, when a custom logo and a safe `brandingUrl` are configured, the + email logo links to `brandingUrl` (new tab) and the footer shows the Brand Website link. +- In transactional emails, when `brandingUrl` is empty/invalid/relative/non-http(s), the logo + is a plain image and no footer Brand Website link is shown. +- URL safety is enforced through the single shared `getSafeBrandingUrl` helper. +- Settings/admin branding previews are unchanged. +- No schema or migration changes. diff --git a/.env.example b/.env.example index f723e1c66..aa6565f8c 100644 --- a/.env.example +++ b/.env.example @@ -48,7 +48,7 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" # [[SIGNING]] -# The transport to use for document signing. Available options: local (default) | gcloud-hsm +# The transport to use for document signing. Available options: local (default) | gcloud-hsm | csc NEXT_PRIVATE_SIGNING_TRANSPORT="local" # OPTIONAL: The passphrase to use for the local file-based signing transport. NEXT_PRIVATE_SIGNING_PASSPHRASE= @@ -70,6 +70,14 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH= NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS= # OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH= +# OPTIONAL: The base URL of the Cloud Signature Consortium (CSC) provider for the csc signing transport. +NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL= +# OPTIONAL: The OAuth client ID registered with the CSC provider for the csc signing transport. +NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID= +# OPTIONAL: The OAuth client secret registered with the CSC provider for the csc signing transport. +NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET= +# OPTIONAL: Default signature level for envelopes created on a CSC instance when the caller doesn't specify one. Available options: AES (default) | QES. Explicit AES/QES requests always pass through unchanged. +NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL= # OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps). NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY= # OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL. diff --git a/.vscode/settings.json b/.vscode/settings.json index 7ef2f1a5a..15c6dc877 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,6 +29,6 @@ "editor.defaultFormatter": "biomejs.biome" }, "[json]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.json-language-features" } } diff --git a/README.md b/README.md index f5117abd0..b52220554 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ For full instructions, requirements, and configuration details, see the [Self Ho #### Railway -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) #### Render diff --git a/SIGNING.md b/SIGNING.md index cb719ffb8..f794bc9ba 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -60,7 +60,7 @@ We support a variety of deployment methods, and are actively working on adding m ## Railway -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) ## Render diff --git a/apps/docs/content/docs/developers/embedding/iframe.mdx b/apps/docs/content/docs/developers/embedding/iframe.mdx new file mode 100644 index 000000000..70077fcfc --- /dev/null +++ b/apps/docs/content/docs/developers/embedding/iframe.mdx @@ -0,0 +1,81 @@ +--- +title: iframe +description: Embed the signing experience directly in your application using an iframe. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + + Embedding via iframe is not recommended. We strongly recommend using the [official SDKs](/docs/developers/embedding/sdks) instead. + + +### Basic iframe Embedding + +```html + +``` + + + The URL you embed depends on the embed mode you’re using (for example direct links vs sign-token embeds). Use the + embed URL provided by Documenso for your flow. + + +### iframe Customization + +You can customize the embedded signing experience by passing **encoded options in the iframe URL fragment** (everything +after `#`). + +Documenso expects the fragment to be **base64** of: + +- `encodeURIComponent(JSON.stringify(options))` + +#### Supported options + +| Option | Type | Description | +| ------ | ---- | ----------- | +| `name` | `string` | Prefill signer name. | +| `email` | `string` | Prefill signer email. | +| `lockName` | `boolean` | Lock the name field (prevents editing). | +| `lockEmail` | `boolean` | Lock the email field (prevents editing). | +| `language` | `string` | Force the embed language (e.g. `en`). | +| `darkModeDisabled` | `boolean` | Disable dark mode behavior. | +| `allowDocumentRejection` | `boolean` | Allow or disallow document rejection. | +| `css` | `string` | Inject custom CSS into the embed. | +| `cssVars` | `object` | Override embed CSS variables (see the CSS Variables page). | + +#### Example + +```ts +const buildEmbedSrc = (host: string, token: string) => { + const options = { + name: 'Ada Lovelace', + email: 'ada@example.com', + lockName: true, + lockEmail: true, + language: 'en', + darkModeDisabled: false, + allowDocumentRejection: true, + css: ':root { --radius: 12px; }', + cssVars: {}, + }; + + const encodedOptions = btoa(encodeURIComponent(JSON.stringify(options))); + + return `${new URL(`/embed/sign/${token}`, host).toString()}#${encodedOptions}`; +}; +``` + +A complete example can be found in the [Embeds repository](https://github.com/documenso/embeds/blob/main/packages/mitosis/src/sign-document.lite.tsx). + + + The fragment is **not sent to the server** as part of the HTTP request, but it is available to the embedded app in + the browser. This makes it a convenient way to pass client-side configuration without changing the base embed URL. + diff --git a/apps/docs/content/docs/developers/embedding/meta.json b/apps/docs/content/docs/developers/embedding/meta.json index 7ccec52c0..75b7276d5 100644 --- a/apps/docs/content/docs/developers/embedding/meta.json +++ b/apps/docs/content/docs/developers/embedding/meta.json @@ -1,4 +1,4 @@ { "title": "Embedding", - "pages": ["sdks", "direct-links", "css-variables", "editor"] + "pages": ["sdks", "direct-links", "css-variables", "editor", "iframe"] } diff --git a/apps/docs/content/docs/policies/meta.json b/apps/docs/content/docs/policies/meta.json index 14ba43d70..2251871a5 100644 --- a/apps/docs/content/docs/policies/meta.json +++ b/apps/docs/content/docs/policies/meta.json @@ -8,6 +8,7 @@ "privacy", "terms", "security", + "verify-email", "support" ] } diff --git a/apps/docs/content/docs/policies/verify-email.mdx b/apps/docs/content/docs/policies/verify-email.mdx new file mode 100644 index 000000000..f2d27277e --- /dev/null +++ b/apps/docs/content/docs/policies/verify-email.mdx @@ -0,0 +1,68 @@ +--- +title: Verifying Emails from Documenso +description: How to confirm that an email is genuinely from Documenso, and what to do if you receive a suspicious message. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +## Check the Sender Domain + +All email sent by Documenso originates from one of the following domains. If you receive an email claiming to be from Documenso and the sender address does not end in one of these domains, treat it as suspicious. + +| Domain | Used for | +| ------------------------ | -------------------------------------------------------------- | +| `app.documenso.com` | Transactional email | +| `documensomail.com` | Transactional email | +| `documensoemail.com` | Transactional email | +| Custom domain | [Enterprise organisations](/docs/users/organisations/email-domains) using a custom email domain | + +Typical sender addresses include: + +- `noreply@app.documenso.com` +- `noreply@free.documensomail.com` +- `noreply@send.documensoemail.com` + + + A misspelling such as `documenso-email.com`, `documensoemaiI.com` (capital i instead of l), or any other variation is not a Documenso domain. + + +## Types of Email Documenso Sends + +Documenso sends email only for the following purposes: + +- **Account verification** — confirming your email address when you sign up or change it +- **Password reset** — a link to reset your password that you requested +- **Document invitations** — notifying you that a document has been shared with you to sign, approve, or view +- **Signing reminders** — follow-up reminders for pending document actions +- **Completed document notifications** — confirmation that all parties have signed a document +- **Team invitations** — inviting you to join an organisation or team + +## What Documenso Will Never Do + +- Ask for your password via email +- Send you an attachment and ask you to open it to verify your identity +- Ask you to confirm payment details or billing information over email +- Send unsolicited marketing emails if you have not opted in + +## How to Tell If an Email Is Legitimate + +1. **Check the sender address** — the domain must be `documenso.com` or `documensomail.com` +2. **Look at the link destination** — hover over any link before clicking; it should point to `app.documenso.com` +3. **Watch for urgency or threats** — legitimate Documenso emails do not threaten account suspension to pressure you into clicking a link immediately +4. **Verify the action yourself** — if in doubt, log in to [app.documenso.com](https://app.documenso.com) directly (not via the email link) and check whether the document or notification exists there + +## Report a Suspicious Email + +If you receive an email that appears to impersonate Documenso: + +1. Do not click any links or download any attachments +2. Forward the email as an attachment to **support@documenso.com** +3. Delete the email from your inbox + +You can also report phishing emails directly to your email provider using their built-in reporting tools. + +## Related + +- [Security Policy](/docs/policies/security) — Documenso's security practices and vulnerability disclosure process +- [Create an Account](/docs/users/getting-started/create-account) — What to expect during sign-up +- [Security Settings](/docs/users/settings/security) — Enable two-factor authentication and manage sessions diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index 1d57b5062..33f723c31 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -186,9 +186,9 @@ Documenso requires a certificate to digitally sign documents. ### Transport Selection -| Variable | Description | Default | -| -------------------------------- | ---------------------------------------- | ------- | -| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local` or `gcloud-hsm` | `local` | +| Variable | Description | Default | +| -------------------------------- | ------------------------------------------------- | ------- | +| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local`, `gcloud-hsm`, or `csc` | `local` | ### Local Signing @@ -210,11 +210,36 @@ Documenso requires a certificate to digitally sign documents. | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | Base64-encoded certificate chain | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | Google Secret Manager path for certificate retrieval | +### Cloud Signature Consortium (CSC) + +Routes signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures (AES/QES). Instance-wide; set `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` to enable. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup walkthrough. + +CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Without a valid license, the instance will refuse to start in `csc` mode. + +| Variable | Description | Default | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------- | +| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | | +| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | | +| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | | +| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller doesn't specify one. `AES` or `QES`. Explicit requests pass through. | `AES` | + +The OAuth callback URL registered with the CSC provider is fixed at `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` — register this exact URL with the TSP. + +#### Derived Public Variables + +The following client-visible variable is **derived automatically** from the private transport at server startup. Do not set it manually — any value set in the environment is overwritten on boot. + +| Variable | Derived from | Value | +| ------------------------------------- | -------------------------------------------------- | ------------------------------------------------- | +| `NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` | `NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc'` | `'true'` when CSC mode is active, else `'false'` | + +The authoring UI uses this flag to gate features that AES/QES envelopes cannot support (parallel signing, assistant role, dictate next signer). Deriving it from the private transport prevents the client-side flag from drifting from the real server-side configuration. + ### Signature Options | Variable | Description | Default | | ------------------------------------------- | ----------------------------------------------------------- | ---------- | -| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures | | +| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures. Optional for `local` / `gcloud-hsm` (signatures omit the timestamp when unset). **Required** when `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` — the instance refuses to start without it. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes#timestamp-authority-resolution). | | | `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info embedded in PDF signatures | Webapp URL | | `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Use `adbe.pkcs7.detached` instead of `ETSI.CAdES.detached` | `false` | diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx new file mode 100644 index 000000000..0495430d7 --- /dev/null +++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx @@ -0,0 +1,213 @@ +--- +title: CSC (AES / QES) +description: Configure Cloud Signature Consortium signing for Advanced and Qualified Electronic Signatures via a third-party Trust Service Provider. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; + +The `csc` signing transport routes signatures through a third-party Trust Service Provider (TSP) using the [Cloud Signature Consortium API v1.0.4.0](https://cloudsignatureconsortium.org/). Each recipient authenticates directly with the TSP (Strong Customer Authentication) and the TSP returns a per-recipient signature bound to the document hash. Documenso assembles the resulting PAdES signature inside the PDF. + +This transport enables **Advanced Electronic Signatures (AES)** and **Qualified Electronic Signatures (QES)** under eIDAS. See [Signature Levels](/docs/compliance/signature-levels) for the legal framework. + + + CSC mode is **instance-wide**: one CSC provider per Documenso install. All envelopes created + while the instance runs in `csc` mode use AES or QES. Switching `NEXT_PRIVATE_SIGNING_TRANSPORT` + is a one-way operational migration — see [Switching Transports](#switching-transports). + + + + CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The + instance refuses to start in `csc` mode without it. + + +## Prerequisites + +{/* prettier-ignore */} + + + +### A TSP account + +Establish a relationship with a CSC-compatible Trust Service Provider. The TSP issues qualified or advanced certificates to your signers, holds the private keys in its HSM, and exposes a CSC v1.0.4.0-compliant API. + + + + +### OAuth client credentials + +Register Documenso as an OAuth client with the TSP. You will receive a client ID and client secret, and must supply Documenso's callback URL when registering: + +``` +${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback +``` + +The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL` and the route mount path. There is no env var to override it; ensuring the registered URL matches your instance's webapp URL exactly is the operator's responsibility. + + + + +### Enterprise Edition license + +CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). + + + + +### S3 storage (strongly recommended) + +CSC produces multiple `DocumentData` rows per envelope item (one per recipient signature, plus the materialised and source rows). Database-backed storage base64-inflates each row by ~33% and is impractical at meaningful PDF sizes. Configure [S3 storage](/docs/self-hosting/configuration/storage) before enabling CSC. + + + + +## Environment Variables + +| Variable | Description | Default | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Set to `csc` | | +| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | | +| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | | +| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | | +| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller does not specify one. `AES` or `QES`. Explicit requests always pass through. | `AES` | +| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | **Required.** Comma-separated RFC 3161 TSA URLs. Always used for B-LTA archival timestamps at seal time, and also serves as the B-T sign-time fallback when the TSP does not expose `signatures/timestamp`. The instance refuses to start in CSC mode without it. See [Timestamp Authority Resolution](#timestamp-authority-resolution). | | + + + `NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` is set automatically from + `NEXT_PRIVATE_SIGNING_TRANSPORT` at server startup. Do not set it manually — see + [Environment Variables](/docs/self-hosting/configuration/environment#derived-public-variables). + + +## Configuration Example + +```bash +NEXT_PRIVATE_SIGNING_TRANSPORT=csc +NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=https://api.example-tsp.com/csc/v1 +NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=documenso-prod +NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=... +NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=QES +NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=http://timestamp.example.com +``` + +Register `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` (e.g. `https://sign.example.com/api/csc/oauth/callback`) as the OAuth callback URL with the TSP. + +## Default Signature Level + +`NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` selects the legal tier applied to envelopes that do not specify one explicitly. It is a default, not a capability gate: callers may still create AES or QES envelopes explicitly regardless of this setting. + +| Configured value | Caller passes nothing | Caller passes `AES` | Caller passes `QES` | +| ---------------- | --------------------- | ------------------- | ------------------- | +| `AES` (default) | Envelope is `AES` | Envelope is `AES` | Envelope is `QES` | +| `QES` | Envelope is `QES` | Envelope is `AES` | Envelope is `QES` | + +Any value other than `AES` or `QES` causes the instance to refuse to start. This prevents silent qualified-to-advanced downgrades from a typo. + +## Timestamp Authority Resolution + +AES/QES envelopes use TSA-attested timestamps in two distinct phases. Resolution differs per phase. + +### Sign time — PAdES B-T per recipient + +Each recipient's CMS embeds a signature timestamp (CMS unsigned attribute) so proven time is bound to the recipient's signature itself. Resolution order: + +1. If the TSP advertises `signatures/timestamp` in its `info` response (CSC §11.10), the TSP endpoint is used. The call is authorised with **this recipient's** service-scope bearer token — the same one authorising the `signatures/signHash` call alongside it. +2. Otherwise, the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is used (RFC 3161 over HTTP). + +Selection is made at boot from the discovered transport, not at runtime; there is no try-then-fall-through. If the chosen source fails, the recipient's sign attempt fails. + +### Seal time — PAdES B-LTA archival + +The seal-document job emits a single archival `/DocTimeStamp` over the fully-signed envelope (plus DSS for the existing signatures and the timestamp's own chain). This phase is **env-only**: the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is always used. + +The archival anchor is the operator's long-term trust anchor and SHOULD point at a dedicated qualified archival TSA (e.g. DigiCert) independent of the per-recipient TSP. We deliberately do not fall back to the TSP at seal time: archive longevity should not be coupled to a TSP that may rotate or revoke, and the seal-document job has no recipient context to carry a service-scope bearer. + +### Boot-time guard + +The instance refuses to start in CSC mode unless `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set (`CSC_PROVIDER_NO_TSA` at transport construction). The env var is required unconditionally — even when the TSP advertises its own `signatures/timestamp`, seal-time B-LTA archival uses the env TSA. Catching this at boot prevents the failure mode where an envelope signs successfully at B-T and then hangs in `WAITING_FOR_SIGNATURE_COMPLETION` when the seal job throws. + +## Switching Transports + +`NEXT_PRIVATE_SIGNING_TRANSPORT` is a one-way operational migration. Existing envelopes route per the `signatureLevel` column they were created with — the runtime branching looks at the envelope, not the env var. After a switch: + +- Envelopes already at `SES` continue to use the new transport for sealing, but the new transport's signer must produce SES-compatible signatures (only `local` and `gcloud-hsm` qualify). +- Envelopes already at `AES` / `QES` will fail at sign or seal time if the new transport is not `csc`. + +Plan migrations during a quiet window with no in-flight envelopes. + +## Behavioural Notes + +CSC mode changes a number of envelope-authoring behaviours that operators should communicate to users. + +### Mutation lock at distribution + +For AES/QES envelopes, all authoring routes refuse mutations once the envelope leaves DRAFT. This locks the PDF before any recipient begins Strong Customer Authentication, closing the PDF-swap window that would otherwise allow an owner to replace the PDF between view and sign and break the legal "what you see is what you sign" guarantee. + +In practice: edit envelope, recipients, fields, and items freely while DRAFT; once sent, no changes are accepted (including from the API). + +### Sequential signing only + +Parallel signing produces conflicting incremental updates over the same base PDF, breaking the per-recipient `/ByteRange` invariant. The signing order is forced to `SEQUENTIAL` on AES/QES envelopes — at the schema layer, at send time, and in the UI (the parallel-signing toggle is hidden). + +### Assistant role and Dictate Next Signer disabled + +Both features modify the recipient set after the envelope is sent, which is incompatible with the AES/QES mutation lock. They are hidden in the UI and rejected at the server schema layer. + +### Sidecar PDFs at download + +The signed PDF must remain byte-identical to what each recipient's TSP signature authorised — Documenso cannot decorate it after signing. Audit logs and the Certificate of Completion are generated on demand and delivered as separate PDFs: + +- `GET /sign/{token}/download` returns the signed PDF only (or a ZIP for multi-item envelopes). +- `GET /sign/{token}/download?version=bundle` returns a ZIP containing the signed PDFs, audit log PDF, and Certificate of Completion. +- The completion email attaches all three. + +## Recipient Flow + +For context when supporting end users, here is what a recipient experiences on an AES/QES envelope: + +1. Opens the email link, lands on the signing page. +2. Documenso redirects to the TSP for Strong Customer Authentication (first visit only; cached for the session lifetime). +3. Fills fields as normal. +4. Clicks Sign → redirected to the TSP for a second authentication round (issues a per-document Signature Activation Data token). +5. Returns to Documenso; the signing call completes within ~15 seconds. +6. Sees the standard completion screen. + +If the TSP returns no eligible credentials for the recipient (e.g. they have not enrolled), they see a blocking page directing them to enrol with the TSP and retry. + +## Error Codes + +CSC-specific error codes surfaced through the standard error channels: + +| Code | Meaning | Recovery | +| -------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- | +| `CSC_UNLICENSED` | License flag absent at transport-create | Operator: enable Enterprise Edition, restart | +| `CSC_PROVIDER_INFO_FAILED` | `info` discovery failed at startup | Operator: check TSP availability and `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | +| `CSC_PROVIDER_NO_TSA` | `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is unset | Operator: configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | +| `CSC_CREDENTIAL_LIST_EMPTY`| TSP returned no credentials for the user | Recipient: enrol with the TSP | +| `CSC_CERT_INVALID` | Certificate refused at credential validation | Recipient: contact the TSP | +| `CSC_ALGORITHM_REFUSED` | Signature algorithm fails policy | Operator/recipient: TSP does not meet policy (see below) | +| `CSC_SAD_EXPIRED_PRE_SIGN` | Signature Activation Data expired before signing | Recipient: retry from Sign | +| `CSC_TSP_TIMEOUT` | 15-second synchronous timeout reached | Recipient: retry (idempotent — the TSP enforces single-use SAD binding) | +| `CSC_EMBED_FAILED` | Sign-time digest diverged from prep capture | Recipient: retry from Sign | +| `CSC_BASE_DOCUMENT_MUTATED`| Document data changed between prep and sign | Operator: investigate (structural guard violation) | +| `CSC_INSTANCE_MODE_MISMATCH`| Envelope created with wrong level for transport | Caller: use a level matching the instance transport | +| `CSC_REQUEST_FAILED` | TSP HTTP transport failure — network error, non-2xx, or malformed response | Operator: check TSP availability; carries the TSP HTTP status and error in the message | + +## Algorithm Policy + +Documenso refuses TSP credentials that do not meet the following minimums, at the OAuth callback boundary and again at sign time: + +| Class | Allowed | Refused | +| ----- | ---------------------------------- | ------------------------------------------------------ | +| RSA | `key.len >= 2048` | Missing `key.len`, `key.len < 2048` | +| ECDSA | P-256, P-384, P-521 | Missing `key.curve`, P-192, P-224, other curves | +| Hash | SHA-256, SHA-384, SHA-512 | SHA-1, MD5 | +| Other | — | DSA | + +This is the union of CSC v1.0.4.0 §11.5 requirements and current cryptographic guidance. + +## Related + +- [Signature Levels](/docs/compliance/signature-levels) — AES / QES legal framework +- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) — overview of all signing transports +- [Environment Variables](/docs/self-hosting/configuration/environment) — full env reference +- [Enterprise Edition](/docs/policies/enterprise-edition) — license requirements diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx index 89a12a327..a2d5a0a7c 100644 --- a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx @@ -24,6 +24,11 @@ Self-hosted Documenso instances require a signing certificate. You can generate description="Hardware-based key protection with Google Cloud KMS." href="/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm" /> + + A self-signed certificate is sufficient for most use cases where your industry has no special signing regulations. @@ -79,6 +84,18 @@ For organisations requiring hardware-based key protection, Documenso supports Go See [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) for setup instructions. + + + +For Advanced and Qualified Electronic Signatures under eIDAS, Documenso integrates with third-party Trust Service Providers via the Cloud Signature Consortium API. Each recipient authenticates directly with the TSP, which holds the private key and issues the signature. + +- Per-recipient identity verification by an accredited TSP +- Legally equivalent to a handwritten signature within the EU (QES) +- Requires an [Enterprise Edition](/docs/policies/enterprise-edition) license +- Instance-wide setting; one CSC provider per Documenso install + +See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for setup instructions. + diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json index d37038af3..b52f83f9e 100644 --- a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json +++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json @@ -1,4 +1,4 @@ { "title": "Signing Certificate", - "pages": ["...index", "local", "google-cloud-hsm", "timestamp-server", "troubleshooting"] + "pages": ["...index", "local", "google-cloud-hsm", "csc-qes", "timestamp-server", "troubleshooting"] } diff --git a/apps/docs/content/docs/self-hosting/deployment/railway.mdx b/apps/docs/content/docs/self-hosting/deployment/railway.mdx index 93c861678..81392a37d 100644 --- a/apps/docs/content/docs/self-hosting/deployment/railway.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/railway.mdx @@ -24,7 +24,7 @@ Before deploying, you need: The fastest way to deploy Documenso on Railway is using the official template: -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) This template automatically provisions: diff --git a/apps/docs/content/docs/users/getting-started/create-account.mdx b/apps/docs/content/docs/users/getting-started/create-account.mdx index 17030e730..8047f48ea 100644 --- a/apps/docs/content/docs/users/getting-started/create-account.mdx +++ b/apps/docs/content/docs/users/getting-started/create-account.mdx @@ -39,7 +39,11 @@ Navigate to [documen.so/free](https://documen.so/free) to create a free account. Provide your name, email address, and create a password. Alternatively, sign up with Google for faster access. -{/* TODO: Add screenshot of registration form */} +Documenso registration form with name, email, and password fields diff --git a/apps/docs/content/docs/users/settings/delete-account.mdx b/apps/docs/content/docs/users/settings/delete-account.mdx index 569dc1bc2..77640db70 100644 --- a/apps/docs/content/docs/users/settings/delete-account.mdx +++ b/apps/docs/content/docs/users/settings/delete-account.mdx @@ -7,14 +7,14 @@ import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; - Account deletion is permanent and irreversible. All documents, signatures, templates, and account - data will be permanently removed. Any active subscription will be cancelled. + Account deletion is permanent and irreversible. Your account, signatures, and personal data will be + permanently removed, and any active subscription will be cancelled. How your organisations and + documents are handled is explained below. ## Before Deleting - Download any documents you need to keep -- Cancel any active subscriptions - Disable two-factor authentication (required before deletion) ## Delete Your Account @@ -36,6 +36,31 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; If you have two-factor authentication enabled, you must disable it before deleting your account. +## What Happens to Your Organisations + +When you delete your account, the organisations you **own** are permanently deleted along with all of +their teams. If an owned organisation has an active subscription, it is scheduled for cancellation at +the end of the current billing period. + +Organisations that you are only a **member** of are not deleted. You are simply removed from them, and +the organisation continues to operate as normal. + +## What Happens to Your Documents + +The way your documents and templates are handled depends on whether you owned the organisation they +belong to: + +- **Organisations you owned** — Completed and in-progress documents are retained in an anonymized form + (reassigned to an internal system account) so the other parties keep their records. Draft documents + and templates are permanently removed. +- **Organisations you were a member of** — Your documents and templates are transferred to the + organisation owner, so they remain accessible to the organisation after you leave. + + + Documents that are retained in anonymized form are no longer associated with your account and cannot + be recovered or accessed by you after deletion. Download anything you need to keep beforehand. + + --- ## See Also diff --git a/apps/docs/public/get-started-images/documenso-registration-form.webp b/apps/docs/public/get-started-images/documenso-registration-form.webp new file mode 100644 index 000000000..5b414adc5 Binary files /dev/null and b/apps/docs/public/get-started-images/documenso-registration-form.webp differ diff --git a/apps/remix/.bin/stripe-dev.sh b/apps/remix/.bin/stripe-dev.sh index 67d349ded..fc8ac7485 100755 --- a/apps/remix/.bin/stripe-dev.sh +++ b/apps/remix/.bin/stripe-dev.sh @@ -73,5 +73,12 @@ if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then echo "╚═════════════════════════════════════════════════════════════════════╝" fi +NEXT_PUBLIC_WEBAPP_URL=$(load_env_var "NEXT_PUBLIC_WEBAPP_URL") + +if [ -z "$NEXT_PUBLIC_WEBAPP_URL" ]; then + NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" + echo "[INFO]: NEXT_PUBLIC_WEBAPP_URL not set, defaulting to $NEXT_PUBLIC_WEBAPP_URL" +fi + echo "[INFO]: Starting Stripe webhook listener..." -stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook +stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to "$NEXT_PUBLIC_WEBAPP_URL/api/stripe/webhook" diff --git a/apps/remix/app/components/dialogs/admin-organisation-sync-subscription-dialog.tsx b/apps/remix/app/components/dialogs/admin-organisation-sync-subscription-dialog.tsx new file mode 100644 index 000000000..57f3c3cb0 --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-organisation-sync-subscription-dialog.tsx @@ -0,0 +1,155 @@ +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +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 } from '@documenso/ui/primitives/form/form'; +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 { useNavigate } from 'react-router'; +import { z } from 'zod'; + +export type AdminOrganisationSyncSubscriptionDialogProps = { + organisationId: string; + trigger?: React.ReactNode; +}; + +const ZAdminOrganisationSyncSubscriptionFormSchema = z.object({ + syncClaims: z.boolean(), +}); + +type TAdminOrganisationSyncSubscriptionFormSchema = z.infer; + +export const AdminOrganisationSyncSubscriptionDialog = ({ + organisationId, + trigger, +}: AdminOrganisationSyncSubscriptionDialogProps) => { + const [open, setOpen] = useState(false); + + const { t } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigate(); + + const form = useForm({ + resolver: zodResolver(ZAdminOrganisationSyncSubscriptionFormSchema), + defaultValues: { + syncClaims: false, + }, + }); + + const { mutateAsync: syncSubscription } = trpc.admin.organisation.subscription.sync.useMutation(); + + const onFormSubmit = async (values: TAdminOrganisationSyncSubscriptionFormSchema) => { + try { + await syncSubscription({ + organisationId, + syncClaims: values.syncClaims, + }); + + toast({ + title: t`Subscription synced`, + description: t`The organisation subscription has been synced with Stripe.`, + duration: 5000, + }); + + await navigate(0); + + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + + console.error(error); + + toast({ + title: t`Failed to sync subscription`, + description: error.message, + variant: 'destructive', + duration: 10000, + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Sync Stripe subscription + + + + Fetch the latest subscription data from Stripe and apply it to this organisation. + + + +
+ +
+ ( + + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx b/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx index 72c0b3326..90489bf8f 100644 --- a/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx @@ -67,7 +67,7 @@ export const AdminSwapSubscriptionDialog = ({ const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId); - const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation(); + const { mutateAsync: swapSubscription } = trpc.admin.organisation.subscription.swap.useMutation(); const onSubmit = async () => { if (!selectedOrgId) { diff --git a/apps/remix/app/components/dialogs/claim-update-dialog.tsx b/apps/remix/app/components/dialogs/claim-update-dialog.tsx index bcbd91a56..bdcdfbd55 100644 --- a/apps/remix/app/components/dialogs/claim-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/claim-update-dialog.tsx @@ -2,6 +2,7 @@ import type { TLicenseClaim } from '@documenso/lib/types/license'; import { trpc } from '@documenso/trpc/react'; import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types'; import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Dialog, DialogContent, @@ -28,6 +29,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD const { toast } = useToast(); const [open, setOpen] = useState(false); + const [backportEmailTransport, setBackportEmailTransport] = useState(false); const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({ onSuccess: () => { @@ -67,19 +69,33 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD await updateClaim({ id: claim.id, data, + backportEmailTransport, }) } licenseFlags={licenseFlags} formSubmitTrigger={ - - + <> +
+ setBackportEmailTransport(checked === true)} + /> + +
- -
+ + + + + + } /> diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx deleted file mode 100644 index 694f2d56a..000000000 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { FolderType } from '@documenso/lib/types/folder-type'; -import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { trpc } from '@documenso/trpc/react'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} 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 { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router'; -import { z } from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; - -export type DocumentMoveToFolderDialogProps = { - documentId: number; - open: boolean; - onOpenChange: (open: boolean) => void; - currentFolderId?: string; -} & Omit; - -const ZMoveDocumentFormSchema = z.object({ - folderId: z.string().nullable().optional(), -}); - -type TMoveDocumentFormSchema = z.infer; - -export const DocumentMoveToFolderDialog = ({ - documentId, - open, - onOpenChange, - currentFolderId, - ...props -}: DocumentMoveToFolderDialogProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const navigate = useNavigate(); - const team = useCurrentTeam(); - - const [searchTerm, setSearchTerm] = useState(''); - - const form = useForm({ - resolver: zodResolver(ZMoveDocumentFormSchema), - defaultValues: { - folderId: currentFolderId, - }, - }); - - const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( - { - parentId: currentFolderId, - type: FolderType.DOCUMENT, - }, - { - enabled: open, - }, - ); - - const { mutateAsync: updateDocument } = trpc.document.update.useMutation(); - - useEffect(() => { - if (!open) { - form.reset(); - setSearchTerm(''); - } else { - form.reset({ folderId: currentFolderId }); - } - }, [open, currentFolderId, form]); - - const onSubmit = async (data: TMoveDocumentFormSchema) => { - try { - await updateDocument({ - documentId, - data: { - folderId: data.folderId ?? null, - }, - }); - - const documentsPath = formatDocumentsPath(team.url); - - if (data.folderId) { - await navigate(`${documentsPath}/f/${data.folderId}`); - } else { - await navigate(documentsPath); - } - - toast({ - title: _(msg`Document moved`), - description: _(msg`The document has been moved successfully.`), - variant: 'default', - }); - - onOpenChange(false); - } catch (err) { - const error = AppError.parseError(err); - - if (error.code === AppErrorCode.NOT_FOUND) { - toast({ - title: _(msg`Error`), - description: _(msg`The folder you are trying to move the document to does not exist.`), - variant: 'destructive', - }); - - return; - } - - if (error.code === AppErrorCode.UNAUTHORIZED) { - toast({ - title: _(msg`Error`), - description: _(msg`You are not allowed to move this document.`), - variant: 'destructive', - }); - - return; - } - - toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while moving the document.`), - variant: 'destructive', - }); - } - }; - - const filteredFolders = folders?.data.filter((folder) => - folder.name.toLowerCase().includes(searchTerm.toLowerCase()), - ); - - return ( - - - - - Move Document to Folder - - - - Select a folder to move this document to. - - - -
- - setSearchTerm(e.target.value)} - className="pl-8" - /> -
- -
- - ( - - - Folder - - - -
- {isFoldersLoading ? ( -
- -
- ) : ( - <> - - - {filteredFolders?.map((folder) => ( - - ))} - - {searchTerm && filteredFolders?.length === 0 && ( -
- No folders found -
- )} - - )} -
-
- -
- )} - /> - - - - - - - - -
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx deleted file mode 100644 index d4e4a5168..000000000 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { useSession } from '@documenso/lib/client-only/providers/session'; -import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import type { TRecipientLite } from '@documenso/lib/types/recipient'; -import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Document } from '@documenso/prisma/types/document-legacy-schema'; -import { trpc as trpcReact } from '@documenso/trpc/react'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Checkbox } from '@documenso/ui/primitives/checkbox'; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu'; -import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form'; -import { useToast } from '@documenso/ui/primitives/use-toast'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { SigningStatus, type Team, type User } from '@prisma/client'; -import { History } from 'lucide-react'; -import { useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; -import * as z from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; - -import { StackAvatar } from '../general/stack-avatar'; - -const FORM_ID = 'resend-email'; - -export type DocumentResendDialogProps = { - document: Pick & { - user: Pick; - recipients: TRecipientLite[]; - team: Pick | null; - }; - recipients: TRecipientLite[]; -}; - -export const ZResendDocumentFormSchema = z.object({ - recipients: z.array(z.number()).min(1, { - message: 'You must select at least one item.', - }), -}); - -export type TResendDocumentFormSchema = z.infer; - -export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => { - const { user } = useSession(); - const team = useCurrentTeam(); - - const { toast } = useToast(); - const { _ } = useLingui(); - - const [isOpen, setIsOpen] = useState(false); - const isOwner = document.userId === user.id; - const isCurrentTeamDocument = team && document.team?.url === team.url; - - const isDisabled = - (!isOwner && !isCurrentTeamDocument) || - document.status !== 'PENDING' || - !recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED); - - const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation(); - - const form = useForm({ - resolver: zodResolver(ZResendDocumentFormSchema), - defaultValues: { - recipients: [], - }, - }); - - const { - handleSubmit, - formState: { isSubmitting }, - } = form; - - const selectedRecipients = useWatch({ - control: form.control, - name: 'recipients', - }); - - const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => { - try { - await resendDocument({ documentId: document.id, recipients }); - - toast({ - title: _(msg`Document re-sent`), - description: _(msg`Your document has been re-sent successfully.`), - duration: 5000, - }); - - setIsOpen(false); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`This document could not be re-sent at this time. Please try again.`), - variant: 'destructive', - duration: 7500, - }); - } - }; - - return ( - - - e.preventDefault()}> - - Resend - - - - - - -

- Who do you want to remind? -

-
-
- -
- - ( - <> - {recipients.map((recipient) => ( - - - - {recipient.email} - - - - - checked - ? onChange([...value, recipient.id]) - : onChange(value.filter((v) => v !== recipient.id)) - } - /> - - - ))} - - )} - /> - - - - -
- - - - - -
-
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx new file mode 100644 index 000000000..462e41921 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx @@ -0,0 +1,95 @@ +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +import { + EmailTransportForm, + type EmailTransportFormValues, + emailTransportFormToConfig, +} from '../forms/email-transport-form'; + +export type EmailTransportCreateDialogProps = { + trigger?: React.ReactNode; +}; + +export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: createTransport, isPending } = trpc.admin.emailTransport.create.useMutation({ + onSuccess: () => { + toast({ + title: t`Transport created.`, + }); + + setOpen(false); + }, + onError: (error) => { + toast({ + title: t`Failed to create transport.`, + description: error.message, + variant: 'destructive', + }); + }, + }); + + const onFormSubmit = async (values: EmailTransportFormValues) => { + await createTransport({ + name: values.name, + fromName: values.fromName, + fromAddress: values.fromAddress, + config: emailTransportFormToConfig(values), + }); + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Add Email Transport + + + Fill in the details to create a new email transport. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx new file mode 100644 index 000000000..055d920b3 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx @@ -0,0 +1,114 @@ +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type EmailTransportDeleteDialogProps = { + transportId: string; + transportName: string; + subscriptionClaimCount: number; + organisationClaimCount: number; + trigger: React.ReactNode; +}; + +export const EmailTransportDeleteDialog = ({ + transportId, + transportName, + subscriptionClaimCount, + organisationClaimCount, + trigger, +}: EmailTransportDeleteDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const isInUse = subscriptionClaimCount + organisationClaimCount > 0; + + const { mutateAsync: deleteTransport, isPending } = trpc.admin.emailTransport.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Transport deleted.`, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`Failed to delete transport.`, + variant: 'destructive', + }); + }, + }); + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Delete Email Transport + + + Are you sure you want to delete the following transport? + + + + + {transportName} + + + {isInUse && ( + + + Warning, this email transport is currently being used by: + +
    + {subscriptionClaimCount > 0 && ( +
  • + +
  • + )} + + {organisationClaimCount > 0 && ( +
  • + +
  • + )} +
+
+
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx new file mode 100644 index 000000000..1a463ff72 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx @@ -0,0 +1,126 @@ +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +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'; + +const ZSendTestEmailFormSchema = z.object({ + to: z.string().email(), +}); + +type TSendTestEmailFormSchema = z.infer; + +export type EmailTransportSendTestDialogProps = { + transportId: string; + trigger: React.ReactNode; +}; + +export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTransportSendTestDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: sendTest } = trpc.admin.emailTransport.sendTest.useMutation({ + onSuccess: () => { + toast({ + title: t`Test email sent.`, + }); + setOpen(false); + }, + onError: (error) => { + toast({ + title: t`Test failed.`, + description: error.message, + variant: 'destructive', + }); + }, + }); + + const form = useForm({ + resolver: zodResolver(ZSendTestEmailFormSchema), + defaultValues: { + to: '', + }, + }); + + const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => { + await sendTest({ id: transportId, to }); + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Send Test Email + + + Send a test email using this transport to verify the configuration. + + + +
+ +
+ ( + + + Email + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx new file mode 100644 index 000000000..5ad3ae023 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx @@ -0,0 +1,104 @@ +import { trpc } from '@documenso/trpc/react'; +import type { TFindEmailTransportsResponse } from '@documenso/trpc/server/admin-router/email-transport/find-email-transports.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +import { + EmailTransportForm, + type EmailTransportFormValues, + emailTransportFormToConfig, +} from '../forms/email-transport-form'; + +export type EmailTransportUpdateDialogProps = { + transport: TFindEmailTransportsResponse['data'][number]; + trigger: React.ReactNode; +}; + +export const EmailTransportUpdateDialog = ({ transport, trigger }: EmailTransportUpdateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: updateTransport, isPending } = trpc.admin.emailTransport.update.useMutation(); + + const onFormSubmit = async (values: EmailTransportFormValues) => { + try { + await updateTransport({ + id: transport.id, + data: { + name: values.name, + fromName: values.fromName, + fromAddress: values.fromAddress, + config: emailTransportFormToConfig(values), + }, + }); + + toast({ + title: t`Transport updated.`, + }); + + setOpen(false); + } catch { + toast({ + title: t`Failed to save transport.`, + variant: 'destructive', + }); + } + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Edit Email Transport + + + Modify the details of the email transport. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx b/apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx new file mode 100644 index 000000000..8cb90cfa0 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx @@ -0,0 +1,134 @@ +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Label } from '@documenso/ui/primitives/label'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useEffect, useState } from 'react'; + +export type EnvelopeCancelDialogProps = { + id: string; + title: string; + trigger?: React.ReactNode; + onCancel?: () => Promise | void; +}; + +export const EnvelopeCancelDialog = ({ id, title, trigger, onCancel }: EnvelopeCancelDialogProps) => { + const { toast } = useToast(); + const { t } = useLingui(); + const trpcUtils = trpcReact.useUtils(); + + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(''); + + const { mutateAsync: cancelEnvelope, isPending } = trpcReact.envelope.cancel.useMutation({ + onSuccess: async () => { + toast({ + title: t`Document cancelled`, + description: t`"${title}" has been successfully cancelled`, + duration: 5000, + }); + + await trpcUtils.document.findDocumentsInternal.invalidate(); + + await onCancel?.(); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`Something went wrong`, + description: t`This document could not be cancelled at this time. Please try again.`, + variant: 'destructive', + duration: 7500, + }); + }, + }); + + useEffect(() => { + if (open) { + setReason(''); + } + }, [open]); + + return ( + !isPending && setOpen(value)}> + {trigger} + + + + + Are you sure? + + + + + You are about to cancel "{title}" + + + + + + +

+ Once confirmed, the following will occur: +

+ +
    +
  • + The document signing process will be stopped +
  • +
  • + Recipients will be notified that the document was cancelled +
  • +
  • + The document will remain in your dashboard marked as Cancelled +
  • +
+
+
+ +
+ + +