Compare commits

...

34 Commits

Author SHA1 Message Date
ephraimduncan 358677fc3a feat: add PDF viewer zoom controls 2026-05-21 03:56:09 +00: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
180 changed files with 9977 additions and 2546 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.
+23 -1
View File
@@ -160,8 +160,16 @@ NEXT_PRIVATE_REDIS_PREFIX="documenso"
NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal).
NEXT_PUBLIC_DISABLE_SIGNUP=
# OPTIONAL: Set to "true" to disable email/password signup only.
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=
# OPTIONAL: Set to "true" to block new-account creation through Google. Existing linked users can still sign in.
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=
# OPTIONAL: Set to "true" to block new-account creation through Microsoft. Existing linked users can still sign in.
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
# OPTIONAL: Set to "true" to block new-account creation through OIDC (including the organisation portal).
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
@@ -203,3 +211,17 @@ NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=
# [[DOCUMENT CONVERSION]]
# OPTIONAL: Base URL of a Gotenberg-compatible service used to convert uploaded
# DOCX files to PDF on the server. When unset, DOCX uploads are disabled and
# only PDF is accepted. The dev docker compose exposes Gotenberg on port 3005.
# NEXT_PRIVATE_DOCUMENT_CONVERSION_URL="http://localhost:3005"
# OPTIONAL: Per-request timeout in milliseconds for the conversion service.
# Defaults to 30000 (30s) if unset.
# NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS=30000
# OPTIONAL: HTTP Basic auth credentials for the conversion service. Set both
# when the service is started with `--api-enable-basic-auth` (the dev compose
# does this; the matching values there are `documenso` / `password`).
# NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
# NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=password
+1 -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
+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:
+14 -144
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
```
Then, inside the `documenso` folder, copy the example env file:
```
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
#### 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,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
@@ -6,14 +6,14 @@ description: Embed document signing experiences directly in your application usi
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
## Embedded Signing vs Embedded Authoring
## Embedded Signing vs Embedded Editor
Documenso offers two types of embedding:
- **Embedded Signing** lets you embed the signing experience in your application. Your users sign documents without leaving your site. Available on Teams Plan and above.
- **Embedded Authoring** lets you embed document and template _creation and editing_ in your application. This is an [Enterprise](/docs/policies/enterprise-edition) feature (also available as a Platform Plan add-on). See the [Authoring](/docs/developers/embedding/authoring) guide.
- **Embedded Editor** lets you embed document and template _creation and editing_ in your application. This is an [Enterprise](/docs/policies/enterprise-edition) feature (also available as a Platform Plan add-on). See the [Editor](/docs/developers/embedding/editor) guide.
This page covers **embedded signing**. If you need your users to create or edit documents inside your app, see [Authoring](/docs/developers/embedding/authoring).
This page covers **embedded signing**. If you need your users to create or edit documents inside your app, see [Editor](/docs/developers/embedding/editor).
---
@@ -229,9 +229,9 @@ Receives an object with:
href="/docs/developers/embedding/css-variables"
/>
<Card
title="Authoring"
title="Editor"
description="Embed document and template creation."
href="/docs/developers/embedding/authoring"
href="/docs/developers/embedding/editor"
/>
</Cards>
@@ -1,4 +1,4 @@
{
"title": "Embedding",
"pages": ["sdks", "direct-links", "css-variables", "authoring"]
"pages": ["sdks", "direct-links", "css-variables", "editor"]
}
@@ -89,4 +89,4 @@ export class SigningComponent {
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
- [Editor](/docs/developers/embedding/editor) - Embed document creation
@@ -93,4 +93,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
- [Editor](/docs/developers/embedding/editor) - Embed document creation
@@ -133,4 +133,4 @@ const DocumentSigning = ({ token }: { token: string }) => {
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
- [Editor](/docs/developers/embedding/editor) - Embed document creation
@@ -93,4 +93,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
- [Editor](/docs/developers/embedding/editor) - Embed document creation
@@ -101,4 +101,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
- [Editor](/docs/developers/embedding/editor) - Embed document creation
@@ -104,4 +104,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
- [Editor](/docs/developers/embedding/editor) - Embed document creation
@@ -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
@@ -1,5 +1,5 @@
---
title: AI Recipient & Field Detection (Self-hosting)
title: AI Recipient & Field Detection
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
---
@@ -0,0 +1,408 @@
---
title: Document Conversion
description: Enable DOCX uploads on a self-hosted Documenso instance by running a Gotenberg sidecar that converts Word documents to PDF.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Overview
Documenso can accept `.docx` uploads in addition to PDFs. When a user uploads a Word document, the Documenso server sends it to a [Gotenberg](https://gotenberg.dev) service which uses LibreOffice to convert it to PDF. The converted PDF is what gets stored, signed, and downloaded. The original DOCX is discarded.
This feature is **opt-in for self-hosted instances**. When the conversion service is not configured, DOCX uploads are rejected in the UI and only PDFs are accepted.
| Property | Value |
| ----------------------- | -------------------------------------------------------------------- |
| Conversion engine | [Gotenberg](https://gotenberg.dev) + LibreOffice |
| Input format | `.docx` (Office Open XML Word documents) |
| Output format | PDF |
| Network requirement | Documenso must reach the Gotenberg HTTP API |
| Default request timeout | 30 seconds per file |
| Failure handling | An internal circuit breaker opens for 30 seconds after a failure |
<Callout type="info">
Only `.docx` is accepted. Legacy `.doc`, `.odt`, `.rtf`, and other LibreOffice-supported formats
are rejected at the upload step even when Gotenberg is configured.
</Callout>
---
## Requirements
- A running Gotenberg 8 instance with the LibreOffice module (`gotenberg/gotenberg:8-libreoffice` or newer).
- Network reachability from the Documenso container to the Gotenberg HTTP API.
- A version of Documenso that includes the document conversion feature.
## Build the Gotenberg Image
The upstream `gotenberg/gotenberg:8-libreoffice` image works out of the box, but it ships only **metric-compatible font substitutes** (Carlito for Calibri, Liberation for Arial/Times/Courier). Layout widths are preserved but documents will look noticeably different from Word.
For better fidelity, especially for non-Latin scripts, build a derived image that adds Microsoft Core Fonts and additional language fonts. The Documenso repository ships a reference Dockerfile at [`docker/development/Dockerfile.gotenberg`](https://github.com/documenso/documenso/blob/main/docker/development/Dockerfile.gotenberg) that you can use as a starting point:
```dockerfile
FROM gotenberg/gotenberg:8-libreoffice
USER root
RUN echo "deb http://deb.debian.org/debian trixie contrib non-free" \
> /etc/apt/sources.list.d/contrib.list \
&& echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" \
| debconf-set-selections \
&& apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
ca-certificates \
ttf-mscorefonts-installer \
fonts-symbola \
fonts-noto-extra \
fonts-hosny-amiri \
fonts-thai-tlwg \
fonts-sil-padauk \
fonts-sarai \
fonts-samyak-taml \
culmus \
libfribidi0 \
libharfbuzz0b \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
USER gotenberg
```
<Callout type="warn">
`ttf-mscorefonts-installer` accepts the Microsoft Core Fonts EULA on your behalf via debconf. By
installing this image you are agreeing to those licence terms. Review them before publishing the
image.
</Callout>
Build and publish the image to a registry you control:
```bash
docker build -t registry.example.com/documenso/gotenberg:8 \
-f Dockerfile.gotenberg .
docker push registry.example.com/documenso/gotenberg:8
```
If you do not need extra fonts, skip the build step entirely and reference `gotenberg/gotenberg:8-libreoffice` directly in the next section.
## Deploy the Service
The Gotenberg service should run **alongside** your Documenso container, not exposed to the public internet. The conversion service has no built-in authorisation beyond HTTP Basic auth, so it should sit on a private network or behind your existing reverse proxy.
<Tabs items={['Docker Compose', 'Kubernetes', 'External Instance']}>
<Tab value="Docker Compose">
Add a `gotenberg` service to the `compose.yml` you use for Documenso:
```yaml
services:
gotenberg:
image: registry.example.com/documenso/gotenberg:8
# Or use upstream directly:
# image: gotenberg/gotenberg:8-libreoffice
restart: unless-stopped
environment:
GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_USERNAME}
GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_PASSWORD}
command:
- gotenberg
- --api-enable-basic-auth
- --libreoffice-deny-private-ips
- --api-timeout=500s
- --libreoffice-auto-start
- --libreoffice-start-timeout=300s
- --pdfengines-disable-routes
- --webhook-disable
healthcheck:
test: ['CMD', 'curl', '-fsS', 'http://localhost:3000/health']
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
documenso:
# existing config
environment:
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL: http://gotenberg:3000
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME: ${GOTENBERG_USERNAME}
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD: ${GOTENBERG_PASSWORD}
depends_on:
gotenberg:
condition: service_healthy
```
Do **not** publish Gotenberg's port (`3000`) to the host. Documenso reaches it over the internal Docker network using the service name (`http://gotenberg:3000`).
</Tab>
<Tab value="Kubernetes">
Create a Deployment, Service, and Secret. Example manifests:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: gotenberg-auth
namespace: documenso
stringData:
username: documenso
password: replace-me-with-a-strong-password
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gotenberg
namespace: documenso
spec:
replicas: 1
selector:
matchLabels: { app: gotenberg }
template:
metadata:
labels: { app: gotenberg }
spec:
containers:
- name: gotenberg
image: registry.example.com/documenso/gotenberg:8
args:
- gotenberg
- --api-enable-basic-auth
- --libreoffice-deny-private-ips
- --api-timeout=500s
- --libreoffice-auto-start
- --libreoffice-start-timeout=300s
- --pdfengines-disable-routes
- --webhook-disable
env:
- name: GOTENBERG_API_BASIC_AUTH_USERNAME
valueFrom: { secretKeyRef: { name: gotenberg-auth, key: username } }
- name: GOTENBERG_API_BASIC_AUTH_PASSWORD
valueFrom: { secretKeyRef: { name: gotenberg-auth, key: password } }
ports:
- containerPort: 3000
readinessProbe:
httpGet: { path: /health, port: 3000 }
livenessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: gotenberg
namespace: documenso
spec:
selector: { app: gotenberg }
ports:
- port: 3000
targetPort: 3000
```
Then reference the in-cluster URL from Documenso's environment:
```
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=http://gotenberg.documenso.svc.cluster.local:3000
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
```
</Tab>
<Tab value="External Instance">
Documenso does not have to colocate with Gotenberg. You can point it at any reachable Gotenberg deployment: a managed instance, a shared internal service, or a Gotenberg-compatible API.
```bash
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=https://gotenberg.internal.example.com
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
```
The remote instance must:
- Expose the LibreOffice route `/forms/libreoffice/convert`.
- Be reachable from the Documenso container with low enough latency that the 30 second per-request timeout is comfortable.
- Be on a private network or require authentication. Uploaded documents are sent to it as multipart form data and may contain sensitive content.
</Tab>
</Tabs>
## Recommended Gotenberg Flags
The flags in the examples above are not arbitrary. Each one matters for a production deployment.
| Flag | Why it matters |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--api-enable-basic-auth` | Requires HTTP Basic credentials on every API route. Without this, anyone with network access to the container can convert arbitrary documents. |
| `--libreoffice-deny-private-ips` | Rejects any outbound fetch LibreOffice tries to make to private, loopback, link-local, or cloud-metadata addresses while processing a document. Mitigates SSRF via malicious `.docx` files that embed `TargetMode="External"` references. Requires Gotenberg 8.32.0. |
| `--api-timeout=500s` | Server-side request ceiling. Documenso aborts at 30 s by default, so this is a safety net for very large documents. |
| `--libreoffice-auto-start` | Starts LibreOffice at container boot so the first request is not slow. |
| `--libreoffice-start-timeout=300s`| Allows LibreOffice up to 5 minutes to come up under load. |
| `--pdfengines-disable-routes` | Disables the PDF engines routes Documenso does not use. Shrinks the attack surface. |
| `--webhook-disable` | Disables webhook callbacks. Documenso uses synchronous requests only. |
## Configure Documenso
Set the following environment variables on the Documenso container and restart it.
### Required
| Variable | Description |
| ------------------------------------- | ---------------------------------------------------------------------- |
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL`| Base URL of the Gotenberg service (e.g., `http://gotenberg:3000`). Leave unset to disable the feature. |
### Optional
| Variable | Default | Description |
| ------------------------------------------- | ------- | -------------------------------------------------------------------------------------------- |
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` | | HTTP Basic auth username. Set when Gotenberg runs with `--api-enable-basic-auth`. |
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD` | | HTTP Basic auth password. Set together with the username. |
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS`| `30000` | Per-request timeout in milliseconds. Increase for very large documents. |
<Callout type="info">
When `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is set, the public flag
`NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED` is derived automatically on server start. You do not
need to set it yourself, and setting it manually has no effect.
</Callout>
### Example `.env` Snippet
```bash
# Document conversion (DOCX -> PDF)
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=http://gotenberg:3000
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
# NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS=60000
```
## Verify the Setup
{/* prettier-ignore */}
<Steps>
<Step>
### Restart the Documenso container
Restart so the new environment variables are picked up.
</Step>
<Step>
### Confirm Gotenberg is healthy
From a shell inside the Documenso container or another container on the same network:
```bash
curl -fsS http://gotenberg:3000/health
```
The endpoint is exempt from basic auth and should return `200 OK`.
</Step>
<Step>
### Upload a test DOCX
In the Documenso web UI, open **Documents** and try uploading a small `.docx` file. The upload dropzone should accept it, and after a few seconds the editor should open with the converted PDF.
</Step>
<Step>
### Check the server logs
Successful conversions log a `document_conversion_attempt` event with `result: "success"`, the duration, and the file size. Failures log the same event with `result: "error"` and an error code (`CONVERSION_SERVICE_UNAVAILABLE`, `CONVERSION_FAILED`, or `UNSUPPORTED_FILE_TYPE`).
</Step>
</Steps>
## Security Considerations
- **Treat the conversion service as untrusted internal infrastructure.** Documents pass through Gotenberg in plain form. Run it on a private network and require HTTP Basic auth.
- **Run with `--libreoffice-deny-private-ips`.** Without this flag, a malicious `.docx` can trigger LibreOffice to fetch URLs from your internal network (SSRF).
- **Disable unused routes.** `--pdfengines-disable-routes` and `--webhook-disable` reduce attack surface. Documenso only uses the LibreOffice convert route.
- **Do not expose Gotenberg to the public internet.** Even with basic auth, this is a document-processing service with a non-trivial CPU and memory footprint; exposing it invites abuse.
- **Rotate credentials.** Rotating the basic auth secret is a config change in both Gotenberg and Documenso, followed by a restart of each.
## Resource Sizing
Conversion is CPU- and memory-bound on LibreOffice. As a starting point:
| Workload | Suggested resources |
| ----------------------------- | ------------------------------------ |
| Light (a few DOCX per minute) | 1 vCPU, 1 GB RAM |
| Moderate (sustained uploads) | 2 vCPU, 2 GB RAM |
| Heavy / multi-tenant | Horizontally scale Gotenberg replicas behind a load balancer |
Gotenberg is stateless. Each container handles one or more concurrent requests independently. Scale horizontally rather than vertically once a single replica is saturated.
## Troubleshooting
<Accordions type="multiple">
<Accordion title="DOCX uploads are rejected with 'Only PDF and DOCX files are allowed'">
The Documenso server does not see `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL`. Check the value is set
on the running container (`docker exec documenso printenv | grep DOCUMENT_CONVERSION`) and
restart after changing it.
</Accordion>
<Accordion title="Uploads fail with 'Document conversion service is currently unavailable'">
Documenso could not reach Gotenberg. Verify:
- The URL in `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is resolvable from the Documenso container
(use the Docker service name or in-cluster DNS, not `localhost`).
- Gotenberg's `/health` endpoint returns `200`.
- Basic auth credentials match between the two services.
After repeated failures, an internal circuit breaker opens for 30 seconds. Subsequent uploads
will fail fast during that window; this is intentional and self-recovers.
</Accordion>
<Accordion title="Uploads fail with 'Failed to convert document to PDF'">
Gotenberg was reachable but returned a non-2xx response. Check the Gotenberg container logs:
```bash
docker compose logs -f gotenberg
```
Common causes: corrupted `.docx` file, exotic embedded objects LibreOffice cannot render, or a
file that genuinely exceeded the conversion timeout. Increase
`NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS` for very large documents.
</Accordion>
<Accordion title="Converted PDFs look different from the Word document">
LibreOffice is not byte-identical to Microsoft Word. Layout, font metrics, and complex elements
(Charts, SmartArt, ActiveX controls) may differ. To improve fidelity:
- Use the custom Dockerfile in this guide to install Microsoft Core Fonts and additional
language fonts.
- Make sure any custom fonts referenced by your documents are installed in the Gotenberg image.
- For pixel-perfect output, ask users to export to PDF from Word before uploading.
</Accordion>
<Accordion title="Form controls in the DOCX appear blank or missing">
Documenso disables Gotenberg's `exportFormFields` flag during conversion. Word content controls
(`<w:sdt>`) become static graphics in the output PDF, which prevents Documenso's later
flattening step from making them invisible. This is intentional. Use Documenso fields
(signature, text, date, etc.) for anything that needs to be filled in by signers.
</Accordion>
<Accordion title="Conversion is slow on the first request">
LibreOffice starts lazily by default. Pass `--libreoffice-auto-start` to Gotenberg so it warms
up at container boot. Allow up to a minute on first start before considering the service
unhealthy.
</Accordion>
<Accordion title="The circuit breaker keeps opening">
Repeated failures open an in-process circuit breaker for 30 seconds. If you see this in
production, the underlying problem is the Gotenberg service. Check its logs, resource usage,
and connectivity. The breaker is per-process and resets on restart.
</Accordion>
</Accordions>
---
## See Also
- [Upload Documents (User Guide)](/docs/users/documents/upload) - End-user view of DOCX uploads
- [Environment Variables](/docs/self-hosting/configuration/environment) - Full configuration reference
- [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose) - Compose-based deployment patterns
- [Gotenberg Documentation](https://gotenberg.dev/docs/getting-started/introduction) - Upstream Gotenberg docs
@@ -1,6 +1,6 @@
---
title: Advanced
description: Optional configuration for OAuth providers, AI features, and other advanced settings.
description: Optional configuration for OAuth providers, AI features, document conversion, and other advanced settings.
---
<Cards>
@@ -14,4 +14,9 @@ description: Optional configuration for OAuth providers, AI features, and other
description="Enable AI-powered recipient and field detection."
href="/docs/self-hosting/configuration/advanced/ai-features"
/>
<Card
title="Document Conversion"
description="Accept DOCX uploads by running a Gotenberg sidecar that converts Word documents to PDF."
href="/docs/self-hosting/configuration/advanced/document-conversion"
/>
</Cards>
@@ -1,4 +1,4 @@
{
"title": "Advanced",
"pages": ["oauth-providers", "ai-features"]
"pages": ["oauth-providers", "document-conversion", "ai-features"]
}
@@ -224,28 +224,41 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
## Feature Flags
| Variable | Description | Default |
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| Variable | Description | Default |
| -------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Disable all signup methods application-wide | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only. SSO signup is unaffected | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google. Existing Google-linked users can still sign in | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
### Signup Restrictions
You can control who is allowed to create accounts on your instance using two environment variables:
You can control who is allowed to create accounts on your instance with the following environment variables:
- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups.
- **`NEXT_PUBLIC_DISABLE_SIGNUP`** (master switch): Set to `true` to block all new signups across every method (email/password, Google, Microsoft, OIDC). When set, this also blocks new-account creation through the organisation OIDC authentication portal.
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`**: Set to `true` to disable email/password signup only. SSO signup is still allowed.
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`**: Set to `true` to block brand-new account creation through the matching SSO provider. Existing users with the provider already linked can still sign in, and existing users can still link the provider to their account. `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` also blocks new-account creation through the organisation authentication portal.
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
Sign-in for existing users is never affected, only the creation of brand-new accounts.
When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list.
Both the master switch and the domain allowlist apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
When both the master switch and the domain allowlist are set, the master switch takes precedence. Signups are blocked regardless of the domain list.
```bash
# Allow signups only from specific domains
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
# Allow OIDC signup only; block email/password, Google, Microsoft
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true"
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true"
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
# Or disable signups entirely
NEXT_PUBLIC_DISABLE_SIGNUP="true"
```
@@ -266,6 +279,23 @@ AI features must also be enabled in organisation/team settings after configurati
---
## Document Conversion
Documenso can accept `.docx` uploads by sending them to a [Gotenberg](https://gotenberg.dev) service that converts them to PDF. When `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is unset, DOCX uploads are rejected and only PDFs are accepted.
| Variable | Description | Default |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------- |
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` | Base URL of the Gotenberg service (e.g., `http://gotenberg:3000`). Unset disables the feature. | |
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` | HTTP Basic auth username. Required when Gotenberg runs with `--api-enable-basic-auth`. | |
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD` | HTTP Basic auth password. Set together with the username. | |
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS` | Per-request timeout in milliseconds. Increase for very large documents. | `30000` |
The public flag `NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED` is derived automatically from `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` on server start. Do not set it manually.
For setup, image-build instructions, and security recommendations, see [Document Conversion](/docs/self-hosting/configuration/advanced/document-conversion).
---
## Background Jobs
Documenso supports multiple background job providers for processing emails, documents, webhooks, and scheduled tasks.
@@ -329,7 +359,7 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed authoring, and 21 CFR Part 11 compliance.
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
@@ -371,6 +401,10 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
# Signup restrictions (optional)
# NEXT_PUBLIC_DISABLE_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
```
@@ -155,7 +155,13 @@ PORT=3000
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
# Signup restrictions (optional)
# Master switch — disables every signup method
NEXT_PUBLIC_DISABLE_SIGNUP=false
# Per-method switches (optional). Each disables brand-new account creation through that method.
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=true
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=true
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
```
@@ -252,7 +258,10 @@ Navigate to the signup page and create your account. Verify your email address
<Callout type="info">
All accounts created through signup are regular user accounts. Admin access must be granted
directly in the database. Once your accounts are set up, consider disabling public signups by
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`. For finer control, use the per-method switches
`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`, `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`,
`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`, `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`, or restrict
signups to specific email domains with
`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`.
</Callout>
@@ -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:
@@ -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">
@@ -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:
+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"
}
+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",
@@ -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,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>
);
};
@@ -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>
+21 -13
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,8 +370,6 @@ export const SignInForm = ({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
@@ -499,8 +509,6 @@ export const SignInForm = ({
<Turnstile
ref={twoFactorTurnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
@@ -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>
+106 -83
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';
@@ -58,18 +58,20 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
export type SignUpFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
isEmailPasswordSignupEnabled?: boolean;
isGoogleSignupEnabled?: boolean;
isMicrosoftSignupEnabled?: boolean;
isOidcSignupEnabled?: boolean;
returnTo?: string;
};
export const SignUpForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isEmailPasswordSignupEnabled = true,
isGoogleSignupEnabled,
isMicrosoftSignupEnabled,
isOidcSignupEnabled,
returnTo,
}: SignUpFormProps) => {
const { _ } = useLingui();
@@ -84,9 +86,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 +103,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 +154,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 +172,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 +184,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,79 +250,85 @@ 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',
@@ -325,7 +346,7 @@ export const SignUpForm = ({
</div>
)}
{isGoogleSSOEnabled && (
{isGoogleSignupEnabled && (
<Button
type="button"
size="lg"
@@ -339,7 +360,7 @@ export const SignUpForm = ({
</Button>
)}
{isMicrosoftSSOEnabled && (
{isMicrosoftSignupEnabled && (
<Button
type="button"
size="lg"
@@ -353,7 +374,7 @@ export const SignUpForm = ({
</Button>
)}
{isOIDCSSOEnabled && (
{isOidcSignupEnabled && (
<Button
type="button"
size="lg"
@@ -377,9 +398,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,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: 'interaction-only',
}}
/>
</div>
)}
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
<Trans>Claim account</Trans>
</Button>
@@ -140,6 +140,15 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => msg`You have reached the limit of the number of files per envelope.`,
)
.with('UNSUPPORTED_FILE_TYPE', () => msg`This file type isn't supported. Please upload a PDF or Word document.`)
.with(
'CONVERSION_SERVICE_UNAVAILABLE',
() => msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
)
.with(
'CONVERSION_FAILED',
() => msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
)
.otherwise(() => msg`An error occurred while uploading your document.`);
toast({
@@ -3,6 +3,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
@@ -115,6 +116,15 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
.with(
'CONVERSION_SERVICE_UNAVAILABLE',
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
)
.with(
'CONVERSION_FAILED',
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
)
.otherwise(() => t`An error occurred during upload.`);
toast({
@@ -158,9 +168,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
accept: getAllowedUploadMimeTypes(),
multiple: true,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
maxFiles: maximumEnvelopeItemCount,
@@ -183,7 +191,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
</h2>
<p className="mt-4 text-md text-muted-foreground">
<Trans>Drag and drop your PDF file here</Trans>
<Trans>Drag and drop your document here</Trans>
</p>
{isUploadDisabled && IS_BILLING_ENABLED() && (
@@ -119,6 +119,15 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
.with(
'CONVERSION_SERVICE_UNAVAILABLE',
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
)
.with(
'CONVERSION_FAILED',
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
)
.otherwise(() => t`An error occurred while uploading your document.`);
toast({
@@ -25,7 +25,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
</div>
)}
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">{title}</h3>
<h3 className="mb-2 flex items-end font-medium text-sm leading-tight">{title}</h3>
</div>
{children || (
@@ -1,8 +1,10 @@
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { MinusIcon, PlusIcon, RotateCcwIcon } from 'lucide-react';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
@@ -27,6 +29,10 @@ type LoadingState = 'loading' | 'loaded' | 'error';
const LOW_RENDER_RESOLUTION = 1;
const HIGH_RENDER_RESOLUTION = 2;
const IDLE_RENDER_DELAY = 200;
const DEFAULT_ZOOM = 1;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2;
const ZOOM_STEP = 0.25;
export type PDFViewerProps = {
className?: string;
@@ -72,6 +78,22 @@ export default function PDFViewer({
const $el = useRef<HTMLDivElement>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const canZoomOut = zoom > MIN_ZOOM;
const canZoomIn = zoom < MAX_ZOOM;
const zoomOut = () => {
setZoom((currentZoom) => Math.max(MIN_ZOOM, currentZoom - ZOOM_STEP));
};
const resetZoom = () => {
setZoom(DEFAULT_ZOOM);
};
const zoomIn = () => {
setZoom((currentZoom) => Math.min(MAX_ZOOM, currentZoom + ZOOM_STEP));
};
const pdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
@@ -88,6 +110,7 @@ export default function PDFViewer({
try {
setLoadingState('loading');
setPages([]);
setZoom(DEFAULT_ZOOM);
if (isCancelled) {
return;
@@ -109,7 +132,11 @@ export default function PDFViewer({
return;
}
const loadedPdf = await pdfjsLib.getDocument({ data: result!, cMapUrl: '/static/cmaps/' }).promise;
if (!result) {
throw new Error('Failed to load PDF data');
}
const loadedPdf = await pdfjsLib.getDocument({ data: result, cMapUrl: '/static/cmaps/' }).promise;
if (isCancelled) {
await loadedPdf.destroy();
@@ -191,13 +218,57 @@ export default function PDFViewer({
}
return (
<div ref={$el} className={cn('h-full w-full', className)} {...props}>
<div ref={$el} className={cn('h-full w-full overflow-x-auto', className)} {...props}>
{/* Loading State */}
{isLoading && <PdfViewerLoadingState />}
{/* Error State */}
{hasError && <PdfViewerErrorState />}
{loadingState === 'loaded' && (
<div className="sticky top-2 right-2 z-20 ml-auto flex w-fit items-center gap-1 rounded-md border bg-background/95 p-1 shadow-sm">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0"
disabled={!canZoomOut}
aria-label={t`Zoom out`}
onClick={zoomOut}
>
<MinusIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Zoom out</Trans>
</span>
</Button>
<Button
type="button"
variant="ghost"
className="h-8 min-w-12 px-2 font-medium text-xs tabular-nums"
disabled={zoom === DEFAULT_ZOOM}
aria-label={t`Reset zoom`}
onClick={resetZoom}
>
<RotateCcwIcon className="mr-1 h-3.5 w-3.5" />
{Math.round(zoom * 100)}%
</Button>
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0"
disabled={!canZoomIn}
aria-label={t`Zoom in`}
onClick={zoomIn}
>
<PlusIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Zoom in</Trans>
</span>
</Button>
</div>
)}
{/* Loaded State */}
{loadingState === 'loaded' && pages.length > 0 && pdfRef.current && (
<VirtualizedPageList
@@ -206,6 +277,7 @@ export default function PDFViewer({
numPages={pages.length}
pages={pages}
pdf={pdfRef.current}
zoom={zoom}
customPageRenderer={customPageRenderer}
/>
)}
@@ -220,6 +292,7 @@ type VirtualizedPageListProps = {
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
zoom: number;
};
const VirtualizedPageList = ({
@@ -229,6 +302,7 @@ const VirtualizedPageList = ({
numPages,
pdf,
customPageRenderer,
zoom,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
@@ -240,9 +314,9 @@ const VirtualizedPageList = ({
itemSize: (index, width) => {
const pageMeta = pages[index];
// Calculate height based on aspect ratio and available width
// Calculate height based on aspect ratio and zoomed width
const aspectRatio = pageMeta.height / pageMeta.width;
const scaledHeight = width * aspectRatio;
const scaledHeight = width * zoom * aspectRatio;
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
// Add additional 2px for the top and bottom borders.
@@ -261,7 +335,7 @@ const VirtualizedPageList = ({
data-page-count={numPages}
style={{
height: `${totalSize}px`,
width: '100%',
width: `${Math.max(constraintWidth, constraintWidth * zoom)}px`,
position: 'relative',
}}
>
@@ -270,20 +344,22 @@ const VirtualizedPageList = ({
const pageMeta = pages[index];
const pageNumber = index + 1;
// Calculate scale based on constraint width
const scale = constraintWidth / pageMeta.width;
// Calculate scale based on fit-to-width plus viewer zoom
const pageDisplayWidth = constraintWidth * zoom;
const scale = pageDisplayWidth / pageMeta.width;
const scaledWidth = Math.floor(pageMeta.width * scale);
const scaledHeight = Math.floor(pageMeta.height * scale);
return (
<div
className="flex flex-col items-center"
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
width: Math.max(constraintWidth, scaledWidth),
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
@@ -373,6 +449,7 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const renderedResolutionRef = useRef<number | null>(null);
const renderedScaleRef = useRef<number | null>(null);
const renderedPageNumberRef = useRef<number | null>(null);
const renderedPdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
@@ -392,7 +469,8 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
return (
renderedPdfRef.current === pdf &&
renderedPageNumberRef.current === pageNumber &&
renderedResolutionRef.current === resolution
renderedResolutionRef.current === resolution &&
renderedScaleRef.current === scale
);
};
@@ -400,6 +478,7 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
renderedPdfRef.current = pdf;
renderedPageNumberRef.current = pageNumber;
renderedResolutionRef.current = resolution;
renderedScaleRef.current = scale;
};
const renderAtResolution = async (resolution: number) => {
@@ -0,0 +1,90 @@
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
import { useEffect } from 'react';
import { toNativeCssVarsString } from '~/utils/css-vars';
export type RecipientBrandingPayload = {
allowCustomBranding: boolean;
colors?: TCssVarsSchema | null;
css?: string | null;
};
export type RecipientBrandingProps = {
branding: RecipientBrandingPayload | null | undefined;
cspNonce: string | undefined;
};
/**
* Renders a `<style nonce>` block for a recipient route, scoped to the
* `.documenso-branded` wrapper rendered in `_recipient+/_layout.tsx`.
*
* Both the CSS variables (from `branding.colors`) and the user's custom CSS
* (from `branding.css`) are emitted inside a single nested rule so the user
* doesn't need to scope their own selectors — native CSS nesting handles it:
*
* .documenso-branded {
* --background: ...;
* .my-class { color: red; }
* }
*
* Equivalent to `.documenso-branded .my-class { color: red; }` after expansion.
*
* The user's CSS is sanitised at write time (`sanitizeBrandingCss`) and stored
* in the DB as-is — no per-render parsing.
*
* Why both SSR `<style>` and a `useEffect` injection?
*
* The rendered `<style>` covers the initial server render so the first paint
* already has the branding applied — without it, the page would flash the
* default theme before hydration.
*
* The `useEffect` covers in-app client-side navigations. When the user
* navigates between recipient routes via the router, the server render
* doesn't run again, so React reconciles the existing DOM. If the loader
* data changes (e.g. a different recipient with different branding), the
* SSR'd `<style>` from the previous page may persist or be reused, leading
* to stale or inconsistent branding. Appending a fresh `<style>` to
* `document.head` and removing it on cleanup guarantees the active branding
* matches the current route on both initial load and subsequent navigations.
*/
export const RecipientBranding = ({ branding, cspNonce }: RecipientBrandingProps) => {
const varsString = toNativeCssVarsString(branding?.colors ?? {});
const userCss = branding?.css ?? '';
const hasVars = varsString.trim().length > 0;
const hasUserCss = userCss.trim().length > 0;
const innerBody = `${hasVars ? `${varsString}\n` : ''}${hasUserCss ? userCss : ''}`.trim();
const css = `.documenso-branded { ${innerBody} }`;
useEffect(() => {
if (!branding?.allowCustomBranding) {
return;
}
if (!hasVars && !hasUserCss) {
return;
}
const style = document.createElement('style');
style.setAttribute('nonce', cspNonce ?? '');
style.textContent = css;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, [branding, cspNonce, css, hasUserCss, hasVars]);
if (!branding?.allowCustomBranding) {
return null;
}
if (!hasVars && !hasUserCss) {
return null;
}
return <style nonce={cspNonce}>{css}</style>;
};
+5 -4
View File
@@ -3,7 +3,6 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react';
import posthog from 'posthog-js';
import { StrictMode, startTransition, useEffect } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
@@ -15,9 +14,11 @@ function PosthogInit() {
useEffect(() => {
if (postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
});
}
}, []);
+9 -1
View File
@@ -17,6 +17,7 @@ import {
Scripts,
ScrollRestoration,
useLoaderData,
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
@@ -110,6 +111,13 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const [theme] = useTheme();
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
// <style> block from `RecipientBranding` applies to BOTH the main tree and
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
// route tree, attached directly to document.body).
const matches = useMatches();
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
return (
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
<head>
@@ -137,7 +145,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
<script nonce={nonce(cspNonce)}>0</script>
</head>
<body>
<body className={isRecipientRoute ? 'documenso-branded' : undefined}>
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
{/* {licenseStatus === '?' && (
<div className="bg-destructive text-destructive-foreground">
@@ -37,6 +37,7 @@ import { Link, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog';
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -64,9 +65,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
const organisationId = params.id;
const { data: organisation, isLoading: isLoadingOrganisation } = trpc.admin.organisation.get.useQuery({
organisationId,
});
const { data: organisation, isLoading: isLoadingOrganisation } = trpc.admin.organisation.get.useQuery(
{
organisationId,
},
{
retry: false,
},
);
const { mutateAsync: createStripeCustomer, isPending: isCreatingStripeCustomer } =
trpc.admin.stripe.createCustomer.useMutation({
@@ -398,6 +404,31 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
</div>
</div>
</div>
<SettingsHeader
title={t`Danger Zone`}
subtitle={t`Irreversible actions for this organisation`}
className="mt-16"
/>
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="destructive">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Delete organisation</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible
via the deleted-account service account.
</Trans>
</AlertDescription>
</div>
<div>
<AdminOrganisationDeleteDialog organisationId={organisation.id} organisationName={organisation.name} />
</div>
</Alert>
</div>
);
}
@@ -27,7 +27,7 @@ import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
@@ -45,6 +45,8 @@ export async function loader() {
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
@@ -142,7 +144,7 @@ export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
<FormControl>
<div>
<ColorPicker {...field} />
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
@@ -162,7 +164,7 @@ export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
<FormControl>
<div>
<ColorPicker {...field} />
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
@@ -1,6 +1,7 @@
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
import { Trans } from '@lingui/react/macro';
import { AdminUserCreateDialog } from '~/components/dialogs/admin-user-create-dialog';
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
import type { Route } from './+types/users._index';
@@ -27,9 +28,13 @@ export default function AdminManageUsersPage({ loaderData }: Route.ComponentProp
return (
<div>
<h2 className="font-semibold text-4xl">
<Trans>Manage users</Trans>
</h2>
<div className="mb-6 flex items-center justify-between">
<h2 className="font-semibold text-4xl">
<Trans>Manage users</Trans>
</h2>
<AdminUserCreateDialog />
</div>
<AdminDashboardUsersTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
</div>
@@ -3,13 +3,15 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import {
@@ -35,7 +37,13 @@ export default function OrganisationSettingsBrandingPage() {
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
const {
data: organisationWithSettings,
isLoading: isLoadingOrganisation,
refetch: refetchOrganisation,
} = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
@@ -43,7 +51,7 @@ export default function OrganisationSettingsBrandingPage() {
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined;
@@ -56,20 +64,40 @@ export default function OrganisationSettingsBrandingPage() {
uploadedBrandingLogo = '';
}
await updateOrganisationSettings({
const result = await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
brandingCss,
},
});
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
// Refetch so the form re-syncs with the sanitised CSS that was
// actually persisted (sanitiser may have dropped rules).
await refetchOrganisation();
const warnings = result?.cssWarnings ?? [];
setCssWarnings(warnings);
if (warnings.length > 0) {
toast({
title: t`Branding preferences updated with warnings`,
description: plural(warnings.length, {
one: '# CSS rule was dropped during sanitisation.',
other: '# CSS rules were dropped during sanitisation.',
}),
duration: 8000,
});
} else {
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
}
} catch (err) {
toast({
title: t`Something went wrong`,
@@ -103,9 +131,36 @@ export default function OrganisationSettingsBrandingPage() {
<section>
<BrandingPreferencesForm
context="Organisation"
hasAdvancedBranding={
organisationWithSettings.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED()
}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</section>
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
@@ -1,9 +1,14 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useState } from 'react';
import {
BrandingPreferencesForm,
@@ -11,27 +16,32 @@ import {
} from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Branding Preferences`);
}
export default function TeamsSettingsPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
const {
data: teamWithSettings,
isLoading: isLoadingTeam,
refetch: refetchTeam,
} = trpc.team.get.useQuery({
teamReference: team.id,
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const canCustomBranding =
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined;
@@ -44,20 +54,40 @@ export default function TeamsSettingsPage() {
uploadedBrandingLogo = '';
}
await updateTeamSettings({
const result = await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
brandingCss,
},
});
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
// Refetch so the form re-syncs with the sanitised CSS that was
// actually persisted (sanitiser may have dropped rules).
await refetchTeam();
const warnings = result?.cssWarnings ?? [];
setCssWarnings(warnings);
if (warnings.length > 0) {
toast({
title: t`Branding preferences updated with warnings`,
description: plural(warnings.length, {
one: '# CSS rule was dropped during sanitisation.',
other: '# CSS rules were dropped during sanitisation.',
}),
duration: 8000,
});
} else {
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
}
} catch (err) {
toast({
title: t`Something went wrong`,
@@ -85,10 +115,35 @@ export default function TeamsSettingsPage() {
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</section>
</div>
);
@@ -1,15 +1,22 @@
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/_layout';
export function meta() {
return [
{ title: i18n._(msg`Sign Document - Documenso`) },
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet, noimageindex' },
];
}
/**
* A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not.
@@ -2,6 +2,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
@@ -20,6 +21,8 @@ import { DocumentSigningAuthProvider } from '~/components/general/document-signi
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
@@ -125,6 +128,7 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
},
select: {
internalVersion: true,
teamId: true,
},
});
@@ -132,12 +136,17 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({
teamId: directEnvelope.teamId,
});
if (directEnvelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({
version: 2,
payload: payloadV2,
branding,
} as const);
}
@@ -146,17 +155,20 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
return superLoaderJson({
version: 1,
payload: payloadV1,
branding,
} as const);
}
export default function DirectTemplatePage() {
const data = useSuperLoaderData<typeof loader>();
const cspNonce = useCspNonce();
if (data.version === 2) {
return <DirectSigningPageV2 data={data.payload} />;
}
return <DirectSigningPageV1 data={data.payload} />;
return (
<>
<RecipientBranding branding={data.branding} cspNonce={cspNonce} />
{data.version === 2 ? <DirectSigningPageV2 data={data.payload} /> : <DirectSigningPageV1 data={data.payload} />}
</>
);
}
const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
@@ -3,6 +3,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
@@ -35,6 +36,8 @@ import { DocumentSigningPageViewV1 } from '~/components/general/document-signing
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
@@ -272,6 +275,7 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
envelope: {
select: {
internalVersion: true,
teamId: true,
},
},
},
@@ -281,12 +285,17 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({
teamId: foundRecipient.envelope.teamId,
});
if (foundRecipient.envelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({
version: 2,
payload: payloadV2,
branding,
} as const);
}
@@ -295,17 +304,20 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
return superLoaderJson({
version: 1,
payload: payloadV1,
branding,
} as const);
}
export default function SigningPage() {
const data = useSuperLoaderData<typeof loader>();
const cspNonce = useCspNonce();
if (data.version === 2) {
return <SigningPageV2 data={data.payload} />;
}
return <SigningPageV1 data={data.payload} />;
return (
<>
<RecipientBranding branding={data.branding} cspNonce={cspNonce} />
{data.version === 2 ? <SigningPageV2 data={data.payload} /> : <SigningPageV1 data={data.payload} />}
</>
);
}
const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
@@ -1,6 +1,8 @@
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
@@ -8,7 +10,6 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env';
import { trpc } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@@ -25,6 +26,8 @@ import { match } from 'ts-pattern';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { ClaimAccount } from '~/components/general/claim-account';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/complete';
@@ -46,6 +49,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
@@ -66,6 +71,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return {
isDocumentAccessValid: false,
recipientEmail: recipient.email,
branding,
} as const;
}
@@ -77,7 +83,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const recipientName =
recipient.name || fields.find((field) => field.type === FieldType.NAME)?.customText || recipient.email;
const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true';
const canSignUp = !isExistingUser && isSignupEnabledForProvider('email');
const canRedirectToFolder = user && document.userId === user.id && document.folderId && document.team?.url;
@@ -92,6 +98,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
document,
recipient,
returnToHomePath,
branding,
};
}
@@ -100,6 +107,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const cspNonce = useCspNonce();
const {
isDocumentAccessValid,
@@ -110,6 +118,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
recipient,
recipientEmail,
returnToHomePath,
branding,
} = loaderData;
// Poll signing status every few seconds
@@ -131,154 +140,163 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
const signingStatus = signingStatusData?.status ?? 'PENDING';
if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientEmail} />;
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<DocumentSigningAuthPageView email={recipientEmail} />
</>
);
}
return (
<div
className={cn('-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28', {
'pt-0 lg:pt-0 xl:pt-0': canSignUp,
})}
>
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
canSignUp,
})}
className={cn(
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)}
>
<div
className={cn('flex flex-col items-center', {
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
canSignUp,
})}
>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{document.title}
</span>
</Badge>
<div
className={cn('flex flex-col items-center', {
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
})}
>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{document.title}
</span>
</Badge>
{/* Card with recipient */}
<SigningCard3D
name={recipientName}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
{recipient.role === RecipientRole.SIGNER && <Trans>Document Signed</Trans>}
{recipient.role === RecipientRole.VIEWER && <Trans>Document Viewed</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
</h2>
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<div className="mt-4 flex items-center text-center text-documenso-700">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Everyone has signed</Trans>
</span>
</div>
))
.with({ status: 'PROCESSING' }, () => (
<div className="mt-4 flex items-center text-center text-orange-600">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span className="text-sm">
<Trans>Processing document</Trans>
</span>
</div>
))
.with({ deletedAt: null }, () => (
<div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Waiting for others to sign</Trans>
</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Document no longer available to sign</Trans>
</span>
</div>
))}
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Everyone has signed! You will receive an email copy of the signed document.</Trans>
</p>
))
.with({ status: 'PROCESSING' }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>
All recipients have signed. The document is being processed and you will receive an email copy
shortly.
</Trans>
</p>
))
.with({ deletedAt: null }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>You will receive an email copy of the signed document once everyone has signed.</Trans>
</p>
))
.otherwise(() => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>
This document has been cancelled by the owner and is no longer available for others to sign.
</Trans>
</p>
))}
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
<DocumentShareButton
documentId={document.id}
token={recipient.token}
className="w-full max-w-none md:flex-1"
{/* Card with recipient */}
<SigningCard3D
name={recipientName}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
{isDocumentCompleted(document) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
token={recipient?.token}
trigger={
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
)}
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
{recipient.role === RecipientRole.SIGNER && <Trans>Document Signed</Trans>}
{recipient.role === RecipientRole.VIEWER && <Trans>Document Viewed</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
</h2>
{user && (
<Button asChild>
<Link to={returnToHomePath}>
<Trans>Go Back Home</Trans>
</Link>
</Button>
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<div className="mt-4 flex items-center text-center text-documenso-700">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Everyone has signed</Trans>
</span>
</div>
))
.with({ status: 'PROCESSING' }, () => (
<div className="mt-4 flex items-center text-center text-orange-600">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span className="text-sm">
<Trans>Processing document</Trans>
</span>
</div>
))
.with({ deletedAt: null }, () => (
<div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Waiting for others to sign</Trans>
</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Document no longer available to sign</Trans>
</span>
</div>
))}
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Everyone has signed! You will receive an email copy of the signed document.</Trans>
</p>
))
.with({ status: 'PROCESSING' }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>
All recipients have signed. The document is being processed and you will receive an email copy
shortly.
</Trans>
</p>
))
.with({ deletedAt: null }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>You will receive an email copy of the signed document once everyone has signed.</Trans>
</p>
))
.otherwise(() => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>
This document has been cancelled by the owner and is no longer available for others to sign.
</Trans>
</p>
))}
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
<DocumentShareButton
documentId={document.id}
token={recipient.token}
className="w-full max-w-none md:flex-1"
/>
{isDocumentCompleted(document) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
token={recipient?.token}
trigger={
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
)}
{user && (
<Button asChild>
<Link to={returnToHomePath}>
<Trans>Go Back Home</Trans>
</Link>
</Button>
)}
</div>
</div>
<div className="flex flex-col items-center">
{canSignUp && (
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
<h2 className="mt-8 text-center font-semibold text-xl md:mt-0">
<Trans>Need to sign documents?</Trans>
</h2>
<p className="mt-4 max-w-[55ch] text-center text-muted-foreground/60 leading-normal">
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
</div>
</div>
<div className="flex flex-col items-center">
{canSignUp && (
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
<h2 className="mt-8 text-center font-semibold text-xl md:mt-0">
<Trans>Need to sign documents?</Trans>
</h2>
<p className="mt-4 max-w-[55ch] text-center text-muted-foreground/60 leading-normal">
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
</div>
</div>
</div>
</>
);
}
@@ -1,5 +1,6 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -10,6 +11,8 @@ import { TimerOffIcon } from 'lucide-react';
import { Link } from 'react-router';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/expired';
@@ -32,6 +35,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
const title = document.title;
const recipient = await getRecipientByToken({ token }).catch(() => null);
@@ -54,55 +59,66 @@ export async function loader({ params, request }: Route.LoaderArgs) {
isDocumentAccessValid: true,
recipientEmail,
title,
branding,
};
}
return {
isDocumentAccessValid: false,
recipientEmail,
branding,
};
}
export default function ExpiredSigningPage({ loaderData }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const cspNonce = useCspNonce();
const { isDocumentAccessValid, recipientEmail, title } = loaderData;
const { isDocumentAccessValid, recipientEmail, title, branding } = loaderData;
if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientEmail} />;
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<DocumentSigningAuthPageView email={recipientEmail} />
</>
);
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" title={title} className="mb-6 rounded-xl border bg-transparent">
{truncateTitle(title ?? '')}
</Badge>
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" title={title} className="mb-6 rounded-xl border bg-transparent">
{truncateTitle(title ?? '')}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<TimerOffIcon className="h-10 w-10 text-orange-500" />
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<TimerOffIcon className="h-10 w-10 text-orange-500" />
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing Deadline Expired</Trans>
</h2>
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing Deadline Expired</Trans>
</h2>
</div>
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The signing deadline for this document has passed. Please contact the document owner if you need a new
copy to sign.
</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The signing deadline for this document has passed. Please contact the document owner if you need a new copy
to sign.
</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
</div>
</>
);
}
@@ -1,5 +1,6 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
@@ -12,6 +13,8 @@ import { XCircle } from 'lucide-react';
import { Link } from 'react-router';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/rejected';
@@ -34,6 +37,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([
@@ -60,6 +65,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
isDocumentAccessValid: true,
recipientReference,
truncatedTitle,
branding,
};
}
@@ -67,57 +73,67 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return {
isDocumentAccessValid: false,
recipientReference,
branding,
};
}
export default function RejectedSigningPage({ loaderData }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const cspNonce = useCspNonce();
const { isDocumentAccessValid, recipientReference, truncatedTitle } = loaderData;
const { isDocumentAccessValid, recipientReference, truncatedTitle, branding } = loaderData;
if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientReference} />;
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<DocumentSigningAuthPageView email={recipientReference} />
</>
);
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="h-10 w-10 text-destructive" />
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="h-10 w-10 text-destructive" />
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="mt-4 flex items-center text-center text-destructive text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further instructions if
necessary.
</Trans>
</p>
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
<div className="mt-4 flex items-center text-center text-destructive text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further instructions if
necessary.
</Trans>
</p>
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
</div>
</>
);
}
@@ -1,4 +1,5 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -10,6 +11,9 @@ import type { Team } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { Link, redirect } from 'react-router';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/waiting';
export async function loader({ params, request }: Route.LoaderArgs) {
@@ -61,48 +65,55 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const documentPathForEditing = isOwnerOrTeamMember && team ? formatDocumentsPath(team.url) + '/' + document.id : null;
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
return {
documentPathForEditing,
branding,
};
}
export default function WaitingForTurnToSignPage({ loaderData }: Route.ComponentProps) {
const { documentPathForEditing } = loaderData;
const { documentPathForEditing, branding } = loaderData;
const cspNonce = useCspNonce();
return (
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md text-center">
<h2 className="font-bold text-3xl tracking-tigh">
<Trans>Waiting for Your Turn</Trans>
</h2>
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md text-center">
<h2 className="font-bold text-3xl tracking-tigh">
<Trans>Waiting for Your Turn</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
It's currently not your turn to sign. You will receive an email with instructions once it's your turn to
sign the document.
</Trans>
</p>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
It's currently not your turn to sign. You will receive an email with instructions once it's your turn to
sign the document.
</Trans>
</p>
<p className="mt-4 text-muted-foreground text-sm">
<Trans>Please check your email for updates.</Trans>
</p>
<p className="mt-4 text-muted-foreground text-sm">
<Trans>Please check your email for updates.</Trans>
</p>
<div className="mt-4">
{documentPathForEditing ? (
<Button variant="link" asChild>
<Link to={documentPathForEditing}>
<Trans>Were you trying to edit this document instead?</Trans>
</Link>
</Button>
) : (
<Button variant="link" asChild>
<Link to="/">
<Trans>Return Home</Trans>
</Link>
</Button>
)}
<div className="mt-4">
{documentPathForEditing ? (
<Button variant="link" asChild>
<Link to={documentPathForEditing}>
<Trans>Were you trying to edit this document instead?</Trans>
</Link>
</Button>
) : (
<Button variant="link" asChild>
<Link to="/">
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
</div>
</div>
</div>
</>
);
}
@@ -3,9 +3,9 @@ import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
isSignupEnabledForProvider,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { msg } from '@lingui/core/macro';
@@ -32,6 +32,11 @@ export async function loader({ request }: Route.LoaderArgs) {
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
const isSignupEnabled =
isSignupEnabledForProvider('email') ||
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
(IS_MICROSOFT_SSO_ENABLED && isSignupEnabledForProvider('microsoft')) ||
(IS_OIDC_SSO_ENABLED && isSignupEnabledForProvider('oidc'));
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
@@ -45,13 +50,15 @@ export async function loader({ request }: Route.LoaderArgs) {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
oidcProviderLabel,
returnTo,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel, returnTo } = loaderData;
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } =
loaderData;
const { _ } = useLingui();
@@ -95,7 +102,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
returnTo={returnTo}
/>
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
{!isEmbeddedRedirect && isSignupEnabled && (
<p className="mt-6 text-center text-muted-foreground text-sm">
<Trans>
Don't have an account?{' '}
@@ -1,5 +1,9 @@
import { IS_GOOGLE_SSO_ENABLED, IS_MICROSOFT_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { msg } from '@lingui/core/macro';
import { redirect } from 'react-router';
@@ -14,14 +18,15 @@ export function meta() {
}
export function loader({ request }: Route.LoaderArgs) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
const isEmailPasswordSignupEnabled = isSignupEnabledForProvider('email');
const isGoogleSignupEnabled = IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google');
const isMicrosoftSignupEnabled = IS_MICROSOFT_SSO_ENABLED && isSignupEnabledForProvider('microsoft');
const isOidcSignupEnabled = IS_OIDC_SSO_ENABLED && isSignupEnabledForProvider('oidc');
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const isAnySignupEnabled =
isEmailPasswordSignupEnabled || isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
if (!isAnySignupEnabled) {
throw redirect('/signin');
}
@@ -30,22 +35,30 @@ export function loader({ request }: Route.LoaderArgs) {
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
return {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isEmailPasswordSignupEnabled,
isGoogleSignupEnabled,
isMicrosoftSignupEnabled,
isOidcSignupEnabled,
returnTo,
};
}
export default function SignUp({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
const {
isEmailPasswordSignupEnabled,
isGoogleSignupEnabled,
isMicrosoftSignupEnabled,
isOidcSignupEnabled,
returnTo,
} = loaderData;
return (
<SignUpForm
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
isEmailPasswordSignupEnabled={isEmailPasswordSignupEnabled}
isGoogleSignupEnabled={isGoogleSignupEnabled}
isMicrosoftSignupEnabled={isMicrosoftSignupEnabled}
isOidcSignupEnabled={isOidcSignupEnabled}
returnTo={returnTo}
/>
);
+32 -5
View File
@@ -1,4 +1,4 @@
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
import { CSS_LENGTH_REGEX, type TCssVarsSchema } from '@documenso/lib/types/css-vars';
import { colord } from 'colord';
import { toKebabCase } from 'remeda';
@@ -12,23 +12,50 @@ export const toNativeCssVars = (vars: TCssVarsSchema) => {
const color = colord(value);
const { h, s, l } = color.toHsl();
cssVars[`--${toKebabCase(key)}`] = `${h} ${s} ${l}`;
// Tailwind's theme.css consumes these via `hsl(var(--token))`. CSS
// Color 4 space-separated `hsl()` requires `%` on saturation and
// lightness — without it, the function is invalid and the property
// falls back to its initial value (which is why bare numeric output
// here used to silently break customer colours).
cssVars[`--${toKebabCase(key)}`] = `${h} ${s}% ${l}%`;
}
}
if (radius) {
cssVars[`--radius`] = `${radius}`;
// Defence in depth: radius is interpolated raw into the rendered <style>
// block, so anything outside the length pattern is a CSS-injection vector.
// The Zod schema rejects bad values at the API boundary; this re-check
// protects against schema drift and any path that bypasses validation.
if (radius && CSS_LENGTH_REGEX.test(radius)) {
cssVars[`--radius`] = radius;
}
return cssVars;
};
/**
* Pure-string sibling of `toNativeCssVars` — returns the same set of CSS custom
* property declarations as a single string suitable for SSR inlining inside a
* rule block. Does not touch the DOM.
*
* Example: { background: '#111', radius: '0.5rem' }
* -> "--background: 0 0% 6.7%; --radius: 0.5rem;"
*
* Saturation and lightness include the `%` suffix that
* `hsl(var(--token))` requires under CSS Color 4 space-separated syntax.
*/
export const toNativeCssVarsString = (vars: TCssVarsSchema): string => {
const map = toNativeCssVars(vars);
return Object.entries(map)
.map(([k, v]) => `${k}: ${v};`)
.join(' ');
};
export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => {
const { css, cssVars } = options;
if (css) {
const style = document.createElement('style');
style.innerHTML = css;
style.textContent = css;
document.head.appendChild(style);
}
+17
View File
@@ -1,3 +1,5 @@
import { useRouteLoaderData } from 'react-router';
/**
* Returns the supplied CSP nonce only when rendering on the server.
*
@@ -19,3 +21,18 @@
* scripts inherit trust via `'strict-dynamic'`.
*/
export const nonce = (value: string | undefined): string | undefined => (typeof window === 'undefined' ? value : '');
/**
* Reads the per-request CSP nonce surfaced by the root loader. Use this
* inside any non-root route component that needs to render a `<style>`,
* `<script>`, or other element that the CSP gates by nonce.
*
* Centralised here so the cast is in one place — if the root loader's
* `nonce` field is ever renamed/removed, only this function needs updating
* (and TypeScript will catch it at the cast site).
*/
export const useCspNonce = (): string | undefined => {
const rootData = useRouteLoaderData('root') as { nonce?: string } | undefined;
return rootData?.nonce;
};
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.10.1"
"version": "2.11.0"
}
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Disallow: /sign/
Disallow: /d/
Disallow: /embed/
+5 -254
View File
@@ -1,257 +1,8 @@
# Docker Setup for Documenso
The following guide will walk you through setting up Documenso using Docker. You can choose between a production setup using Docker Compose or a standalone container.
For full instructions on running Documenso with Docker, see the official documentation:
## Prerequisites
Before you begin, ensure that you have the following installed:
- Docker
- Docker Compose (if using the Docker Compose setup)
## Option 1: Production Docker Compose Setup
This setup includes a PostgreSQL database and the Documenso application. You will need to provide your own SMTP details via environment variables.
1. Download the Docker Compose file from the Documenso repository: [compose.yml](https://raw.githubusercontent.com/documenso/documenso/release/docker/production/compose.yml)
2. Navigate to the directory containing the `compose.yml` file.
3. Create a `.env` file in the same directory and add your SMTP details as well as a few extra environment variables, following the example below:
```
# Generate random secrets (you can use: openssl rand -hex 32)
NEXTAUTH_SECRET="<your-secret>"
NEXT_PRIVATE_ENCRYPTION_KEY="<your-key>"
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-secondary-key>"
# Your application URL
NEXT_PUBLIC_WEBAPP_URL="<your-url>"
# SMTP Configuration
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
NEXT_PRIVATE_SMTP_HOST="<your-host>"
NEXT_PRIVATE_SMTP_PORT=<your-port>
NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
NEXT_PRIVATE_SMTP_FROM_NAME="<your-from-name>"
NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-from-email>"
# Certificate passphrase (required)
NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
```
4. Set up your signing certificate. You have three options:
**Option A: Generate Certificate Inside Container (Recommended)**
Start your containers first, then generate a self-signed certificate:
```bash
# Start containers
docker-compose up -d
# Set certificate password securely (won't appear in command history)
read -s -p "Enter certificate password: " CERT_PASS
echo
# Generate certificate inside container using environment variable
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /tmp/private.key \
-out /tmp/certificate.crt \
-subj '/C=US/ST=State/L=City/O=Organization/CN=localhost' && \
openssl pkcs12 -export -out /app/certs/cert.p12 \
-inkey /tmp/private.key -in /tmp/certificate.crt \
-passout env:CERT_PASS && \
rm /tmp/private.key /tmp/certificate.crt
"
# Restart container
docker-compose restart documenso
```
**Option B: Use Existing Certificate**
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
```yaml
volumes:
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
```
5. Run the following command to start the containers:
```
docker-compose --env-file ./.env up -d
```
This will start the PostgreSQL database and the Documenso application containers.
6. Access the Documenso application by visiting `http://localhost:3000` in your web browser.
## Option 2: Standalone Docker Container
If you prefer to host the Documenso application on your container provider of choice, you can use the pre-built Docker image from DockerHub or GitHub's Package Registry. Note that you will need to provide your own database and SMTP host.
1. Pull the Documenso Docker image:
```
docker pull documenso/documenso
```
Or, if using GitHub's Package Registry:
```
docker pull ghcr.io/documenso/documenso
```
2. Run the Docker container, providing the necessary environment variables for your database and SMTP host:
```
docker run -d \
-p 3000:3000 \
-e NEXTAUTH_SECRET="<your-nextauth-secret>" \
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>" \
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>" \
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>" \
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>" \
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>" \
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>" \
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>" \
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>" \
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>" \
-v /path/to/your/cert.p12:/opt/documenso/cert.p12:ro \
documenso/documenso
```
Replace the placeholders with your actual database and SMTP details.
3. Access the Documenso application by visiting the URL you provided in the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
## Success
You have now successfully set up Documenso using Docker. You can start organizing and managing your documents efficiently.
## Troubleshooting
### Certificate Permission Issues
If you encounter errors related to certificate access, here are common solutions:
#### Error: "Failed to read signing certificate"
1. **Check file exists:**
```bash
ls -la /path/to/your/cert.p12
```
2. **Fix permissions:**
```bash
chmod 644 /path/to/your/cert.p12
chown 1001:1001 /path/to/your/cert.p12
```
3. **Verify Docker mount:**
```bash
docker exec -it <container_name> ls -la /opt/documenso/cert.p12
```
### Container Logs
Check application logs for detailed error information:
```bash
# For Docker Compose
docker-compose logs -f documenso
# For standalone container
docker logs -f <container_name>
```
### Health Checks
Check the status of your Documenso instance:
```bash
# Basic health check (database + certificate)
curl http://localhost:3000/api/health
# Detailed certificate status
curl http://localhost:3000/api/certificate-status
```
The health endpoint will show:
- `status: "ok"` - Everything working properly
- `status: "warning"` - App running but certificate issues
- `status: "error"` - Critical issues (database down, etc.)
### Common Issues
1. **Port already in use:** Change the port mapping in compose.yml or your docker run command
2. **Database connection issues:** Ensure your database is running and accessible
3. **SMTP errors:** Verify your email server settings in the .env file
If you encounter any issues or have further questions, please refer to the official Documenso documentation or seek assistance from the community.
## Advanced Configuration
The environment variables listed above are a subset of those that are available for configuring Documenso. For a complete list of environment variables and their descriptions, refer to the table below:
Here's a markdown table documenting all the provided environment variables:
| Variable | Description |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port to run the Documenso application on, defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded Google Cloud HSM public certificate for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded certificate chain for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing (enables LTV). |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). |
- [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) — Standalone container with an external database
- [Docker Compose Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) — Production setup with PostgreSQL included
- [Environment Variables](https://docs.documenso.com/docs/self-hosting/configuration/environment) — Full configuration reference
- [Signing Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate) — Set up document signing
+36
View File
@@ -0,0 +1,36 @@
FROM gotenberg/gotenberg:8-libreoffice
# Install Microsoft Core Fonts (Arial, Times New Roman, Courier New, Verdana,
# Georgia, Comic Sans MS, Trebuchet MS, Impact, Andale Mono, Webdings) so that
# LibreOffice can render typical Word documents faithfully. The default image
# only ships metric-compatible substitutes (Carlito for Calibri, Liberation for
# Arial/Times/Courier) which preserve layout widths but look noticeably wrong.
#
# `ttf-mscorefonts-installer` lives in the non-free repo and requires accepting
# the Microsoft EULA, which we do non-interactively via debconf-set-selections.
USER root
RUN echo "deb http://deb.debian.org/debian trixie contrib non-free" \
> /etc/apt/sources.list.d/contrib.list \
&& echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" \
| debconf-set-selections \
&& apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
ca-certificates \
wget \
unzip \
culmus \
ttf-mscorefonts-installer \
fonts-symbola \
fonts-noto-extra \
fonts-hosny-amiri \
fonts-thai-tlwg \
fonts-sil-padauk \
fonts-sarai \
fonts-samyak-taml \
libfribidi0 \
libharfbuzz0b \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
USER gotenberg
+46
View File
@@ -48,6 +48,52 @@ services:
entrypoint: sh
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
gotenberg:
build:
context: .
dockerfile: Dockerfile.gotenberg
image: documenso-dev-gotenberg:latest
container_name: gotenberg
restart: unless-stopped
ports:
- 3005:3000
environment:
# Basic auth credentials Gotenberg checks when `--api-enable-basic-auth`
# is passed. Dev defaults are non-secret — match
# `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` / `_PASSWORD` in `.env`.
GOTENBERG_API_BASIC_AUTH_USERNAME: documenso
GOTENBERG_API_BASIC_AUTH_PASSWORD: password
command:
- gotenberg
# Require basic auth on every API route — prevents anyone with network
# access to the container from invoking conversions.
- --api-enable-basic-auth
# SSRF defence in depth: reject any outbound fetch LibreOffice tries to
# make to a private/loopback/link-local/cloud-metadata address while
# processing an uploaded document. Mitigates CVE-2026-42591 (malicious
# docx files embedding `TargetMode="External"` references to internal
# services). Added in Gotenberg 8.32.0.
- --libreoffice-deny-private-ips
# Generous server-side timeout; the Node client aborts at 30 s by
# default, so this is just a safety net.
- --api-timeout=500s
# Pre-warm LibreOffice at boot so the first request isn't cold.
- --libreoffice-auto-start
- --libreoffice-start-timeout=300s
# Disable surfaces we don't use to shrink the attack surface.
- --pdfengines-disable-routes
- --webhook-disable
# Verbose logs for the dev compose only.
- --log-level=debug
healthcheck:
# `/health` is exempt from `--api-enable-basic-auth` so the check
# doesn't need to authenticate.
test: ['CMD', 'curl', '-fsS', 'http://localhost:3000/health']
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
volumes:
minio:
redis:
+4
View File
@@ -59,6 +59,10 @@ services:
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
- NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP}
- NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP}
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
+1357 -1306
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.10.1",
"version": "2.11.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -88,7 +88,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
"@libpdf/core": "^0.3.3",
"@libpdf/core": "^0.3.6",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -0,0 +1,574 @@
import { prisma } from '@documenso/prisma';
import { BackgroundJobStatus, DocumentStatus, EnvelopeType, Role } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
/**
* Helper that polls until the `admin.organisation.delete` background job for the
* supplied organisation has finished (status COMPLETED). Returns the org id.
*/
const waitForOrganisationDeletionJob = async (organisationId: string) => {
await expect
.poll(
async () => {
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.admin-delete-organisation',
// payload is JSON; match the organisationId field.
payload: {
path: ['organisationId'],
equals: organisationId,
},
},
orderBy: { submittedAt: 'desc' },
});
return job?.status ?? null;
},
{
message: `Background deletion job for organisation ${organisationId} did not complete in time`,
timeout: 30_000,
intervals: [250, 500, 1000],
},
)
.toBe(BackgroundJobStatus.COMPLETED);
};
const waitForOrganisationToBeGone = async (organisationId: string) => {
await expect
.poll(
async () => {
const org = await prisma.organisation.findUnique({
where: { id: organisationId },
select: { id: true },
});
return org === null;
},
{
message: `Organisation ${organisationId} was not removed`,
timeout: 30_000,
intervals: [250, 500, 1000],
},
)
.toBe(true);
};
test.describe.configure({ mode: 'parallel' });
// ─── Happy path ──────────────────────────────────────────────────────────────
test('[ADMIN][DELETE_ORG]: admin can delete an organisation via the dialog', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await expect(page.getByRole('heading', { name: 'Danger Zone' })).toBeVisible();
// Open the dialog
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// The Delete submit button is initially enabled but submission should fail
// until the confirmation text matches. Type it now.
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
// The "send email to owner" checkbox should be checked by default.
const emailCheckbox = dialog.getByRole('checkbox');
await expect(emailCheckbox).toBeChecked();
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
// Dialog closes on success
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// Background job completes and the org is removed
await waitForOrganisationDeletionJob(organisation.id);
await waitForOrganisationToBeGone(organisation.id);
});
// ─── Confirmation text validation ────────────────────────────────────────────
test('[ADMIN][DELETE_ORG]: typing the wrong confirmation text prevents deletion', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Type something that does NOT match.
await dialog.getByRole('textbox').fill('delete wrong-name');
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
// Validation message should appear and the dialog should stay open.
await expect(dialog.getByText(/You must enter/)).toBeVisible();
await expect(dialog).toBeVisible();
// Org is still there.
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
});
test('[ADMIN][DELETE_ORG]: empty confirmation text prevents deletion', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(dialog.getByText(/You must enter/)).toBeVisible();
await expect(dialog).toBeVisible();
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
});
// ─── Cancel ──────────────────────────────────────────────────────────────────
test('[ADMIN][DELETE_ORG]: clicking Cancel closes the dialog without deleting', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Fill in the correct text but cancel anyway.
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).not.toBeVisible();
// Org still there.
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
});
// ─── Email checkbox ──────────────────────────────────────────────────────────
test('[ADMIN][DELETE_ORG]: email checkbox can be unchecked, payload reflects choice', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
const emailCheckbox = dialog.getByRole('checkbox');
// Default is checked.
await expect(emailCheckbox).toBeChecked();
// Uncheck it.
await emailCheckbox.click();
await expect(emailCheckbox).not.toBeChecked();
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// Verify the enqueued job payload has sendEmailToOwner=false.
await expect
.poll(
async () => {
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.admin-delete-organisation',
payload: { path: ['organisationId'], equals: organisation.id },
},
});
if (!job) {
return null;
}
const payload = job.payload as { sendEmailToOwner?: boolean };
return payload.sendEmailToOwner;
},
{ timeout: 15_000 },
)
.toBe(false);
await waitForOrganisationDeletionJob(organisation.id);
await waitForOrganisationToBeGone(organisation.id);
});
// ─── Documents are orphaned, not deleted ─────────────────────────────────────
test('[ADMIN][DELETE_ORG]: envelopes authored by owner and members are orphaned, drafts are removed', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
// Add two organisation members who will author their own envelopes.
const [memberUser, managerUser] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [{ organisationRole: 'MEMBER' }, { organisationRole: 'MANAGER' }],
});
// ── Owner-authored envelopes ──────────────────────────────────────────────
const ownerCompleted = await seedBlankDocument(owner, team.id, { key: 'owner-completed' });
await prisma.envelope.update({
where: { id: ownerCompleted.id },
data: { status: DocumentStatus.COMPLETED },
});
const ownerPending = await seedBlankDocument(owner, team.id, { key: 'owner-pending' });
await prisma.envelope.update({
where: { id: ownerPending.id },
data: { status: DocumentStatus.PENDING },
});
const ownerDraft = await seedBlankDocument(owner, team.id, { key: 'owner-draft' });
// ── Member-authored envelopes ─────────────────────────────────────────────
const memberCompleted = await seedBlankDocument(memberUser, team.id, { key: 'member-completed' });
await prisma.envelope.update({
where: { id: memberCompleted.id },
data: { status: DocumentStatus.COMPLETED },
});
const memberPending = await seedBlankDocument(memberUser, team.id, { key: 'member-pending' });
await prisma.envelope.update({
where: { id: memberPending.id },
data: { status: DocumentStatus.PENDING },
});
const memberDraft = await seedBlankDocument(memberUser, team.id, { key: 'member-draft' });
// ── Manager-authored envelope (third author for good measure) ─────────────
const managerRejected = await seedBlankDocument(managerUser, team.id, { key: 'manager-rejected' });
await prisma.envelope.update({
where: { id: managerRejected.id },
data: { status: DocumentStatus.REJECTED },
});
// Sanity check: before deletion all 7 envelopes belong to the team and
// retain their original authors.
const beforeCount = await prisma.envelope.count({ where: { teamId: team.id } });
expect(beforeCount).toBe(7);
// ── Trigger the deletion via the admin UI ─────────────────────────────────
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
await waitForOrganisationDeletionJob(organisation.id);
await waitForOrganisationToBeGone(organisation.id);
// The deleted-account service account is where orphaned envelopes land.
const deletedAccount = await prisma.user.findFirstOrThrow({
where: { email: { startsWith: 'deleted-account@' } },
select: { id: true, ownedOrganisations: { select: { teams: { select: { id: true } } } } },
});
const deletedAccountTeamId = deletedAccount.ownedOrganisations[0].teams[0].id;
// ── Owner-authored envelopes ──────────────────────────────────────────────
// Completed/pending: orphaned (reparented to service account + deletedAt set).
for (const original of [ownerCompleted, ownerPending]) {
const after = await prisma.envelope.findUnique({
where: { id: original.id },
select: { id: true, teamId: true, userId: true, deletedAt: true },
});
expect(after, `owner envelope ${original.id} should survive as orphan`).not.toBeNull();
expect(after?.teamId).toBe(deletedAccountTeamId);
expect(after?.userId).toBe(deletedAccount.id);
expect(after?.deletedAt).not.toBeNull();
}
// Draft: hard-deleted because orphan only re-parents PENDING/REJECTED/COMPLETED.
const ownerDraftAfter = await prisma.envelope.findUnique({
where: { id: ownerDraft.id },
select: { id: true },
});
expect(ownerDraftAfter, 'owner draft should be hard-deleted').toBeNull();
// ── Member-authored envelopes (the critical case) ─────────────────────────
// The orphan logic filters by teamId only — NOT by userId — so member-authored
// envelopes must be orphaned just like the owner's.
for (const original of [memberCompleted, memberPending]) {
const after = await prisma.envelope.findUnique({
where: { id: original.id },
select: { id: true, teamId: true, userId: true, deletedAt: true },
});
expect(after, `member envelope ${original.id} should survive as orphan`).not.toBeNull();
expect(after?.teamId).toBe(deletedAccountTeamId);
expect(after?.userId).toBe(deletedAccount.id);
expect(after?.deletedAt).not.toBeNull();
}
const memberDraftAfter = await prisma.envelope.findUnique({
where: { id: memberDraft.id },
select: { id: true },
});
expect(memberDraftAfter, 'member draft should be hard-deleted').toBeNull();
// ── Manager-authored rejected envelope: also orphaned ─────────────────────
const managerRejectedAfter = await prisma.envelope.findUnique({
where: { id: managerRejected.id },
select: { id: true, teamId: true, userId: true, deletedAt: true },
});
expect(managerRejectedAfter).not.toBeNull();
expect(managerRejectedAfter?.teamId).toBe(deletedAccountTeamId);
expect(managerRejectedAfter?.userId).toBe(deletedAccount.id);
// ── Original team is gone, member users still exist ───────────────────────
const teamAfter = await prisma.team.findUnique({ where: { id: team.id } });
expect(teamAfter).toBeNull();
// No envelope should reference the now-deleted team.
const orphanedToOldTeam = await prisma.envelope.count({ where: { teamId: team.id } });
expect(orphanedToOldTeam).toBe(0);
// The owner and members survive — only the org is deleted, not the users.
const ownerAfter = await prisma.user.findUnique({ where: { id: owner.id } });
const memberAfter = await prisma.user.findUnique({ where: { id: memberUser.id } });
const managerAfter = await prisma.user.findUnique({ where: { id: managerUser.id } });
expect(ownerAfter).not.toBeNull();
expect(memberAfter).not.toBeNull();
expect(managerAfter).not.toBeNull();
});
// ─── Owner can no longer access the deleted organisation ─────────────────────
test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
await waitForOrganisationDeletionJob(organisation.id);
await waitForOrganisationToBeGone(organisation.id);
// Sign in as the original owner and confirm they can no longer reach the
// organisation settings page.
await apiSignout({ page });
await apiSignin({
page,
email: owner.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
// They should NOT see the organisation settings heading for this org.
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
});
// ─── Access control: UI ──────────────────────────────────────────────────────
test('[ADMIN][DELETE_ORG]: non-admin user cannot access /admin/organisations/$id', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: nonAdminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// The admin layout loader redirects non-admins to "/". They must not see the
// admin panel or any Delete affordance.
await expect(page.getByRole('heading', { name: 'Admin Panel' })).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible();
// The org must still exist.
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
});
test('[ADMIN][DELETE_ORG]: unauthenticated user cannot access /admin/organisations/$id', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
// No apiSignin call. Navigate directly.
await page.goto(`/admin/organisations/${organisation.id}`);
// Unauthenticated requests should be redirected away from any /admin/* route.
await expect(page).not.toHaveURL(new RegExp(`/admin/organisations/${organisation.id}`));
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
});
// ─── Belt-and-braces: organisation owner (without admin role) can't use it ──
test('[ADMIN][DELETE_ORG]: an organisation owner without admin role cannot reach the admin delete UI', async ({
page,
}) => {
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
// Confirm the owner is NOT an admin (sanity check on the seed).
expect(owner.roles).not.toContain(Role.ADMIN);
await apiSignin({
page,
email: owner.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible();
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
});
// ─── Org with multiple members triggers email to the OWNER only ─────────────
test('[ADMIN][DELETE_ORG]: job payload targets the organisation owner for the email notification', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
await seedOrganisationMembers({
organisationId: organisation.id,
members: [{ organisationRole: 'MEMBER' }, { organisationRole: 'ADMIN' }, { organisationRole: 'MANAGER' }],
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// The job payload should record the admin who requested the delete and
// sendEmailToOwner=true. (Verifying the actual email send is out of scope
// for this test; we verify the payload only.)
await expect
.poll(
async () => {
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.admin-delete-organisation',
payload: { path: ['organisationId'], equals: organisation.id },
},
});
if (!job) {
return null;
}
const payload = job.payload as {
sendEmailToOwner?: boolean;
requestedByUserId?: number;
};
return payload;
},
{ timeout: 15_000 },
)
.toMatchObject({
sendEmailToOwner: true,
requestedByUserId: adminUser.id,
});
await waitForOrganisationDeletionJob(organisation.id);
await waitForOrganisationToBeGone(organisation.id);
// Owner user record itself is NOT deleted — only the org.
const ownerStillExists = await prisma.user.findUnique({ where: { id: owner.id } });
expect(ownerStillExists).not.toBeNull();
});
// ─── EnvelopeType.TEMPLATE is also cleaned up via orphan flow ───────────────
test('[ADMIN][DELETE_ORG]: template envelopes are removed (not orphaned)', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
// Create a TEMPLATE envelope. orphanEnvelopes only re-parents DOCUMENT
// envelopes; templates fall into the "deleteMany" path.
const draftDoc = await seedBlankDocument(owner, team.id, { key: 'tmpl' });
await prisma.envelope.update({
where: { id: draftDoc.id },
data: { type: EnvelopeType.TEMPLATE },
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await page.getByRole('button', { name: 'Delete' }).first().click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
await waitForOrganisationDeletionJob(organisation.id);
await waitForOrganisationToBeGone(organisation.id);
const templateAfter = await prisma.envelope.findUnique({
where: { id: draftDoc.id },
select: { id: true },
});
expect(templateAfter).toBeNull();
});
@@ -0,0 +1,387 @@
import { prisma } from '@documenso/prisma';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
/**
* Fill in the create-user dialog and submit it.
* Assumes the dialog trigger is already visible on the page.
*/
const submitCreateUserDialog = async ({
page,
email,
name,
}: {
page: import('@playwright/test').Page;
email: string;
name: string;
}) => {
await page.getByRole('button', { name: 'Create User' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByLabel('Email').fill(email);
await dialog.getByLabel('Name').fill(name);
await dialog.getByTestId('dialog-create-user-button').click();
};
// ─── Happy path ──────────────────────────────────────────────────────────────
test('[ADMIN][CREATE_USER]: admin can create a new user via the dialog', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const newUserEmail = seedTestEmail();
const newUserName = 'New Created User';
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await expect(page.getByRole('heading', { name: 'Manage users' })).toBeVisible();
await submitCreateUserDialog({ page, email: newUserEmail, name: newUserName });
// After success the dialog closes and we navigate to /admin/users/:id.
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
// The user-detail page renders the user's name in the heading.
await expect(page.getByRole('heading', { name: `Manage ${newUserName}'s profile` })).toBeVisible();
// The user exists in the database.
const created = await prisma.user.findUnique({
where: { email: newUserEmail.toLowerCase() },
});
expect(created).not.toBeNull();
expect(created?.name).toBe(newUserName);
});
// ─── emailVerified is set + password is null for admin-created users ────────
test('[ADMIN][CREATE_USER]: a newly created user has emailVerified set and no password', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const newUserEmail = seedTestEmail();
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await submitCreateUserDialog({
page,
email: newUserEmail,
name: 'Pending Password User',
});
// Wait for redirect to confirm the request finished.
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
// Admin-created users start with:
// - emailVerified set (the admin vouches for the email)
// - password null (user must set it via the welcome email reset link)
// The "password=null" state hard-blocks login at email-password.ts:101,
// forcing the user through the reset-link flow before they can sign in.
const created = await prisma.user.findUnique({
where: { email: newUserEmail.toLowerCase() },
select: { id: true, emailVerified: true, password: true },
});
expect(created, 'user should exist in the database').not.toBeNull();
expect(
created?.emailVerified,
'admin-created user should have emailVerified set — admin vouches for the email',
).not.toBeNull();
expect(
created?.password,
'admin-created user must have password=null — they must set one via the welcome reset link',
).toBeNull();
});
// ─── Welcome email side effect: a PasswordResetToken is issued ───────────────
test('[ADMIN][CREATE_USER]: creating a user issues a PasswordResetToken valid for ~24 hours', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const newUserEmail = seedTestEmail();
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
const beforeCreation = Date.now();
await submitCreateUserDialog({
page,
email: newUserEmail,
name: 'Token Recipient',
});
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
const created = await prisma.user.findUniqueOrThrow({
where: { email: newUserEmail.toLowerCase() },
select: { id: true },
});
// The PasswordResetToken is created by an async background job
// (send.admin.user.created.email), so poll until it shows up.
await expect
.poll(
async () => {
const found = await prisma.passwordResetToken.findFirst({
where: { userId: created.id },
});
return found === null ? null : 'found';
},
{
message: `PasswordResetToken for user ${created.id} was not created by the welcome-email job in time`,
timeout: 30_000,
intervals: [250, 500, 1000],
},
)
.toBe('found');
// Now that we know it exists, fetch it with strict types.
const token = await prisma.passwordResetToken.findFirstOrThrow({
where: { userId: created.id },
});
// Token should be ~24h in the future (allow a generous fudge window).
const expiry = token.expiry.getTime();
const expectedExpiry = beforeCreation + 24 * 60 * 60 * 1000;
const driftMs = Math.abs(expiry - expectedExpiry);
// Allow up to 5 minutes of drift (test setup, db round-trips, clock skew,
// plus job scheduling delay).
expect(driftMs, `token expiry should be ~24h from now, drift was ${driftMs}ms`).toBeLessThan(5 * 60 * 1000);
// The token value should be a non-trivial hex string.
expect(token.token.length).toBeGreaterThanOrEqual(32);
expect(token.token).toMatch(/^[a-f0-9]+$/);
});
// ─── Duplicate email is rejected ─────────────────────────────────────────────
test('[ADMIN][CREATE_USER]: creating a user with an email that already exists is rejected', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
// Seed an existing user whose email we'll collide with.
const { user: existingUser } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await submitCreateUserDialog({
page,
email: existingUser.email,
name: 'Collision Attempt',
});
// The dialog should stay open OR an error toast should surface. Either way
// we must NOT navigate to a new user detail page.
await page.waitForTimeout(1000);
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
// The existing user record must not have been mutated by the attempt.
const stillExisting = await prisma.user.findUnique({
where: { email: existingUser.email },
select: { id: true, name: true, emailVerified: true },
});
expect(stillExisting?.id).toBe(existingUser.id);
expect(stillExisting?.name).toBe(existingUser.name);
// The seeded user was verified — make sure the failed create didn't
// somehow flip the flag.
expect(stillExisting?.emailVerified).not.toBeNull();
// Count of users with this email must still be 1.
const matching = await prisma.user.count({
where: { email: existingUser.email },
});
expect(matching).toBe(1);
});
// ─── Validation: empty form ──────────────────────────────────────────────────
test('[ADMIN][CREATE_USER]: submitting an empty form shows validation errors and does not create a user', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await page.getByRole('button', { name: 'Create User' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Submit without filling anything.
await dialog.getByTestId('dialog-create-user-button').click();
// Validation errors are surfaced for both required fields. Their presence
// proves react-hook-form's zodResolver blocked the submit before the
// mutation ran, so no DB write could have happened.
await expect(dialog.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true');
await expect(dialog.getByLabel('Name')).toHaveAttribute('aria-invalid', 'true');
// Dialog stays open and we must not have navigated to a user detail page.
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
});
// ─── Validation: malformed email ─────────────────────────────────────────────
test('[ADMIN][CREATE_USER]: a malformed email is rejected client-side', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await page.getByRole('button', { name: 'Create User' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const emailInput = dialog.getByLabel('Email');
await emailInput.fill('not-an-email');
await dialog.getByLabel('Name').fill('Some Name');
// The Email input is rendered with type="email" and the form does not set
// noValidate, so the browser's native HTML5 constraint validation rejects
// the malformed value and blocks the submit event from ever firing. (As a
// result react-hook-form's zodResolver never runs and `aria-invalid` is
// not flipped to true — the browser is the layer doing the rejection.) We
// assert directly on the input's ValidityState to prove the value is
// recognised as invalid client-side.
await expect(emailInput).toHaveJSProperty('validity.valid', false);
await dialog.getByTestId('dialog-create-user-button').click();
// Dialog stays open and we must not have navigated.
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
// The bogus email is definitely not present in the DB — a targeted check
// on a specific row, not a global count, so it's safe to run in parallel.
const bogus = await prisma.user.findFirst({
where: { email: 'not-an-email' },
});
expect(bogus).toBeNull();
});
// ─── Cancel button closes dialog without creating ───────────────────────────
test('[ADMIN][CREATE_USER]: clicking Cancel closes the dialog and does not create a user', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
const newUserEmail = seedTestEmail();
await page.getByRole('button', { name: 'Create User' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Fill in valid data but cancel anyway.
await dialog.getByLabel('Email').fill(newUserEmail);
await dialog.getByLabel('Name').fill('Cancelled User');
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).not.toBeVisible();
// No user was created with that email.
const created = await prisma.user.findUnique({
where: { email: newUserEmail.toLowerCase() },
});
expect(created).toBeNull();
});
// ─── Email is lowercased when stored ─────────────────────────────────────────
test('[ADMIN][CREATE_USER]: email entered with mixed case is normalised to lowercase', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
// Build a known mixed-case email.
const rawEmail = seedTestEmail();
const mixedCaseEmail = rawEmail.replace(/^./, (c) => c.toUpperCase());
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await submitCreateUserDialog({
page,
email: mixedCaseEmail,
name: 'Mixed Case Email User',
});
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
// Look up by lowercased form — that's the canonical storage.
const created = await prisma.user.findUnique({
where: { email: rawEmail.toLowerCase() },
select: { id: true, email: true, emailVerified: true },
});
expect(created).not.toBeNull();
expect(created?.email).toBe(rawEmail.toLowerCase());
// Verified — admin vouches for the email. Case normalisation must not
// affect verification state.
expect(created?.emailVerified).not.toBeNull();
});
// ─── Access control: non-admin cannot see the Create User affordance ────────
test('[ADMIN][CREATE_USER]: non-admin user redirected away from /admin/users and cannot see Create User button', async ({
page,
}) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
await apiSignin({
page,
email: nonAdminUser.email,
redirectPath: '/admin/users',
});
// Non-admins are redirected away from /admin/*; the admin heading must not
// be visible.
await expect(page.getByRole('heading', { name: 'Manage users' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Create User' })).not.toBeVisible();
});
test('[ADMIN][CREATE_USER]: unauthenticated user cannot access /admin/users', async ({ page }) => {
// No apiSignin — just navigate directly.
await page.goto('/admin/users');
await expect(page).not.toHaveURL(/\/admin\/users$/);
await expect(page.getByRole('button', { name: 'Create User' })).not.toBeVisible();
});
@@ -0,0 +1,252 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
const callDeleteOrganisation = async (
page: Page,
input: {
organisationId: string;
organisationName: string;
sendEmailToOwner: boolean;
},
) => {
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.organisation.delete`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: input }),
});
};
// ─── Access control ──────────────────────────────────────────────────────────
test('[ADMIN][TRPC][DELETE_ORG]: unauthenticated request is rejected with 401', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
// No sign-in.
const res = await callDeleteOrganisation(page, {
organisationId: organisation.id,
organisationName: organisation.name,
sendEmailToOwner: true,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
// Org must still exist.
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
// No deletion job must have been enqueued.
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.admin-delete-organisation',
payload: { path: ['organisationId'], equals: organisation.id },
},
});
expect(job).toBeNull();
});
test('[ADMIN][TRPC][DELETE_ORG]: non-admin authenticated user is rejected with 401', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({ page, email: nonAdminUser.email });
const res = await callDeleteOrganisation(page, {
organisationId: organisation.id,
organisationName: organisation.name,
sendEmailToOwner: true,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.admin-delete-organisation',
payload: { path: ['organisationId'], equals: organisation.id },
},
});
expect(job).toBeNull();
});
test('[ADMIN][TRPC][DELETE_ORG]: organisation owner (non-admin) cannot delete their own org via admin route', async ({
page,
}) => {
// Owners can delete via the regular organisation.delete endpoint, but the
// ADMIN endpoint must reject them too.
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({ page, email: owner.email });
const res = await callDeleteOrganisation(page, {
organisationId: organisation.id,
organisationName: organisation.name,
sendEmailToOwner: true,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
});
// ─── Validation ──────────────────────────────────────────────────────────────
test('[ADMIN][TRPC][DELETE_ORG]: admin call with mismatched name is rejected and org is preserved', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteOrganisation(page, {
organisationId: organisation.id,
organisationName: `${organisation.name}-WRONG`,
sendEmailToOwner: true,
});
expect(res.ok()).toBeFalsy();
// Body should contain INVALID_REQUEST error.
const body = await res.text();
expect(body).toContain('does not match');
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(stillExists).not.toBeNull();
// Most importantly: no job has been enqueued for this org.
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.admin-delete-organisation',
payload: { path: ['organisationId'], equals: organisation.id },
},
});
expect(job).toBeNull();
});
test('[ADMIN][TRPC][DELETE_ORG]: admin call against non-existent organisation returns NOT_FOUND', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteOrganisation(page, {
organisationId: 'org_does-not-exist-1234567890',
organisationName: 'Anything',
sendEmailToOwner: true,
});
expect(res.ok()).toBeFalsy();
const body = await res.text();
expect(body).toContain('Organisation not found');
});
test('[ADMIN][TRPC][DELETE_ORG]: zod schema rejects malformed input', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
// Missing organisationName and sendEmailToOwner.
const res = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.organisation.delete`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: { organisationId: 'whatever' } }),
});
expect(res.ok()).toBeFalsy();
// Zod validation failures surface as 400 from tRPC.
expect([400, 422]).toContain(res.status());
});
// ─── Happy path via tRPC (admin) ────────────────────────────────────────────
test('[ADMIN][TRPC][DELETE_ORG]: admin can delete via the tRPC endpoint directly', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({ page, email: adminUser.email });
const res = await callDeleteOrganisation(page, {
organisationId: organisation.id,
organisationName: organisation.name,
sendEmailToOwner: false,
});
expect(res.ok()).toBeTruthy();
// Background job should be enqueued; wait for it to complete then verify
// the org is gone.
await expect
.poll(
async () => {
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.admin-delete-organisation',
payload: { path: ['organisationId'], equals: organisation.id },
},
});
return job?.status ?? null;
},
{ timeout: 30_000, intervals: [250, 500, 1000] },
)
.toBe('COMPLETED');
const org = await prisma.organisation.findUnique({ where: { id: organisation.id } });
expect(org).toBeNull();
});
// ─── Idempotency: calling delete twice does not throw ───────────────────────
test('[ADMIN][TRPC][DELETE_ORG]: a second delete call after deletion is harmless (NOT_FOUND or no-op)', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({ page, email: adminUser.email });
// First call succeeds.
const first = await callDeleteOrganisation(page, {
organisationId: organisation.id,
organisationName: organisation.name,
sendEmailToOwner: false,
});
expect(first.ok()).toBeTruthy();
// Wait for the deletion to actually happen.
await expect
.poll(
async () => {
const org = await prisma.organisation.findUnique({ where: { id: organisation.id } });
return org === null;
},
{ timeout: 30_000, intervals: [250, 500, 1000] },
)
.toBe(true);
// Second call: the org no longer exists, so the route should fail with
// NOT_FOUND. It must NOT 500.
const second = await callDeleteOrganisation(page, {
organisationId: organisation.id,
organisationName: organisation.name,
sendEmailToOwner: false,
});
expect(second.ok()).toBeFalsy();
expect(second.status()).not.toBe(500);
const body = await second.text();
expect(body).toContain('Organisation not found');
});
@@ -32,10 +32,14 @@ const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
* Expected HSL values after conversion by `toNativeCssVars`:
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
*
* The `%` on saturation and lightness is required: theme.css consumes these
* via `hsl(var(--token))`, and CSS Color 4 space-separated `hsl()` rejects
* bare numbers there. See `apps/remix/app/utils/css-vars.ts`.
*/
const EXPECTED_CSS_VARS = {
'--background': '0 100 50',
'--primary': '120 100 50',
'--background': '0 100% 50%',
'--primary': '120 100% 50%',
'--radius': '1rem',
};
@@ -64,7 +68,7 @@ const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
/**
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
* When `--background` is set to `0 100% 50%` (hsl(0, 100%, 50%)) the body background
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
*/
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
@@ -13,9 +13,9 @@ import {
} from '@documenso/prisma/seed/documents';
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { expect, type Locator, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
import { apiSignin } from '../fixtures/authentication';
export const PDF_PAGE_SELECTOR = 'img[data-page-number]';
@@ -46,6 +46,16 @@ async function addSecondEnvelopeItem(envelopeId: string) {
});
}
async function getLocatorWidth(locator: Locator) {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Locator bounding box not found');
}
return box.width;
}
test.describe('PDF Viewer Rendering', () => {
test.describe('Authenticated Pages', () => {
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
@@ -101,6 +111,38 @@ test.describe('PDF Viewer Rendering', () => {
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
});
test('should zoom in and reset to fit width', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}`,
});
const pageImage = page.locator(PDF_PAGE_SELECTOR).first();
const pdfContent = page.locator('[data-pdf-content]');
await expect(pageImage).toBeVisible({ timeout: 30_000 });
const initialImageWidth = await getLocatorWidth(pageImage);
const initialContentWidth = await getLocatorWidth(pdfContent);
expect(Math.abs(initialImageWidth - initialContentWidth)).toBeLessThanOrEqual(2);
await page.getByRole('button', { name: 'Zoom in' }).click();
await expect.poll(async () => await getLocatorWidth(pageImage)).toBeGreaterThan(initialImageWidth);
await expect.poll(async () => await getLocatorWidth(pdfContent)).toBeGreaterThan(initialContentWidth);
await page.getByRole('button', { name: 'Reset zoom' }).click();
await expect
.poll(async () => Math.abs((await getLocatorWidth(pageImage)) - initialImageWidth))
.toBeLessThanOrEqual(2);
});
});
test.describe('Recipient Signing', () => {
@@ -131,6 +173,68 @@ test.describe('PDF Viewer Rendering', () => {
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should keep V2 signing fields clickable after zooming', async ({ page, request }) => {
const { distributeResult, envelope } = await apiSeedPendingDocument(request, {
recipients: [{ email: 'pdf-zoom-signer@test.documenso.com', name: 'PDF Zoom Signer' }],
fieldsPerRecipient: [
[
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 10,
positionY: 10,
width: 15,
height: 5,
},
],
],
});
const { token } = distributeResult.recipients[0];
await page.goto(`/sign/${token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('1 Field Remaining').first()).toBeVisible();
const initialCanvasWidth = await getLocatorWidth(canvas);
await page.getByRole('button', { name: 'Zoom in' }).click();
await expect.poll(async () => await getLocatorWidth(canvas)).toBeGreaterThan(initialCanvasWidth);
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
await page.getByRole('button', { name: 'Next' }).click();
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
if (!signatureField) {
throw new Error('Signature field not found');
}
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(signatureField.positionX) / 100) * canvasBox.width +
((Number(signatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(signatureField.positionY) / 100) * canvasBox.height +
((Number(signatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe('Direct Template', () => {
@@ -168,7 +272,7 @@ test.describe('PDF Viewer Rendering', () => {
const qrTokenV1 = prefixedId('qr');
const qrTokenV2 = prefixedId('qr');
const documentV1 = await seedCompletedDocument(user, team.id, ['share-v1@test.documenso.com'], {
await seedCompletedDocument(user, team.id, ['share-v1@test.documenso.com'], {
createDocumentOptions: { qrToken: qrTokenV1 },
});
+1 -1
View File
@@ -41,7 +41,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
baseURL: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

@@ -1,10 +1,9 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@prisma/client';
@@ -115,8 +114,8 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(redirectPath, 302);
}
// Check if signups are disabled.
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
// Check if signups are disabled for this provider.
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
@@ -1,4 +1,5 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
@@ -65,6 +66,14 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
// Handle new user.
if (!userToLink) {
if (!isSignupEnabledForProvider('oidc')) {
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
return c.redirect(errorUrl.toString(), 302);
}
userToLink = await prisma.user.create({
data: {
email: email,
@@ -1,4 +1,4 @@
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
@@ -27,7 +27,6 @@ import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/serv
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
@@ -184,7 +183,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
if (!isSignupEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
statusCode: 400,
});
@@ -0,0 +1,57 @@
import { Trans } from '@lingui/react/macro';
import { Button, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateAdminUserCreatedProps = {
resetPasswordLink: string;
assetBaseUrl: string;
};
export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: TemplateAdminUserCreatedProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Trans>Welcome to Documenso!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>An administrator has created a Documenso account for you.</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>To get started, please set your password by clicking the button below:</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
href={resetPasswordLink}
>
<Trans>Set Password</Trans>
</Button>
<Text className="mt-8 text-center text-slate-400 text-sm italic">
<Trans>
You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)
</Trans>
</Text>
</Section>
<Section className="mt-8">
<Text className="text-center text-slate-400 text-sm">
<Trans>
If you didn't expect this account or have any questions, please{' '}
<Link href="mailto:support@documenso.com" className="text-documenso-500">
contact support
</Link>
.
</Trans>
</Text>
</Section>
</Section>
</>
);
};
@@ -0,0 +1,45 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
import type { TemplateAdminUserCreatedProps } from '../template-components/template-admin-user-created';
import { TemplateAdminUserCreated } from '../template-components/template-admin-user-created';
import { TemplateFooter } from '../template-components/template-footer';
export const AdminUserCreatedTemplate = ({
resetPasswordLink,
assetBaseUrl = 'http://localhost:3002',
}: TemplateAdminUserCreatedProps) => {
const { _ } = useLingui();
const previewText = msg`Set your password for Documenso`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
<TemplateAdminUserCreated resetPasswordLink={resetPasswordLink} assetBaseUrl={assetBaseUrl} />
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default AdminUserCreatedTemplate;
@@ -0,0 +1,75 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type OrganisationDeleteEmailProps = {
assetBaseUrl: string;
organisationName: string;
/**
* Whether the deletion was performed by an administrator (as opposed to the owner).
* Slightly changes the wording in the email.
*/
deletedByAdmin?: boolean;
};
export const OrganisationDeleteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
organisationName = 'Organisation Name Placeholder',
deletedByAdmin = false,
}: OrganisationDeleteEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Your organisation has been deleted`;
const title = msg`Your organisation has been deleted`;
const description = deletedByAdmin
? msg`The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data.`
: msg`The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data.`;
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
) : (
<TemplateImage assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" staticAsset="logo.png" />
)}
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
<Text className="my-1 text-center text-base">{_(description)}</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
{organisationName}
</div>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default OrganisationDeleteEmailTemplate;
@@ -1,5 +1,14 @@
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { posthog } from 'posthog-js';
let posthogPromise: Promise<typeof import('posthog-js')> | null = null;
const getPosthog = async () => {
if (!posthogPromise) {
posthogPromise = import('posthog-js');
}
return posthogPromise;
};
export function useAnalytics() {
// const featureFlags = useFeatureFlags();
@@ -16,7 +25,9 @@ export function useAnalytics() {
return;
}
posthog.capture(event, properties);
void getPosthog().then(({ default: posthog }) => {
posthog.capture(event, properties);
});
};
/**
@@ -30,7 +41,9 @@ export function useAnalytics() {
return;
}
posthog.captureException(error, properties);
void getPosthog().then(({ default: posthog }) => {
posthog.captureException(error, properties);
});
};
/**
+19
View File
@@ -119,3 +119,22 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
return allowedDomains.includes(emailDomain);
};
/**
* Check if signup is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
*/
export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
return false;
}
const flagMap = {
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP',
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP',
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP',
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNUP',
} as const;
return env(flagMap[provider]) !== 'true';
};
+11
View File
@@ -0,0 +1,11 @@
/**
* Maximum length (in characters) of the user-supplied custom CSS for branding.
* Bound enforced at the TRPC request boundary on both the organisation and
* team settings update routes. The sanitiser is run after this check; this
* limit is purely a request-size guard.
*
* 256 KB — generous enough for hand-written branding CSS and the occasional
* compiled-from-Tailwind-or-similar paste, while still keeping a request
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
@@ -0,0 +1,90 @@
import { env } from '@documenso/lib/utils/env';
export const DOCUMENT_CONVERSION_MIME_TYPE_DOCX =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS = 30_000;
/**
* Returns whether the document conversion feature is enabled.
*
* Platform-aware:
* - On the server, checks the private URL is configured.
* - On the client, reads the derived public flag injected via `window.__ENV__`.
*/
export const IS_DOCUMENT_CONVERSION_ENABLED = (): boolean => {
if (typeof window === 'undefined') {
return !!env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
}
return env('NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED') === 'true';
};
/**
* Returns the configured conversion service base URL as supplied via env, or
* `undefined` if not configured.
*
* Server-side only.
*/
export const DOCUMENT_CONVERSION_URL = (): string | undefined => {
return env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
};
/**
* Returns HTTP Basic auth credentials for the conversion service, or
* `undefined` if either env var is missing. When Gotenberg is started with
* `--api-enable-basic-auth`, every request must carry these credentials.
*
* Server-side only.
*/
export const DOCUMENT_CONVERSION_AUTH = (): { username: string; password: string } | undefined => {
const username = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME');
const password = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD');
if (!username || !password) {
return undefined;
}
return { username, password };
};
/**
* Returns the per-request timeout for conversion calls in milliseconds.
*
* Falls back to a 30 second default when the env value is missing or
* unparseable.
*/
export const DOCUMENT_CONVERSION_TIMEOUT_MS = (): number => {
const raw = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS');
if (!raw) {
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
}
return parsed;
};
/**
* Returns the mime type -> extensions map that should be passed to the
* dropzone `accept` config and used for server-side validation.
*
* Always includes PDF; only includes DOCX when the conversion feature is
* enabled.
*/
export const getAllowedUploadMimeTypes = (): Record<string, string[]> => {
const base: Record<string, string[]> = {
'application/pdf': ['.pdf'],
};
if (IS_DOCUMENT_CONVERSION_ENABLED()) {
base[DOCUMENT_CONVERSION_MIME_TYPE_DOCX] = ['.docx'];
}
return base;
};

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