Compare commits

...

120 Commits

Author SHA1 Message Date
Lucas Smith f2525ae95b feat: add API endpoint to reject documents on behalf of recipients (#3007)
Programmatically record an external rejection on behalf of a recipient
who declined outside the platform. Flags the rejection as external in
the audit log, optionally attributes it to a specific team member via
actAsEmail, and enforces team membership and document visibility.
2026-06-22 21:59:07 +10:00
David Nguyen 2f24a8eab2 fix: set send status on resend (#3011) 2026-06-22 17:00:24 +10:00
David Nguyen d9b7722325 fix: correctly use default distribute envelope tab (#3010) 2026-06-22 16:27:50 +10:00
github-actions[bot] 783123f72b chore: extract translations (#2987) 2026-06-22 16:06:57 +10:00
Lucas Smith e8ed1c3d99 fix: respect branding enabled for recipient routes (#3009) 2026-06-22 16:06:06 +10:00
David Nguyen c23d739f76 feat: allow additional envelope duplicate settings (#3008) 2026-06-22 14:41:38 +10:00
Lucas Smith 0bf58ca66e feat: add custom brand colours to emails (#3005) 2026-06-22 14:33:34 +10:00
David Nguyen dee3259088 fix: remove old dialogs (#3006) 2026-06-22 14:17:22 +10:00
Nandini Dhanrale 6ad1a2dfaf fix: signing request email renders blank when organisation/team branding is enabled (#2968) 2026-06-22 14:15:12 +10:00
Abdelrahman Abdelhamed 306e7fe5ed fix: render unicode characters in typed signatures (#2728) 2026-06-22 13:40:56 +10:00
Yash Singh 219db32fdf fix: only send S3 checksums when required to support S3-compatible storage (#2984) 2026-06-22 13:35:37 +10:00
David Nguyen 948d1bbf12 fix: improve team member removal ux (#3001) 2026-06-22 12:16:55 +10:00
Lucas Smith 40d20ad068 v2.13.0 2026-06-18 16:03:26 +10:00
Lucas Smith a99bdf5e20 fix: include envelopeId in webhook payload (#2998) 2026-06-18 14:32:15 +10:00
Lucas Smith 4f346d3c2d feat: add cancellable document status (#2992)
Adds a CANCELLED envelope status that privileged members (owner or team
admin/manager) can move a pending document into. Sending recipient
notifications via a background job while retaining the document in the
dashboard as proof of distribution.

Includes a dedicated Cancelled tab, single and bulk cancel actions,
the ENVELOPE_CANCELLED mutability guard, and e2e coverage for
permissions
and visibility.
2026-06-18 13:52:35 +10:00
Lucas Smith d5ce222482 feat: add CSC AES/QES signing (v1 instance-wide config) (#2874)
Adds Cloud Signature Consortium (CSC) integration for AES/QES signing
against a configured TSP. v1 ships as instance-wide configuration via
environment variables, with per-envelope signature level selection,
license gating, and an OAuth-driven signing flow (capture + FIFO
signers, SAD session, blocking/in-progress recipient pages).

Includes signature level compatibility checks (role, signing order,
dictate next signer), envelope mutability assertions, Prisma migration
for signature level and CSC tables, and docs for the new signing
certificate options.
2026-06-16 23:37:34 +10:00
Lucas Smith 9b59f1a273 v2.12.0 2026-06-16 11:00:17 +10:00
David Nguyen 15549a6758 fix: add early quota warning (#2986)
## Description

- Add banners when plans near fair use limits
- Automated email alerts to our support when nearing fair use limits
2026-06-15 16:02:18 +10:00
Lucas Smith eb45d1e5a9 fix: reconcile billing when stripe subscription is missing (#2988)
Catch resource_missing in the subscription route, return null so the
billing page still loads, and fire a sync to converge the stale row.
2026-06-15 15:58:40 +10:00
Lucas Smith 0aa84cecc8 chore: add translations (#2975) 2026-06-15 14:29:42 +10:00
Lucas Smith 702e747375 fix: use correct property for embed edit editor config (#2985) 2026-06-15 14:29:13 +10:00
Lucas Smith 3887aa67c8 fix: rework stripe webhooks into idempotent subscription sync (#2977)
Replace per-event webhook handlers with a single sync function that
fetches the current state from Stripe and converges the local
subscription, claim, and organisation type.

- Create organisations upfront before checkout, restricted as
  "pending payment" until the first payment syncs
- Add rate-limited subscription sync route, triggered on checkout
  success so the UI doesn't wait on webhooks
- Surface pending payment state in banner, billing table, and limits
2026-06-12 16:01:03 +10:00
Konrad b84b87cea6 fix(i18n): add space between tag and text (#2972) 2026-06-11 10:31:19 +10:00
David Nguyen ac0a0086d6 chore: update verify email docs (#2970) 2026-06-10 13:09:53 +10:00
David Nguyen 8c11266747 fix: correctly orphan envelopes and stripe cancel on delete (#2967) 2026-06-09 15:52:14 +10:00
Ephraim Duncan 3c0345f755 fix: link signing brand logos (#2881) 2026-06-09 15:51:25 +10:00
Lucas Smith 3e47f1913c chore: add translations (#2929) 2026-06-09 15:48:50 +10:00
David Nguyen 9ccf50ed95 fix: correctly strip background job values (#2965) 2026-06-09 15:33:41 +10:00
Catalin Pit ecc98fbd41 feat: enhance document signing page with field canvas style integration (#2876) 2026-06-09 15:05:22 +10:00
Romone6 58f0f5da43 fix: load typed signature font on profile page (#2963) 2026-06-09 14:31:32 +10:00
David Nguyen d5c6cf4ad5 feat: allow changing field types (#2873) 2026-06-09 13:48:40 +10:00
github-actions[bot] 90462bf414 chore: extract translations (#2923) 2026-06-09 13:37:34 +10:00
David Nguyen 791a54bb8b chore: update docs trusted emails (#2964) 2026-06-09 13:33:54 +10:00
Rana Pratap Sarangi 5f4e0ccf6b fix: exclude rejected documents from inbox count (#2893) 2026-06-09 10:49:18 +10:00
Clayton Chew 583e35c768 fix: ensures new expire on setSessionCookie (#2708) 2026-06-09 10:47:19 +10:00
Ephraim Duncan 1e129580b8 fix: point Railway deploy button at the working template URL (#2962) 2026-06-09 10:05:45 +10:00
Arun Kumar 184cbd6770 fix: guard missing password reset token and fix broken reset link URL (#2928) 2026-06-08 14:31:26 +10:00
roshboi b53295a9d5 docs: clarify email verification instructions for free and paid plans (#2959) 2026-06-08 14:22:44 +10:00
David Nguyen 8448e333cf fix: update new quota and rates UX (#2954) 2026-06-08 14:14:22 +10:00
Ephraim Duncan 03b5fe6117 fix: update link-only notification settings (#2821) 2026-06-08 12:59:50 +10:00
Arun Kumar f60698a353 docs: add registration form screenshot to create account page (#2908) 2026-06-08 12:45:56 +10:00
Konrad 7c48ae6ff4 chore(i18n): fix typo in Polish translation (#2948) 2026-06-08 12:40:07 +10:00
David Nguyen 4ee789ea37 fix: add multi email transport system (#2942) 2026-06-05 21:19:20 +10:00
David Nguyen ebf5b75a19 fix: add email disable flag (#2931) 2026-06-05 17:55:10 +10:00
David Nguyen 0ecde7ac1e feat: add header metadata to emails (#2927) 2026-06-04 16:48:57 +10:00
Catalin Pit c41e387220 docs(embedding): document iframe URL fragment options (#2915) 2026-06-04 09:12:44 +03:00
David Nguyen 7f796ed74e fix: update org stats table ui (#2924) 2026-06-03 16:48:20 +10:00
Lucas Smith 0a21598fec chore: add translations (#2922) 2026-06-03 16:20:16 +10:00
David Nguyen 240bef1a66 fix: add org insight document complete stat (#2920) 2026-06-03 16:15:59 +10:00
github-actions[bot] 9583e79056 chore: extract translations (#2919) 2026-06-03 16:11:30 +10:00
David Nguyen 993a494784 fix: add email reporting (#2918) 2026-06-03 16:05:39 +10:00
Konrad 743d31651f fix(i18n): mark Bio string for translation (#2910) 2026-06-03 16:00:37 +10:00
Konrad ce96238464 fix(ui): direct signing templates window layout (#2909) 2026-06-03 16:00:24 +10:00
github-actions[bot] 8b8e7e9f2e chore: extract translations (#2867) 2026-06-03 15:54:52 +10:00
roshboi 50006ca053 fix: prevent sending emails for free organisation claims (#2917) 2026-06-03 14:28:00 +10:00
David Nguyen c3135a3ce7 fix: filter ccers from delete email (#2914) 2026-06-02 15:27:36 +10:00
David Nguyen d2f60b13fd fix: correctly log cc emails (#2913) 2026-06-02 15:04:02 +10:00
David Nguyen c50a01d004 fix: improve field signing (#2830) 2026-06-01 19:48:20 +10:00
David Nguyen 4bda501d51 feat: add stripe sync (#2877) 2026-06-01 18:17:16 +10:00
Lucas Smith a7713f7228 chore: add translations (#2885) 2026-06-01 17:27:46 +10:00
David Nguyen 536142be03 feat: add admin org stats (#2904) 2026-06-01 17:26:51 +10:00
David Nguyen 44c4826e92 fix: track monthly usage for unlimited quotas (#2894) 2026-05-31 13:34:10 +10:00
David Nguyen 61138cdd81 fix: add dynamic rate limits (#2892) 2026-05-31 00:34:28 +10:00
Lucas Smith 22ceff43e3 feat: admin-configurable email blocklist (#2884) 2026-05-29 01:12:55 +10:00
Lucas Smith a84da2f2c7 chore: disabled account enforcement (#2882) 2026-05-28 22:19:13 +10:00
Lucas Smith 7e8da85bd8 feat: block disposable email signups (#2883)
Reject disposable / throwaway email providers (mailinator, yopmail,
10minutemail, ...) across all signup paths: email/password, Google,
Microsoft, personal OIDC and organisation OIDC. Backed by the
mailchecker package (offline, ~55k domains, subdomain-aware).

Exposes a SIGNUP_DISPOSABLE_EMAIL error code so the signup form and
SSO redirect alert can show a dedicated message instead of the
generic 'signup disabled' one.
2026-05-28 21:15:27 +09:00
David Nguyen d304d8720c fix: add temp email rate limit (#2879) 2026-05-28 17:09:09 +10:00
Kendry Grullon 9da2db2e67 feat(storage): add native Azure Blob transport (#2871) 2026-05-27 11:58:39 +07:00
Nikhil Shukla 993df7dc21 fix(docs): correct broken internal docs links (#2869) 2026-05-27 12:39:48 +10:00
ザヘド 807d094cf2 fix: email dictated direct template signer (#2810) 2026-05-27 12:30:31 +10:00
Lucas Smith 3cef238f46 chore: add translations (#2854) 2026-05-26 15:40:11 +10:00
David Nguyen 886c40a46b fix: add constraint on name schema (#2866) 2026-05-26 15:39:35 +10:00
David Nguyen b1b82b775c fix: add missing doc page ref (#2865) 2026-05-26 15:18:20 +10:00
Anish Patil 0fe697c26c fix: handle duplicate organization URL update errors gracefully (#2808) 2026-05-26 14:56:23 +10:00
redouanegrib 7c0031679a docs: implement global error handling and troubleshooting matrix (#2784) 2026-05-26 14:55:40 +10:00
Durgesh Shekhawat 6bb0496224 fix: prevent division by zero in progress bar when requiredRecipientFields is empty (#2855) 2026-05-26 14:41:12 +10:00
Ephraim Duncan eedf483957 fix(prisma): stop large-team-seed running on import (#2852) 2026-05-26 14:13:12 +10:00
Abdulazez (Abza) 5421b0d1cc fix: prevent prop array mutation by spreading allRecipients before sort (#2840) 2026-05-26 14:09:54 +10:00
Abdulazez (Abza) fa2c53bd72 fix: prevent React state mutation by spreading envelope.recipients before sort (#2839) 2026-05-26 14:04:48 +10:00
Lucas Smith 6ac67e646c fix: always show captcha (#2860) 2026-05-25 19:56:24 +07:00
github-actions[bot] 6a20fefd7b chore: extract translations (#2806) 2026-05-22 14:41:35 +10:00
Abdulazez (Abza) 43fe558459 fix: prevent crash when removing last dropdown option in removeValue (#2843) 2026-05-22 14:40:40 +10:00
Abdulazez (Abza) 0a6b0452dc fix: handleInitialsFieldClick now returns initialsToInsert instead of initials (#2838) 2026-05-22 14:31:46 +10:00
David Nguyen fec5d55250 fix: move document complete email to a job (#2835) 2026-05-22 14:21:26 +10:00
Abdulazez (Abza) f1b235819e fix: remove duplicate loadingSpinnerGroup.destroy() in DROPDOWN sign (#2841) 2026-05-22 14:19:57 +10:00
Abdulazez (Abza) d0f9f68689 fix: correct reversed comparison in admin organisations table pagination (#2842) 2026-05-22 14:18:42 +10:00
roshboi f93a98e9a5 chore: updated certification status (#2850)
## Description
Updated HIPAA status to compliant
2026-05-21 15:49:41 +10:00
roshboi c0ea4c60e4 fix(docs): correct API example URLs from /documents to /document (#2836)
## Description

Corrected API endpoint path from /api/v2/documents to /api/v2/document

The current example in the docs(/api/v2/documents) returns a 404
NOT_FOUND object.
2026-05-20 18:17:14 +10:00
Ephraim Duncan 2cb4cc29ea feat: allow admins to create users (#2082) 2026-05-19 20:37:03 +10:00
Lucas Smith d9b5f01e21 chore: add translations (#2833) 2026-05-19 16:19:44 +10:00
Lucas Smith bc3acba72c fix: use captcha imperatively (#2832) 2026-05-19 14:38:40 +10:00
Ephraim Duncan 247a0158bd refactor(ui): replace hardcoded colors with semantic tokens (#2749) 2026-05-19 14:19:31 +10:00
Lucas Smith 9e0b567686 chore: deps upgrade (#2831) 2026-05-18 22:25:48 +10:00
David Nguyen 8f6be474a9 fix: improve api logging (#2820) 2026-05-15 13:41:35 +10:00
Ephraim Duncan 8f5bdef384 docs: require English for PRs and issues (#2819) 2026-05-15 12:30:13 +10:00
David Nguyen 999942014e chore: update docs for self hosters (#2816) 2026-05-14 15:07:10 +10:00
Tarana 194b2134cc docs: remove leftover Next.js commands and update to Remix-compatible syntax (#2695) 2026-05-14 12:06:59 +10:00
Ephraim Duncan b8df02750b fix: convert DOCX template uploads to PDF (#2807) 2026-05-14 11:59:27 +10:00
Lucas Smith 191170923a v2.11.0 2026-05-13 22:21:57 +10:00
Lucas Smith 4078c6b46d chore: add translations (#2805) 2026-05-13 17:06:56 +10:00
github-actions[bot] abbca79b48 chore: extract translations (#2804) 2026-05-13 16:34:21 +10:00
Gaurav goswami d6dd2b3292 perf: compress signing-celebration.png from 20MB to 4MB (#2781) 2026-05-13 15:46:32 +10:00
David Nguyen cfaad6efc9 feat: add admin org deletion (#2795) 2026-05-13 15:28:27 +10:00
github-actions[bot] 9a45b3564f chore: extract translations (#2796) 2026-05-13 15:20:04 +10:00
David Nguyen 8b171c9a30 chore: update docs to use editor instead of authoring (#2800)
## Description

Update docs to use the term "Editor" instead of "Authoring" to reduce
confusion.
2026-05-13 15:17:55 +10:00
Lucas Smith a8efb6f495 fix: remove translation tag from css textarea placeholder (#2803) 2026-05-13 15:17:34 +10:00
Lucas Smith bc184d445f feat: support DOCX uploads via Gotenberg (#2801)
Uploaded .docx files are converted to PDF on the server using a
Gotenberg
sidecar before entering the normal envelope pipeline. The feature is
opt-in via NEXT_PRIVATE_DOCUMENT_CONVERSION_URL; when unset, only PDF
uploads are accepted.

A per-process circuit breaker opens for 30s after a conversion failure
to shed load.

Ships a dev Dockerfile that layers Microsoft Core Fonts and additional
language fonts
onto the upstream Gotenberg image for better fidelity.

Co-authored-by: Ephraim Duncan
<55143799+ephraimduncan@users.noreply.github.com>

Co-authored-by: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com>
2026-05-13 15:06:21 +10:00
David Nguyen 8dfd548c08 chore: remove github action caches (#2802) 2026-05-13 15:06:06 +10:00
Anish Patil 73a7335c89 refactor: remove unnecessary DateRange type assertion (#2790) 2026-05-13 13:11:13 +10:00
Timur Ercan be3e45427f chore: update fair use policy (#2798)
## Description

refined fair use policy with examples and guidelines
2026-05-12 14:58:39 +02:00
Abdelhamid Henni 57eb40d6aa chore: update French translations (#2717) 2026-05-12 15:29:52 +10:00
David Nguyen 684fab1909 chore: add section on personal organisations for SSO users (#2793) 2026-05-12 15:20:05 +10:00
Lucas Smith d794ceb8da chore: add translations (#2788) 2026-05-12 14:28:10 +10:00
github-actions[bot] 87315adb0f chore: extract translations (#2786) 2026-05-11 17:27:06 +10:00
Ephraim Duncan 0a7794be61 feat: protect signing URLs from indexing, caching, and embedding (#2469) 2026-05-11 17:24:58 +10:00
Ephraim Duncan f15d6f0150 perf: dynamically import posthog (#2622) 2026-05-11 15:58:15 +10:00
Lucas Smith 0b86ece1d5 feat: add custom branding for signing pages (#2785)
Platform-plan organisations and teams can now customise non-embed
signing pages with six brand colour tokens, a border-radius, and
a free-text custom CSS block (up to 256 KB).

- Stored on OrganisationGlobalSettings / TeamGlobalSettings;
  teams inherit from the org via brandingEnabled === null.
- CSS is sanitised on save (PostCSS) so we can inline it at SSR
  with no per-render parsing.
- Rendered via a nonce'd <style> scoped under .documenso-branded,
  using native CSS nesting so user selectors don't need scoping.
- Gated on the existing embedSigningWhiteLabel claim (or
  self-hosted) — reuses the embed white-label decision.
2026-05-11 13:03:02 +10:00
Ephraim Duncan a197bf113f feat: add granular signup disable flags (#2765) 2026-05-09 01:16:13 +00:00
Lucas Smith ec8728b33e chore: add translations (#2774) 2026-05-08 16:22:32 +10:00
github-actions[bot] 22122f51da chore: extract translations (#2772) 2026-05-08 16:22:08 +10:00
616 changed files with 44462 additions and 7416 deletions
@@ -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.
@@ -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 ? <Img/> : <fallback/>`
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 `<img>`. `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 `<img>`; the
Documenso fallback keeps its `<Link to="/">`.
- `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 `<a target="_blank">`, 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.
+32 -2
View File
@@ -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.
@@ -160,8 +168,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 +219,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 -19
View File
@@ -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 -12
View File
@@ -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
-18
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
name: 'PR Labeler'
on:
- pull_request_target
- pull_request
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
-1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
name: 'Validate PR Name'
on:
pull_request_target:
pull_request:
types:
- opened
- reopened
+1 -1
View File
@@ -29,6 +29,6 @@
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "vscode.json-language-features"
}
}
+4
View File
@@ -9,6 +9,10 @@ If you plan to contribute to Documenso, please take a moment to feel awesome ✨
- Consider the results from the discussion on the issue
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
## English only PRs and Issues
Please write all issues, pull requests, and related comments in English so maintainers and the wider contributor community can follow the discussion.
## Taking issues
Before taking an issue, ensure that:
+15 -145
View File
@@ -11,6 +11,8 @@
·
<a href="https://documenso.com">Website</a>
·
<a href="https://docs.documenso.com">Documentation</a>
·
<a href="https://github.com/documenso/documenso/issues">Issues</a>
·
<a href="https://documen.so/live">Upcoming Releases</a>
@@ -146,45 +148,7 @@ npm run d
### Manual Setup
Follow these steps to setup Documenso on your local machine:
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
After forking the repository, clone it to your local device by using the following command:
```sh
git clone https://github.com/<your-username>/documenso
```
2. Run `npm i` in the root directory
3. Create your `.env` from the `.env.example`. You can use `cp .env.example .env` to get started with our handpicked defaults.
4. Set the following environment variables:
- NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL
- NEXT_PRIVATE_DATABASE_URL
- NEXT_PRIVATE_DIRECT_DATABASE_URL
- NEXT_PRIVATE_SMTP_FROM_NAME
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
5. Create the database schema by running `npm run prisma:migrate-dev`
6. Run `npm run translate:compile` in the root directory to compile lingui
7. Run `npm run dev` in the root directory to start
8. Register a new user at http://localhost:3000/signup
---
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
- Optional: Create your own signing certificate.
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
- Optional: Configure job provider for document reminders.
- The default local job provider does not support scheduled jobs required for document reminders.
- To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file.
Follow the [manual setup guide](https://docs.documenso.com/docs/developers/local-development/manual) to configure Documenso on your local machine.
### Run in Gitpod
@@ -204,138 +168,44 @@ If you're a visual learner and prefer to watch a video walkthrough of setting up
## Docker
We provide a Docker container for Documenso, which is published on both DockerHub and GitHub Container Registry.
We provide official Docker images on [DockerHub](https://hub.docker.com/r/documenso/documenso) and [GitHub Container Registry](https://ghcr.io/documenso/documenso).
- DockerHub: [https://hub.docker.com/r/documenso/documenso](https://hub.docker.com/r/documenso/documenso)
- GitHub Container Registry: [https://ghcr.io/documenso/documenso](https://ghcr.io/documenso/documenso)
You can pull the Docker image from either of these registries and run it with your preferred container hosting provider.
Please note that you will need to provide environment variables for connecting to the database, mailserver, and so forth.
For detailed instructions on how to configure and run the Docker container, please refer to the [Docker README](./docker/README.md) in the `docker` directory.
For setup instructions, see the [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) and [Docker Compose](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) guides.
## Self Hosting
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
We support a variety of deployment methods including Docker, Docker Compose, Railway, Kubernetes, and manual deployment.
### Fetch, configure, and build
For full instructions, requirements, and configuration details, see the [Self Hosting documentation](https://docs.documenso.com/docs/self-hosting).
First, clone the code from Github:
### One-Click Deploys
```
git clone https://github.com/documenso/documenso.git
```
#### Railway
Then, inside the `documenso` folder, copy the example env file:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
```
cp .env.example .env
```
The following environment variables must be set:
- `NEXTAUTH_SECRET`
- `NEXT_PUBLIC_WEBAPP_URL`
- `NEXT_PRIVATE_DATABASE_URL`
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
- `NEXT_PRIVATE_SMTP_FROM_NAME`
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
Now you can install the dependencies and build it:
```
npm i
npm run build
npm run prisma:migrate-deploy
```
Finally, you can start it with:
```
cd apps/remix
npm run start
```
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
### Run as a service
You can use a systemd service file to run the app. Here is a simple example of the service running on port 3500 (using 3000 by default):
```bash
[Unit]
Description=documenso
After=network.target
[Service]
Environment=PATH=/path/to/your/node/binaries
Type=simple
User=www-data
WorkingDirectory=/var/www/documenso/apps/remix
ExecStart=/usr/bin/next start -p 3500
TimeoutSec=15
Restart=always
[Install]
WantedBy=multi-user.target
```
### Railway
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
### Render
#### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
### Koyeb
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
## Elestio
#### Elestio
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso)
## Troubleshooting
For troubleshooting self-hosted deployments, see the [Troubleshooting guide](https://docs.documenso.com/docs/self-hosting/maintenance/troubleshooting) and [Tips & Common Pitfalls](https://docs.documenso.com/docs/self-hosting/getting-started/tips).
### I'm not receiving any emails when using the developer quickstart.
When using the developer quickstart, an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing emails locally for you to view.
The Web UI can be found at http://localhost:9000, while the SMTP port will be on localhost:2500.
### Support IPv6
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command
For local docker run
```bash
docker run -it documenso:latest npm run start -- -H ::
```
For k8s or docker-compose
```yaml
containers:
- name: documenso
image: documenso:latest
imagePullPolicy: IfNotPresent
command:
- npm
args:
- run
- start
- --
- -H
- '::'
```
### I can't see environment variables in my package scripts.
Wrap your package script with the `with:env` script like such:
+1 -1
View File
@@ -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
@@ -12,7 +12,7 @@ import { Callout } from 'fumadocs-ui/components/callout';
| 21 CFR Part 11 | Compliant (Enterprise) |
| SOC 2 | Compliant |
| ISO 27001 | Planned |
| HIPAA | Planned |
| HIPAA | Compliant (Enterprise) |
## 21 CFR Part 11
@@ -97,12 +97,12 @@ Documenso implements digital signatures with the following characteristics:
- **Timestamps**: RFC 3161 timestamps can be applied to signatures
- **Signature visualization**: Signed documents include visual signature representations
For specific implementation details and configuration options, refer to the [signing certificates](/signing-certificates/overview) documentation.
For specific implementation details and configuration options, refer to the [signing certificates](/docs/concepts/signing-certificates) documentation.
Self-hosted deployments can configure their own signing certificates and timestamp authorities to meet specific compliance requirements.
## Related
- [Legal Validity](/compliance/legal-validity) - Legal frameworks for electronic signatures
- [Signing Certificates Overview](/signing-certificates/overview) - Certificate configuration
- [Audit Log](/features/audit-log) - Document activity tracking
- [E-Sign Compliance](/docs/compliance/esign) - Legal frameworks for electronic signatures
- [Signing Certificates](/docs/concepts/signing-certificates) - Certificate configuration
- [Signing Workflow](/docs/concepts/signing-workflow) - Document activity and audit trail
@@ -167,5 +167,5 @@ To enable sequential signing:
## Related
- [Add Recipients](/users/documents/add-recipients) - How to add recipients to a document
- [Field Types](/concepts/field-types) - Learn about the different field types you can assign to recipients
- [Add Recipients](/docs/users/documents/add-recipients) - How to add recipients to a document
- [Field Types](/docs/concepts/field-types) - Learn about the different field types you can assign to recipients
@@ -0,0 +1,45 @@
---
title: Common Errors
description: A comprehensive troubleshooting matrix for Documenso API and Webhook integration errors.
---
This guide provides a comprehensive troubleshooting matrix for the standard error codes returned by the Documenso API. Use this reference to diagnose and resolve integration issues related to envelopes, recipients, and webhooks.
## Application Error Codes
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `ALREADY_EXISTS` | The resource you are attempting to create already exists. | Verify if the entity (e.g., user, envelope, webhook) has already been instantiated. Use a `PUT` or `PATCH` request to update the existing resource instead of `POST`. |
| `EXPIRED_CODE` | The provided access code or token has expired. | Generate a new access code or request a new invitation link before retrying the request. |
| `INVALID_BODY` | The request payload is malformed. | Inspect your JSON payload structure. Ensure it strictly adheres to the expected schema and that no required fields are missing. |
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
| `INVALID_CAPTCHA` | Security token (Captcha) validation failed. | Ensure the Captcha token is correctly generated on the client side and transmitted without alteration in your request. |
| `UNAUTHORIZED` | Missing or invalid authentication (401). | Verify that your API key is correct, active, and properly formatted in the `Authorization` header (e.g., `Bearer <YOUR_API_KEY>`). |
| `FORBIDDEN` | Access to the resource is denied (403). | Ensure your API key or user account has the necessary permissions and roles to execute this specific action. |
| `UNKNOWN_ERROR` | An unexpected internal server error occurred (500). | Retry the request later. If the issue persists, contact technical support with your request payload and the timestamp of the incident. |
| `RETRY_EXCEPTION` | The operation failed temporarily but can be retried. | Implement an automatic retry logic in your integration, ideally using an exponential backoff strategy. |
| `SCHEMA_FAILED` | Strict data schema validation failed. | Verify that the data types sent (string, number, boolean) exactly match the OpenAPI specification. |
| `TOO_MANY_REQUESTS` | Rate limit exceeded (429). | Reduce the frequency of your API calls. Implement rate-limiting handling based on the response headers. |
| `TWO_FACTOR_AUTH_FAILED` | Two-factor authentication (2FA) failed. | Verify the provided 2FA code. Ensure it was entered correctly and has not expired. |
| `WEBHOOK_INVALID_REQUEST` | The webhook-related request is invalid. | Check your receiving endpoint configuration. Ensure the URL is correct and that your server accepts `POST` requests from Documenso. |
## Envelope State Errors
The following errors occur when attempting to perform actions on an envelope that are incompatible with its current state.
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
## See Also
- [Documents API](/docs/developers/api/documents)
- [Webhooks](/docs/developers/webhooks)
@@ -1,4 +1,14 @@
{
"title": "API Reference",
"pages": ["documents", "recipients", "fields", "templates", "teams", "rate-limits", "versioning", "developer-mode"]
"pages": [
"documents",
"recipients",
"fields",
"templates",
"teams",
"rate-limits",
"versioning",
"developer-mode",
"common-errors"
]
}
@@ -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,4 +1,4 @@
{
"title": "Authoring",
"title": "Editor",
"pages": ["v1", "v2"]
}
@@ -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
@@ -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
@@ -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';
<Callout type="warn" title="iframes are not recommended">
Embedding via iframe is not recommended. We strongly recommend using the [official SDKs](/docs/developers/embedding/sdks) instead.
</Callout>
### Basic iframe Embedding
```html
<iframe
src="https://app.documenso.com/embed/sign/abc123xyz"
width="100%"
height="800"
frameborder="0"
allow="clipboard-write"
></iframe>
```
<Callout title="Use the correct embed URL">
The URL you embed depends on the embed mode youre using (for example direct links vs sign-token embeds). Use the
embed URL provided by Documenso for your flow.
</Callout>
### 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).
<Callout type="info" title="Why use the URL fragment?">
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.
</Callout>
@@ -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", "iframe"]
}
@@ -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
@@ -73,14 +73,14 @@ Include the token in the `Authorization` header of your HTTP requests.
### cURL
```bash
curl https://app.documenso.com/api/v2/documents \
curl https://app.documenso.com/api/v2/document \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
```
### JavaScript / TypeScript
```typescript
const response = await fetch('https://app.documenso.com/api/v2/documents', {
const response = await fetch('https://app.documenso.com/api/v2/document', {
method: 'GET',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
@@ -83,6 +83,15 @@ npm run prisma:seed -w @documenso/prisma
</Step>
<Step>
### Optional: configure job provider
The default local job provider does not support scheduled jobs required for document reminders.
See the [Background Jobs](/docs/self-hosting/configuration/background-jobs) page for more information.
</Step>
<Step>
### Start the application
@@ -105,6 +114,20 @@ Access the Documenso application by visiting `http://localhost:3000` in your web
certificate](/docs/developers/local-development/signing-certificate)**.
</Callout>
## Running Scripts with Environment Variables
If a package script does not automatically load your `.env` and `.env.local` files, wrap it with the `with:env` script:
```bash
npm run with:env -- npm run myscript
```
The same works for `npx` when running bin scripts:
```bash
npm run with:env -- npx myscript
```
## See Also
- [Developer Quickstart](/docs/developers/local-development/quickstart) - Quick Docker-based setup
@@ -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
+11 -8
View File
@@ -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 plans intended purpose.
- Experiment and automate freely within the plan features.
- If volume grows beyond whats sustainable on your plan, well reach out to discuss an upgrade.
- Assume that extreme usage will lead to us contacting you. You can scale up—or scale back. Its 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.
- Dont expect the platform plan to cover enterprise-scale volume or support. If you reach that point, well reach out to guide you to the right fit.
- Dont overthink this if youre building something valuable, we want to see you succeed. If we need to talk, we will.
## Rate Limits
@@ -8,6 +8,7 @@
"privacy",
"terms",
"security",
"verify-email",
"support"
]
}
@@ -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`
<Callout type="warn">
A misspelling such as `documenso-email.com`, `documensoemaiI.com` (capital i instead of l), or any other variation is not a Documenso domain.
</Callout>
## 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
@@ -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"]
}
@@ -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` |
@@ -224,28 +249,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 +304,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 +384,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 +426,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"
```
@@ -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.
<Callout type="warn">
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).
</Callout>
<Callout type="warn">
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The
instance refuses to start in `csc` mode without it.
</Callout>
## Prerequisites
{/* prettier-ignore */}
<Steps>
<Step>
### 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.
</Step>
<Step>
### 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.
</Step>
<Step>
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
### 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.
</Step>
</Steps>
## 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). | |
<Callout type="info">
`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).
</Callout>
## 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
@@ -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"
/>
<Card
title="CSC (AES / QES)"
description="Route signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures."
href="/docs/self-hosting/configuration/signing-certificate/csc-qes"
/>
<Card
title="Timestamp Server"
description="Add trusted timestamps and customise signature appearance."
@@ -38,7 +43,7 @@ Self-hosted Documenso instances require a signing certificate. You can generate
## Certificate Options
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM']}>
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM', 'CSC (AES / QES)']}>
<Tab value="Self-Signed">
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.
</Tab>
<Tab value="CSC (AES / QES)">
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.
</Tab>
</Tabs>
@@ -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"]
}
@@ -1,6 +1,6 @@
---
title: Storage Configuration
description: Configure file storage for uploaded documents and signed PDFs using database storage (default) or S3-compatible object storage.
description: Configure file storage for uploaded documents and signed PDFs using database storage (default), S3-compatible object storage, or Azure Blob Storage.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
@@ -10,10 +10,11 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Storage Options
| Backend | Best For | Scalability | Configuration |
| ---------- | -------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None required |
| `s3` | Production, large files, backups | High | Required |
| Backend | Best For | Scalability | Configuration |
| ------------ | --------------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None required |
| `s3` | Production, large files, backups | High | Required |
| `azure-blob` | Production on Azure, native Blob access | High | Required |
Select the storage backend with the `NEXT_PUBLIC_UPLOAD_TRANSPORT` environment variable:
@@ -23,6 +24,9 @@ NEXT_PUBLIC_UPLOAD_TRANSPORT=database
# S3-compatible storage
NEXT_PUBLIC_UPLOAD_TRANSPORT=s3
# Azure Blob Storage (native)
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
```
---
@@ -283,6 +287,111 @@ NEXT_PRIVATE_UPLOAD_REGION=us-east-1
---
## Azure Blob Storage
Azure Blob Storage is supported as a native transport (not S3-compatible). Documenso uses the official `@azure/storage-blob` SDK and signs SAS URLs with the Storage Account key for browser uploads and downloads.
### Required Variables
| Variable | Description |
| --------------------------------------- | ------------------------------------------------- |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Set to `azure-blob` |
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME` | Azure Storage Account name |
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY` | Azure Storage Account access key |
| `NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER` | Container name where uploads are stored |
### Optional Variables
| Variable | Description | Default |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT` | Custom Blob endpoint URL. Useful for local development against Azurite (for example `http://127.0.0.1:10000`). | `https://<account>.blob.core.windows.net` |
### Azure Setup
{/* prettier-ignore */}
<Steps>
<Step>
### Create a Storage Account and Container
Create a Storage Account in the Azure Portal or via the Azure CLI, then create a container inside it:
```bash
az storage account create \
--name yourstorageaccount \
--resource-group your-rg \
--location eastus \
--sku Standard_LRS
az storage container create \
--name documenso-documents \
--account-name yourstorageaccount
```
</Step>
<Step>
### Configure CORS on the container
The browser uploads documents directly to Azure Blob using a SAS URL, and downloads them the same way, so the Storage Account needs CORS rules that allow your application origin:
```bash
az storage cors add \
--services b \
--methods GET PUT \
--origins https://your-documenso-domain.com \
--allowed-headers "Content-Type" "x-ms-blob-type" "Authorization" \
--exposed-headers "*" \
--max-age 3600 \
--account-name yourstorageaccount
```
</Step>
<Step>
### Configure Environment Variables
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=yourstorageaccount
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=your-account-key
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
```
</Step>
</Steps>
### Local Development with Azurite
Azurite is the official Azure Storage emulator. It supports the Blob REST API with account-key authentication.
```bash
docker run -d --name azurite \
-p 10000:10000 -p 10001:10001 -p 10002:10002 \
mcr.microsoft.com/azure-storage/azurite
```
Create the container against the well-known development account:
```bash
az storage container create \
--name documenso-documents \
--connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
```
Configure environment variables to point at the emulator:
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=devstoreaccount1
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT=http://127.0.0.1:10000
```
<Callout type="info">
The Azurite key shown above is the public well-known development key, published by Microsoft for emulator use. Never reuse it in production.
</Callout>
---
## CloudFront CDN (Optional)
Use Amazon CloudFront to serve documents with lower latency and reduced S3 costs. CloudFront integration uses signed URLs for secure access.
@@ -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>
@@ -26,8 +26,14 @@ docker --version
## Pulling the Docker Image
The Documenso image is available on both DockerHub and GitHub Container Registry:
```bash
# DockerHub
docker pull documenso/documenso:latest
# GitHub Container Registry
docker pull ghcr.io/documenso/documenso:latest
```
### Available Tags
@@ -100,7 +106,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).
@@ -192,6 +202,14 @@ Documenso provides health check endpoints for monitoring:
| `/api/health` | Checks database connectivity and certificate status |
| `/api/certificate-status` | Returns whether a signing certificate is configured and usable |
Both endpoints return a JSON response with a `status` field:
| Status | Meaning |
| ----------- | -------------------------------------------------------------------- |
| `"ok"` | Everything is working properly |
| `"warning"` | Application is running but there are certificate issues |
| `"error"` | Critical issues (database unreachable, missing configuration, etc.) |
### Docker Health Check
Add a health check to your container:
@@ -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:
@@ -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` |
@@ -3,6 +3,8 @@ title: Getting Started
description: Requirements and quick start guide for self-hosting Documenso.
---
import { Callout } from 'fumadocs-ui/components/callout';
<Cards>
<Card
title="Requirements"
@@ -15,3 +17,11 @@ description: Requirements and quick start guide for self-hosting Documenso.
href="/docs/self-hosting/getting-started/quick-start"
/>
</Cards>
<Callout type="error">
**You must generate a signing certificate.** Documenso does not ship with one. Without a
certificate, the application starts normally but document signing will fail on completion with
errors.
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
</Callout>
@@ -7,14 +7,29 @@ import { Callout } from 'fumadocs-ui/components/callout';
## What You Need
Documenso requires the following external services:
Documenso requires the following items and external services:
| Service | Purpose | Minimum Version |
| ------------- | ---------------------------- | --------------- |
| Signing certificate | Digital signature for documents | N/A |
| PostgreSQL | Primary database | 14+ |
| SMTP server | Sending emails to recipients | Any |
| Reverse proxy | SSL termination, routing | Any |
### Signing Certificate
<Callout type="error">
Documenso does not ship with a signing certificate. Without one, the application starts normally
but all document signing will fail. You must generate or provide a `.p12` certificate before going
to production.
</Callout>
Every completed document is digitally signed using an X.509 certificate. You can generate a self-signed certificate for free or use one from a Certificate Authority (CA).
- [Generate a local certificate](/docs/self-hosting/configuration/signing-certificate/local) — step-by-step instructions to create a `.p12` certificate
- [All certificate options](/docs/self-hosting/configuration/signing-certificate) — self-signed, CA-issued, and Google Cloud HSM
### PostgreSQL Database
Documenso uses PostgreSQL for all data storage including documents, users, and audit logs. You cannot use MySQL, SQLite, or other databases.
@@ -154,6 +154,34 @@ See [Background Jobs Configuration](/docs/self-hosting/configuration/background-
---
## IPv6-Only Deployments
If you are deploying to an environment that uses only IPv6, set the `HOST` environment variable to `::` so the application binds to all IPv6 addresses:
**Docker:**
```bash
docker run -it -e HOST=:: documenso/documenso:latest npm run start
```
**Kubernetes or Docker Compose:**
```yaml
containers:
- name: documenso
image: documenso/documenso:latest
command:
- npm
args:
- run
- start
env:
- name: HOST
value: '::'
```
---
## Docker File Permissions
The Documenso container runs as a non-root user (UID 1001). If you mount files into the container (certificates, configuration), ensure they're readable:
+10 -1
View File
@@ -3,6 +3,8 @@ title: Self-Hosting
description: Deploy and manage your own Documenso instance for complete control over your data, compliance, and customization.
---
import { Callout } from 'fumadocs-ui/components/callout';
## Getting Started
<Cards>
@@ -18,6 +20,13 @@ description: Deploy and manage your own Documenso instance for complete control
/>
</Cards>
<Callout type="error">
**You must generate a signing certificate.** Documenso does not ship with one. Without a
certificate, the application starts normally but document signing will fail.
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
</Callout>
---
## Deployment Options
@@ -122,7 +131,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
![Documents dashboard](/document-signing/documenso-documents-dashboard.webp)
@@ -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">
@@ -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 */}
<img
src="/get-started-images/documenso-registration-form.webp"
alt="Documenso registration form with name, email, and password fields"
style={{width: '500px', height: '650px', objectFit: 'contain' }}
/>
</Step>
@@ -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:
@@ -7,14 +7,14 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
<Callout type="error">
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.
</Callout>
## 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.
</Callout>
## 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.
<Callout type="warn">
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.
</Callout>
---
## See Also
+17 -2
View File
@@ -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,
},
+2 -2
View File
@@ -16,7 +16,7 @@
"fumadocs-ui": "16.5.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.2.4",
"next": "16.2.6",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
@@ -29,7 +29,7 @@
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"postcss": "^8.5.14",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.2.4"
"next": "16.2.6"
},
"devDependencies": {
"@types/node": "^20",
+8 -1
View File
@@ -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"
@@ -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>
);
};
@@ -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<typeof ZAdminOrganisationSyncSubscriptionFormSchema>;
export const AdminOrganisationSyncSubscriptionDialog = ({
organisationId,
trigger,
}: AdminOrganisationSyncSubscriptionDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const form = useForm<TAdminOrganisationSyncSubscriptionFormSchema>({
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 (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sync Stripe subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fetch the latest subscription data from Stripe and apply it to this organisation.</Trans>
</DialogDescription>
</DialogHeader>
<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="syncClaims"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<Checkbox
id="admin-sync-subscription-sync-claims"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="admin-sync-subscription-sync-claims"
className="font-normal text-muted-foreground text-sm leading-snug"
>
<Trans>
Sync claims. This will overwrite the current claim with the one resolved from the Stripe
subscription.
</Trans>
</label>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Sync</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -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) {
@@ -0,0 +1,152 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateUserRequestSchema } from '@documenso/trpc/server/admin-router/create-user.types';
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 type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
export type AdminUserCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZFormSchema = ZCreateUserRequestSchema;
type TFormSchema = z.infer<typeof ZFormSchema>;
export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const form = useForm<TFormSchema>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
email: '',
name: '',
},
});
const { mutateAsync: createUser } = trpc.admin.user.create.useMutation();
const onFormSubmit = async (data: TFormSchema) => {
try {
const result = await createUser(data);
await navigate(`/admin/users/${result.userId}`);
setOpen(false);
toast({
title: t`Success`,
description: t`User created and welcome email sent`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: error.message || t`We encountered an error while creating the user. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create User</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create User</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create a new user. A welcome email will be sent with a link to set their password.</Trans>
</DialogDescription>
</DialogHeader>
<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="email"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" data-testid="dialog-create-user-button" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -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={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<>
<div className="flex items-center space-x-2">
<Checkbox
id="backport-email-transport"
checked={backportEmailTransport}
onCheckedChange={(checked) => setBackportEmailTransport(checked === true)}
/>
<label htmlFor="backport-email-transport" className="text-muted-foreground text-sm">
<Trans>Backport email transport</Trans>
</label>
</div>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
</>
}
/>
</DialogContent>
@@ -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<DialogPrimitive.DialogProps, 'children'>;
const ZMoveDocumentFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
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<TMoveDocumentFormSchema>({
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 (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a folder to move this document to.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting || currentFolderId === null}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -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<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | 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<typeof ZResendDocumentFormSchema>;
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<TResendDocumentFormSchema>({
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle asChild>
<h1 className="text-center text-xl">
<Trans>Who do you want to remind?</Trans>
</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem key={recipient.id} className="flex flex-row items-center justify-between gap-x-3">
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full border border-neutral-400"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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 (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="flex-shrink-0">
<Trans>Add transport</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fill in the details to create a new email transport.</Trans>
</DialogDescription>
</DialogHeader>
<EmailTransportForm
onFormSubmit={onFormSubmit}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};
@@ -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 (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Are you sure you want to delete the following transport?</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">{transportName}</AlertDescription>
</Alert>
{isInUse && (
<Alert variant="destructive">
<AlertDescription>
<Trans>Warning, this email transport is currently being used by:</Trans>
<ul className="mt-2 list-disc pl-5">
{subscriptionClaimCount > 0 && (
<li>
<Plural value={subscriptionClaimCount} one="# Subscription claim" other="# Subscription claims" />
</li>
)}
{organisationClaimCount > 0 && (
<li>
<Plural value={organisationClaimCount} one="# Organisation claim" other="# Organisation claims" />
</li>
)}
</ul>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () => deleteTransport({ id: transportId })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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<typeof ZSendTestEmailFormSchema>;
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<TSendTestEmailFormSchema>({
resolver: zodResolver(ZSendTestEmailFormSchema),
defaultValues: {
to: '',
},
});
const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => {
await sendTest({ id: transportId, to });
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Send Test Email</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Send a test email using this transport to verify the configuration.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" placeholder={t`test@example.com`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Send</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -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 (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Edit Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Modify the details of the email transport.</Trans>
</DialogDescription>
</DialogHeader>
<EmailTransportForm
isEdit
defaultValues={{
// Pre-fill the non-secret connection settings; secrets stay blank
// and are preserved on save unless re-entered.
...(transport.config ?? {}),
name: transport.name,
fromName: transport.fromName,
fromAddress: transport.fromAddress,
type: transport.type,
}}
onFormSubmit={onFormSubmit}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Save changes</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};
@@ -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> | 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 (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are about to cancel <strong>"{title}"</strong>
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document signing process will be stopped</Trans>
</li>
<li>
<Trans>Recipients will be notified that the document was cancelled</Trans>
</li>
<li>
<Trans>The document will remain in your dashboard marked as Cancelled</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Label htmlFor="cancel-reason">
<Trans>Reason (optional)</Trans>
</Label>
<Textarea
id="cancel-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t`Add an optional reason for cancelling this document`}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void cancelEnvelope({ envelopeId: id, reason: reason || undefined })}
variant="destructive"
>
<Trans>Cancel document</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -166,7 +166,7 @@ export const EnvelopeDeleteDialog = ({
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
@@ -1,6 +1,7 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -37,6 +38,7 @@ import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type EnvelopeDistributeDialogProps = {
onDistribute?: () => Promise<void>;
@@ -66,7 +68,7 @@ export const EnvelopeDistributeDialog = ({
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
const { toast } = useToast();
const { t } = useLingui();
const { t, i18n } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
@@ -174,9 +176,13 @@ export const EnvelopeDistributeDialog = ({
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: t`Something went wrong`,
description: t`This envelope could not be distributed at this time. Please try again.`,
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -200,6 +206,11 @@ export const EnvelopeDistributeDialog = ({
};
useEffect(() => {
// Default the distribution method tab to the envelope's configured setting.
if (isOpen && envelope.documentMeta) {
setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod);
}
// Resync the whole envelope if the envelope is mid saving.
if (isOpen && (isAutosaving || autosaveError)) {
void handleSync();
@@ -1,6 +1,7 @@
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@@ -11,10 +12,12 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
@@ -37,6 +40,15 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
@@ -55,8 +67,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await duplicateEnvelope({ envelopeId });
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
} catch {
toast({
title: t`Something went wrong`,
@@ -70,7 +88,20 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
};
return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
<Dialog
open={open}
onOpenChange={(value) => {
if (isDuplicating) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
@@ -87,6 +118,49 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeDuplicateIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeFields" className={!includeRecipients ? 'opacity-50' : ''}>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isDuplicating}>
@@ -1,4 +1,5 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
@@ -24,14 +25,16 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'> & {
recipients: TEnvelopeRecipientLite[];
};
envelopeType?: EnvelopeType;
trigger?: React.ReactNode;
};
@@ -43,11 +46,11 @@ export const ZEnvelopeRedistributeFormSchema = z.object({
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => {
export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: EnvelopeRedistributeDialogProps) => {
const recipients = envelope.recipients;
const { toast } = useToast();
const { t } = useLingui();
const { t, i18n } = useLingui();
const [isOpen, setIsOpen] = useState(false);
@@ -69,17 +72,34 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
try {
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
const successMessage = match(envelopeType)
.with(EnvelopeType.DOCUMENT, () => ({
title: t`Document resent`,
description: t`Your document has been resent successfully.`,
}))
.with(EnvelopeType.TEMPLATE, () => ({
title: t`Template resent`,
description: t`Your template has been resent successfully.`,
}))
.otherwise(() => ({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
}));
toast({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
title: successMessage.title,
description: successMessage.description,
duration: 5000,
});
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: t`Something went wrong`,
description: t`This envelope could not be resent at this time. Please try again.`,
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -0,0 +1,159 @@
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,
} 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 { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
export type EnvelopesBulkCancelDialogProps = {
envelopeIds: string[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkCancelDialog = ({
envelopeIds,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkCancelDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const trpcUtils = trpc.useUtils();
const [reason, setReason] = useState('');
useEffect(() => {
if (open) {
setReason('');
}
}, [open]);
const { mutateAsync: bulkCancelEnvelopes, isPending } = trpc.envelope.bulk.cancel.useMutation({
onSuccess: async (result) => {
await trpcUtils.document.findDocumentsInternal.invalidate();
if (result.failedIds.length > 0) {
toast({
title: t`Documents partially cancelled`,
description: t`${plural(result.cancelledCount, {
one: '# document cancelled.',
other: '# documents cancelled.',
})} ${plural(result.failedIds.length, {
one: '# document could not be cancelled.',
other: '# documents could not be cancelled.',
})}`,
variant: 'destructive',
});
} else {
toast({
title: t`Documents cancelled`,
description: plural(result.cancelledCount, {
one: '# document has been cancelled.',
other: '# documents have been cancelled.',
}),
variant: 'default',
});
}
onSuccess?.();
onOpenChange(false);
},
onError: () => {
toast({
title: t`Error`,
description: t`An error occurred while cancelling the documents.`,
variant: 'destructive',
});
},
});
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Cancel Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopeIds.length}
one="You are about to cancel the selected document."
other="You are about to cancel # documents."
/>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Only pending documents you have permission to manage will be cancelled.</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document signing process will be stopped</Trans>
</li>
<li>
<Trans>Recipients will be notified that the document was cancelled</Trans>
</li>
<li>
<Trans>The documents will remain in your dashboard marked as Cancelled</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Label htmlFor="bulk-cancel-reason">
<Trans>Reason (optional)</Trans>
</Label>
<Textarea
id="bulk-cancel-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t`Add an optional reason for cancelling these documents`}
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={(e) => {
e.preventDefault();
void bulkCancelEnvelopes({ envelopeIds, reason: reason || undefined });
}}
loading={isPending}
variant="destructive"
>
<Trans>Cancel documents</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -28,7 +28,7 @@ export type EnvelopesBulkMoveDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: () => void;
onSuccess?: (folderId: string | null) => Promise<void> | void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
@@ -99,11 +99,12 @@ export const EnvelopesBulkMoveDialog = ({
await trpcUtils.template.findTemplates.invalidate();
}
await onSuccess?.(data.folderId);
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
@@ -222,7 +222,7 @@ export const ManagePublicTemplateDialog = ({
.with({ currentStep: 'SELECT_TEMPLATE' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
<DialogTitle className="w-full max-w-full whitespace-pre-line break-words">
{team?.name ? (
<Trans>{team.name} direct signing templates</Trans>
) : (
@@ -16,6 +16,17 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { match } from 'ts-pattern';
/**
* The reason a team member cannot be removed from the team. When set, the delete
* dialog explains the reason instead of offering a confirm button.
*/
export type TeamMemberDeleteDisableReason =
| 'TEAM_OWNER'
| 'HIGHER_ROLE'
| 'INHERIT_MEMBER_ENABLED'
| 'INHERITED_MEMBER';
export type TeamMemberDeleteDialogProps = {
teamId: number;
@@ -23,7 +34,7 @@ export type TeamMemberDeleteDialogProps = {
memberId: string;
memberName: string;
memberEmail: string;
isInheritMemberEnabled: boolean | null;
disableReason?: TeamMemberDeleteDisableReason | null;
trigger?: React.ReactNode;
};
@@ -34,7 +45,7 @@ export const TeamMemberDeleteDialog = ({
memberId,
memberName,
memberEmail,
isInheritMemberEnabled,
disableReason,
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
@@ -86,10 +97,19 @@ export const TeamMemberDeleteDialog = ({
</DialogDescription>
</DialogHeader>
{isInheritMemberEnabled ? (
{disableReason ? (
<Alert variant="neutral">
<AlertDescription>
<Trans>You cannot remove members from this team if the inherit member feature is enabled.</Trans>
{match(disableReason)
.with('TEAM_OWNER', () => <Trans>You cannot remove the organisation owner from the team.</Trans>)
.with('HIGHER_ROLE', () => <Trans>You cannot remove a member with a role higher than your own.</Trans>)
.with('INHERIT_MEMBER_ENABLED', () => (
<Trans>You cannot remove members from this team while the inherit member feature is enabled.</Trans>
))
.with('INHERITED_MEMBER', () => (
<Trans>This member is inherited from a group and cannot be removed from the team directly.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
) : (
@@ -109,11 +129,10 @@ export const TeamMemberDeleteDialog = ({
<Trans>Close</Trans>
</Button>
{!isInheritMemberEnabled && (
{!disableReason && (
<Button
type="submit"
variant="destructive"
disabled={Boolean(isInheritMemberEnabled)}
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
@@ -1,232 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } 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 TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await updateTemplate({
templateId,
data: {
folderId: data.folderId ?? null,
},
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} 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 template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data?.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -4,7 +4,7 @@ import {
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
@@ -35,8 +35,8 @@ import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
@@ -180,22 +180,11 @@ export function TemplateUseDialog({
await navigate(documentPath);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('DOCUMENT_SEND_FAILED', () => msg`The document was created but could not be sent to recipients.`)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
() =>
msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
)
.with(AppErrorCode.NOT_FOUND, () => msg`The template or one of its recipients could not be found.`)
.with(AppErrorCode.LIMIT_EXCEEDED, () => msg`You have reached your document limit for this plan.`)
.otherwise(() => msg`An error occurred while creating document from template.`);
const errorMessage = getTemplateUseErrorMessage(error.code);
toast({
title: _(msg`Error`),
description: _(errorMessage),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});
}
@@ -3,6 +3,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -42,6 +43,7 @@ import { useSearchParams } from 'react-router';
import { BrandingLogo } from '~/components/general/branding-logo';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
@@ -259,9 +261,12 @@ export const EmbedDirectTemplateClientPage = ({
);
}
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});
}
@@ -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>
@@ -116,11 +116,12 @@ export const EditorFieldDropdownForm = ({
}
const newValues = [...currentValues];
const removedValue = currentValues[index].value;
newValues.splice(index, 1);
form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
if (form.getValues('defaultValue') === removedValue) {
form.setValue('defaultValue', undefined);
}
};
@@ -0,0 +1,317 @@
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(),
port: z.coerce.number().int().positive().optional(),
secure: z.boolean().optional(),
ignoreTLS: z.boolean().optional(),
username: z.string().optional(),
password: z.string().optional(),
service: z.string().optional(),
apiKey: z.string().optional(),
apiKeyUser: z.string().optional(),
endpoint: z.string().optional(),
});
export type EmailTransportFormValues = z.infer<typeof ZEmailTransportFormSchema>;
type EmailTransportFormProps = {
defaultValues?: Partial<EmailTransportFormValues>;
isEdit?: boolean;
onFormSubmit: (values: EmailTransportFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
};
export const EmailTransportForm = ({
defaultValues,
isEdit = false,
onFormSubmit,
formSubmitTrigger,
}: EmailTransportFormProps) => {
const { t } = useLingui();
const form = useForm<EmailTransportFormValues>({
resolver: zodResolver(ZEmailTransportFormSchema),
defaultValues: {
name: '',
fromName: '',
fromAddress: '',
type: 'SMTP_AUTH',
secure: false,
ignoreTLS: false,
...defaultValues,
},
});
const type = form.watch('type');
const secretPlaceholder = isEdit ? t`Leave blank to keep current` : undefined;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`e.g. Resend (free plans)`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="fromName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>From name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fromAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>From address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Transport type</Trans>
</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={isEdit}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="SMTP_AUTH">SMTP (auth)</SelectItem>
<SelectItem value="SMTP_API">SMTP (api)</SelectItem>
<SelectItem value="RESEND">Resend</SelectItem>
<SelectItem value="MAILCHANNELS">MailChannels</SelectItem>
</SelectContent>
</Select>
{isEdit && (
<FormDescription>
<Trans>Transport type cannot be changed after creation.</Trans>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{(type === 'SMTP_AUTH' || type === 'SMTP_API') && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Host</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Port</Trans>
</FormLabel>
<FormControl>
<Input type="number" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{type === 'SMTP_AUTH' && (
<>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === 'SMTP_API' && (
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>API key</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{(type === 'RESEND' || type === 'MAILCHANNELS') && (
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>API key</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === 'MAILCHANNELS' && (
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Endpoint (optional)</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{formSubmitTrigger}
</fieldset>
</form>
</Form>
);
};
/**
* Maps flat form values to the tRPC `config` discriminated union.
*/
export const emailTransportFormToConfig = (values: EmailTransportFormValues) => {
switch (values.type) {
case 'SMTP_AUTH':
return {
type: 'SMTP_AUTH' as const,
host: values.host ?? '',
port: values.port ?? 587,
secure: values.secure ?? false,
ignoreTLS: values.ignoreTLS ?? false,
username: values.username || undefined,
password: values.password || undefined,
service: values.service || undefined,
};
case 'SMTP_API':
return {
type: 'SMTP_API' as const,
host: values.host ?? '',
port: values.port ?? 587,
secure: values.secure ?? false,
apiKey: values.apiKey || '',
apiKeyUser: values.apiKeyUser || undefined,
};
case 'RESEND':
return { type: 'RESEND' as const, apiKey: values.apiKey || '' };
case 'MAILCHANNELS':
return {
type: 'MAILCHANNELS' as const,
apiKey: values.apiKey || '',
endpoint: values.endpoint || undefined,
};
}
};
@@ -197,7 +197,9 @@ export const PublicProfileForm = ({ className, profile, onProfileUpdate }: Publi
return (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormLabel>
<Trans>Bio</Trans>
</FormLabel>
<FormControl>
<Textarea {...field} placeholder={_(msg`Write a description to display on your public profile`)} />
</FormControl>
+23 -15
View File
@@ -89,7 +89,6 @@ export const SignInForm = ({
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const twoFactorTurnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
@@ -197,13 +196,31 @@ export const SignInForm = ({
};
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
const $turnstile = isTwoFactorAuthenticationDialogOpen ? twoFactorTurnstileRef.current : turnstileRef.current;
try {
let token: string | undefined;
if (turnstileSiteKey) {
token = await $turnstile?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signIn({
email,
password,
totpCode,
backupCode,
captchaToken: captchaToken ?? undefined,
captchaToken: token ?? undefined,
redirectPath,
});
} catch (err) {
@@ -214,10 +231,6 @@ export const SignInForm = ({
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
setIsTwoFactorAuthenticationDialogOpen(true);
// Turnstile tokens are single-use. Clear the consumed one so the
// dialog's fresh widget mounts cleanly and the dialog can't be
// submitted with the stale token before a new one is issued.
setCaptchaToken(null);
return;
}
@@ -247,8 +260,7 @@ export const SignInForm = ({
variant: 'destructive',
});
turnstileRef.current?.reset();
setCaptchaToken(null);
$turnstile?.reset();
}
};
@@ -358,11 +370,9 @@ export const SignInForm = ({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
)}
@@ -499,11 +509,9 @@ export const SignInForm = ({
<Turnstile
ref={twoFactorTurnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
</div>
@@ -518,7 +526,7 @@ export const SignInForm = ({
)}
</Button>
<Button type="submit" loading={isSubmitting} disabled={Boolean(turnstileSiteKey) && !captchaToken}>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</DialogFooter>
+108 -84
View File
@@ -20,7 +20,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
@@ -49,6 +49,7 @@ export const ZSignUpFormSchema = z
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
SIGNUP_DISPOSABLE_EMAIL: msg`Disposable email addresses are not allowed. Please sign up with a permanent email address.`,
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
};
@@ -58,18 +59,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();
@@ -84,9 +87,7 @@ export const SignUpForm = ({
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const hasSocialAuthEnabled = isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
const form = useForm<TSignUpFormSchema>({
values: {
@@ -103,12 +104,28 @@ export const SignUpForm = ({
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
try {
let token: string | undefined;
if (turnstileSiteKey) {
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signUp({
name,
email,
password,
signature,
captchaToken: captchaToken ?? undefined,
captchaToken: token ?? undefined,
});
await navigate(returnTo ? returnTo : '/unverified-account');
@@ -138,14 +155,13 @@ export const SignUpForm = ({
});
turnstileRef.current?.reset();
setCaptchaToken(null);
}
};
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 +173,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 +185,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,82 +251,88 @@ 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
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
)}
@@ -325,7 +347,7 @@ export const SignUpForm = ({
</div>
)}
{isGoogleSSOEnabled && (
{isGoogleSignupEnabled && (
<Button
type="button"
size="lg"
@@ -339,7 +361,7 @@ export const SignUpForm = ({
</Button>
)}
{isMicrosoftSSOEnabled && (
{isMicrosoftSignupEnabled && (
<Button
type="button"
size="lg"
@@ -353,7 +375,7 @@ export const SignUpForm = ({
</Button>
)}
{isOIDCSSOEnabled && (
{isOidcSignupEnabled && (
<Button
type="button"
size="lg"
@@ -377,9 +399,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">
@@ -1,5 +1,6 @@
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -13,6 +14,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
@@ -20,6 +22,8 @@ import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import type { z } from 'zod';
import { ClaimLimitFields } from '../general/claim-limit-fields';
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
type SubscriptionClaimFormProps = {
@@ -49,10 +53,22 @@ export const SubscriptionClaimForm = ({
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
flags: subscriptionClaim.flags,
documentRateLimits: subscriptionClaim.documentRateLimits,
documentQuota: subscriptionClaim.documentQuota,
emailRateLimits: subscriptionClaim.emailRateLimits,
emailQuota: subscriptionClaim.emailQuota,
apiRateLimits: subscriptionClaim.apiRateLimits,
apiQuota: subscriptionClaim.apiQuota,
emailTransportId: subscriptionClaim.emailTransportId ?? null,
},
});
const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 });
const transports = transportsData?.data ?? [];
const NONE_VALUE = '__none__';
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
@@ -145,6 +161,30 @@ export const SubscriptionClaimForm = ({
)}
/>
<FormField
control={form.control}
name="recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
@@ -203,6 +243,42 @@ export const SubscriptionClaimForm = ({
)}
</div>
<ClaimLimitFields control={form.control} disabled={form.formState.isSubmitting} />
<FormField
control={form.control}
name="emailTransportId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email transport</Trans>
</FormLabel>
<Select
value={field.value ?? NONE_VALUE}
onValueChange={(value) => field.onChange(value === NONE_VALUE ? null : value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Default (system mailer)`} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}>{t`Default (system mailer)`}</SelectItem>
{transports.map((transport) => (
<SelectItem key={transport.id} value={transport.id}>
{transport.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
<Trans>Plans without a transport use the system default mailer.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{formSubmitTrigger}
</fieldset>
</form>
@@ -0,0 +1,171 @@
import {
SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
type TSiteSettingsEmailBlocklistSchema,
} from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
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 { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
const ZEmailBlocklistFormSchema = z.object({
enabled: z.boolean(),
domains: z.string(),
});
type TEmailBlocklistFormSchema = z.infer<typeof ZEmailBlocklistFormSchema>;
/**
* Splits a comma-separated string into a normalised list of domains.
* Normalisation (trim, lowercase, strip leading "@", dedupe) is applied
* server-side by the schema as well — this is for display consistency.
*/
const parseDomainsInput = (value: string): string[] => {
return Array.from(
new Set(
value
.split(',')
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
.filter((entry) => entry.length > 0),
),
);
};
type AdminEmailBlocklistSectionProps = {
emailBlocklist: TSiteSettingsEmailBlocklistSchema | undefined;
};
export const AdminEmailBlocklistSection = ({ emailBlocklist }: AdminEmailBlocklistSectionProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TEmailBlocklistFormSchema>({
resolver: zodResolver(ZEmailBlocklistFormSchema),
defaultValues: {
enabled: emailBlocklist?.enabled ?? false,
domains: (emailBlocklist?.data?.domains ?? []).join(', '),
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBlocklistUpdate = async ({ enabled, domains }: TEmailBlocklistFormSchema) => {
try {
const parsedDomains = parseDomainsInput(domains);
await updateSiteSetting({
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
enabled,
data: {
domains: parsedDomains,
},
});
// Reflect the normalised value back in the form.
form.reset({
enabled,
domains: parsedDomains.join(', '),
});
toast({
title: _(msg`Email Blocklist Updated`),
description: _(msg`The email blocklist has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the email blocklist. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Email Blocklist</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Block signups from additional email domains on top of the bundled disposable email list. Subdomains are
matched automatically (e.g. blocking "bad.com" also blocks "foo.bad.com").
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBlocklistUpdate)}>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="mt-4" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Blocked Domains</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" placeholder="bad.com, spam.net, throwaway.io" {...field} />
</FormControl>
<FormDescription>
<Trans>Comma-separated list of email domains to block from signing up.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Blocklist</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,197 @@
import {
SITE_SETTINGS_BANNER_ID,
type TSiteSettingsBannerSchema,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
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 { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { useCspNonce } from '~/utils/nonce';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
type AdminSiteBannerSectionProps = {
banner: TSiteSettingsBannerSchema | undefined;
};
export const AdminSiteBannerSection = ({ banner }: AdminSiteBannerSectionProps) => {
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -54,7 +54,6 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
if (plan[interval] && plan[interval].isVisibleInApp) {
prices.push({
...plan[interval],
memberCount: plan.memberCount,
claim: plan.id,
});
}
@@ -120,12 +119,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
<Trans>Subscribe</Trans>
</IndividualPersonalLayoutCheckoutButton>
) : (
<BillingDialog
priceId={price.id}
planName={price.product.name}
memberCount={price.memberCount}
claim={price.claim}
/>
<BillingDialog priceId={price.id} planName={price.product.name} claim={price.claim} />
)}
</CardContent>
</MotionCard>
@@ -136,16 +130,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
);
};
const BillingDialog = ({
priceId,
planName,
claim,
}: {
priceId: string;
planName: string;
memberCount: number;
claim: string;
}) => {
const BillingDialog = ({ priceId, planName, claim }: { priceId: string; planName: string; claim: string }) => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useLingui();
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -12,6 +13,9 @@ 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 { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
@@ -50,6 +54,9 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
const analytics = useAnalytics();
const navigate = useNavigate();
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const form = useForm<TClaimAccountFormSchema>({
values: {
name: defaultName ?? '',
@@ -61,7 +68,28 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
try {
await authClient.emailPassword.signUp({ name, email, password });
let token: string | undefined;
if (turnstileSiteKey) {
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signUp({
name,
email,
password,
captchaToken: token ?? undefined,
});
await navigate(`/unverified-account`);
@@ -87,6 +115,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
description: _(errorMessage),
variant: 'destructive',
});
turnstileRef.current?.reset();
}
};
@@ -141,6 +171,19 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
)}
/>
{turnstileSiteKey && (
<div className="mt-4">
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{
size: 'flexible',
appearance: 'always',
}}
/>
</div>
)}
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
<Trans>Claim account</Trans>
</Button>
@@ -0,0 +1,97 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react';
import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input';
type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
prefix?: string;
disabled?: boolean;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
disabled,
}: ClaimLimitFieldsProps<T>) => {
const { t } = useLingui();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>;
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
<FormField
control={control}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type="number"
min={0}
disabled={disabled}
value={field.value === null || field.value === undefined ? '' : field.value}
placeholder={t`Unlimited`}
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
const renderRateLimitField = (key: string, label: ReactNode) => (
<FormField
control={control}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
return (
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
{renderQuotaField(
'emailQuota',
<Trans>Monthly email quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
</div>
);
};
@@ -1,4 +1,5 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -12,11 +13,12 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Field, Recipient } from '@prisma/client';
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form';
import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form';
@@ -35,7 +37,6 @@ export const DirectTemplatePageView = ({
directTemplateRecipient,
directTemplateToken,
}: DirectTemplatePageViewProps) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { _ } = useLingui();
@@ -117,12 +118,15 @@ export const DirectTemplatePageView = ({
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${token}/complete`);
window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});

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