Compare commits

..

82 Commits

Author SHA1 Message Date
Lucas Smith 9b59f1a273 v2.12.0 2026-06-16 11:00:17 +10:00
David Nguyen 15549a6758 fix: add early quota warning (#2986)
## Description

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

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

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

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

The current example in the docs(/api/v2/documents) returns a 404
NOT_FOUND object.
2026-05-20 18:17:14 +10:00
Ephraim Duncan 2cb4cc29ea feat: allow admins to create users (#2082) 2026-05-19 20:37:03 +10:00
Lucas Smith d9b5f01e21 chore: add translations (#2833) 2026-05-19 16:19:44 +10:00
Lucas Smith bc3acba72c fix: use captcha imperatively (#2832) 2026-05-19 14:38:40 +10:00
Ephraim Duncan 247a0158bd refactor(ui): replace hardcoded colors with semantic tokens (#2749) 2026-05-19 14:19:31 +10:00
Lucas Smith 9e0b567686 chore: deps upgrade (#2831) 2026-05-18 22:25:48 +10:00
David Nguyen 8f6be474a9 fix: improve api logging (#2820) 2026-05-15 13:41:35 +10:00
Ephraim Duncan 8f5bdef384 docs: require English for PRs and issues (#2819) 2026-05-15 12:30:13 +10:00
David Nguyen 999942014e chore: update docs for self hosters (#2816) 2026-05-14 15:07:10 +10:00
Tarana 194b2134cc docs: remove leftover Next.js commands and update to Remix-compatible syntax (#2695) 2026-05-14 12:06:59 +10:00
Ephraim Duncan b8df02750b fix: convert DOCX template uploads to PDF (#2807) 2026-05-14 11:59:27 +10:00
342 changed files with 22634 additions and 4963 deletions
@@ -0,0 +1,122 @@
---
date: 2026-05-28
title: Custom Brand Logo Url
---
# Problem
`brandingUrl` (the configured "Brand Website") is persisted and editable in branding
settings, but historically it was never consumed anywhere. It flowed into the database,
the settings form, and the admin read-only view, but never affected any rendered output.
We want `brandingUrl` to actually do something, with deliberately different behavior per
surface.
# Relationship we're going for
`brandingUrl` is an **email-only** linking concept. It is intentionally **not** used on
in-app signing surfaces.
| Surface | Custom branding logo configured | `brandingUrl` behavior |
| --- | --- | --- |
| Transactional emails (logo) | Logo shown | Logo links to `brandingUrl` when it is a safe http(s) URL; otherwise plain image |
| Transactional emails (footer) | n/a | `brandingUrl` rendered as a link in the footer when it is a safe http(s) URL |
| Signing pages (V1 + V2, normal + direct-template) | Logo shown | Ignored — logo is a plain image with no link |
| Signing pages (no custom logo) | Documenso fallback shown | Fallback keeps its internal `/` link |
| Embedded signing | Logo shown | Ignored (logo not linked) |
| Embedded authoring/editor | Logo shown | Ignored |
| Settings / admin branding previews | n/a | Unchanged (display only) |
Rationale:
- On signing pages the recipient is mid-task; sending them off to an external marketing
site via the logo is undesirable, so the custom logo is a plain image there.
- In emails the logo and a footer link to the brand's own site are a normal, expected
pattern and reinforce that the email is legitimately from that brand.
# Decisions
## Scope
- Use `brandingUrl` only in transactional email rendering:
- The shared email logo component links the custom branding logo to `brandingUrl`.
- The shared email footer renders `brandingUrl` as a link.
- On signing surfaces, render a configured custom branding logo as a plain image with no
link wrapper. Leave the Documenso fallback logo's internal `/` link untouched.
- Do not change embedded signing, embedded authoring/editor, or settings/admin previews.
- No Prisma schema or database migration. `brandingUrl` already exists and is editable.
## URL safety
Rendering must be defensive because old/imported data can bypass the branding form's URL
validation. Only treat the stored value as a usable Brand Website when it parses as an
absolute `http:` or `https:` URL.
- Empty, missing, invalid, relative, or non-http(s) values are treated as "no Brand
Website" and produce a plain logo / no footer link.
- Do not mutate stored settings or run a cleanup migration.
- Factored into a single shared helper so both email logo and footer apply identical rules:
- `packages/email/utils/branding-url.ts` -> `getSafeBrandingUrl(value): string | null`.
## Email rendering
- New shared component `packages/email/template-components/template-branding-logo.tsx`
(`TemplateBrandingLogo`) renders either:
- the custom branding logo, wrapped in a `Link` to the safe `brandingUrl` with
`target="_blank"` when one exists, or a plain `Img` when not; or
- the Documenso fallback logo (`/static/logo.png`) when custom branding is disabled or
no logo is set.
- This component replaced the duplicated `brandingEnabled && brandingLogo ? <Img/> : <fallback/>`
ternary that was copy-pasted across all transactional email templates.
- `packages/email/template-components/template-footer.tsx` renders `brandingUrl` as a
footer link (via `getSafeBrandingUrl`) when branding is enabled and the URL is safe.
The branding context already exposes `brandingUrl` (`packages/email/providers/branding.tsx`),
populated by `teamGlobalSettingsToBranding` / `organisationGlobalSettingsToBranding`
(which spread `...settings`), so no additional plumbing into the email branding context was
required.
## Signing rendering
- `apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx`:
custom logo renders as a bare `<img>`. `brandingUrl` is not read; the local branding type
and loader payload no longer carry it.
- `apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx` (V2,
shared by normal and direct-template signing): custom logo renders as a bare `<img>`; the
Documenso fallback keeps its `<Link to="/">`.
- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx`: V1 loader branding payload no
longer includes `brandingUrl`.
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` and
`get-envelope-for-direct-template-signing.ts`: `brandingUrl` removed from the V2
`EnvelopeForSigningResponse.settings` schema/payload since it is not consumed there.
# History
An earlier iteration of this plan wired `brandingUrl` into the in-app signing pages so a
custom logo linked to the Brand Website (external `<a target="_blank">`, internal `/`
fallback otherwise) and added `brandingUrl` to the V1/V2 signing payloads. That direction
was reversed: signing-page logos are now plain images and `brandingUrl` is email-only. The
signing payload additions were removed.
# Test coverage
`packages/app-tests/e2e/signing-branding.spec.ts`:
- V1 normal `/sign/:token`: custom logo is a plain image, not inside a link, and no
`brandingUrl` link is present.
- V2 normal `/sign/:token` and V2 direct-template: same plain-image assertions.
- V2 with no custom logo: Documenso fallback still links to `/`.
- Embedded signing: no custom-logo Brand Website link is rendered.
# Acceptance criteria
- A custom branding logo on any signing surface (V1, V2 normal, V2 direct-template, embedded)
renders as a plain image with no link, and `brandingUrl` is never rendered as a link there.
- Documenso fallback logos continue linking to `/`.
- In transactional emails, when a custom logo and a safe `brandingUrl` are configured, the
email logo links to `brandingUrl` (new tab) and the footer shows the Brand Website link.
- In transactional emails, when `brandingUrl` is empty/invalid/relative/non-http(s), the logo
is a plain image and no footer Brand Website link is shown.
- URL safety is enforced through the single shared `getSafeBrandingUrl` helper.
- Settings/admin branding previews are unchanged.
- No schema or migration changes.
+1 -1
View File
@@ -29,6 +29,6 @@
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "vscode.json-language-features"
}
}
+4
View File
@@ -9,6 +9,10 @@ If you plan to contribute to Documenso, please take a moment to feel awesome ✨
- Consider the results from the discussion on the issue
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
## English only PRs and Issues
Please write all issues, pull requests, and related comments in English so maintainers and the wider contributor community can follow the discussion.
## Taking issues
Before taking an issue, ensure that:
+15 -145
View File
@@ -11,6 +11,8 @@
·
<a href="https://documenso.com">Website</a>
·
<a href="https://docs.documenso.com">Documentation</a>
·
<a href="https://github.com/documenso/documenso/issues">Issues</a>
·
<a href="https://documen.so/live">Upcoming Releases</a>
@@ -146,45 +148,7 @@ npm run d
### Manual Setup
Follow these steps to setup Documenso on your local machine:
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
After forking the repository, clone it to your local device by using the following command:
```sh
git clone https://github.com/<your-username>/documenso
```
2. Run `npm i` in the root directory
3. Create your `.env` from the `.env.example`. You can use `cp .env.example .env` to get started with our handpicked defaults.
4. Set the following environment variables:
- NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL
- NEXT_PRIVATE_DATABASE_URL
- NEXT_PRIVATE_DIRECT_DATABASE_URL
- NEXT_PRIVATE_SMTP_FROM_NAME
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
5. Create the database schema by running `npm run prisma:migrate-dev`
6. Run `npm run translate:compile` in the root directory to compile lingui
7. Run `npm run dev` in the root directory to start
8. Register a new user at http://localhost:3000/signup
---
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
- Optional: Create your own signing certificate.
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
- Optional: Configure job provider for document reminders.
- The default local job provider does not support scheduled jobs required for document reminders.
- To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file.
Follow the [manual setup guide](https://docs.documenso.com/docs/developers/local-development/manual) to configure Documenso on your local machine.
### Run in Gitpod
@@ -204,138 +168,44 @@ If you're a visual learner and prefer to watch a video walkthrough of setting up
## Docker
We provide a Docker container for Documenso, which is published on both DockerHub and GitHub Container Registry.
We provide official Docker images on [DockerHub](https://hub.docker.com/r/documenso/documenso) and [GitHub Container Registry](https://ghcr.io/documenso/documenso).
- DockerHub: [https://hub.docker.com/r/documenso/documenso](https://hub.docker.com/r/documenso/documenso)
- GitHub Container Registry: [https://ghcr.io/documenso/documenso](https://ghcr.io/documenso/documenso)
You can pull the Docker image from either of these registries and run it with your preferred container hosting provider.
Please note that you will need to provide environment variables for connecting to the database, mailserver, and so forth.
For detailed instructions on how to configure and run the Docker container, please refer to the [Docker README](./docker/README.md) in the `docker` directory.
For setup instructions, see the [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) and [Docker Compose](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) guides.
## Self Hosting
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
We support a variety of deployment methods including Docker, Docker Compose, Railway, Kubernetes, and manual deployment.
### Fetch, configure, and build
For full instructions, requirements, and configuration details, see the [Self Hosting documentation](https://docs.documenso.com/docs/self-hosting).
First, clone the code from Github:
### One-Click Deploys
```
git clone https://github.com/documenso/documenso.git
```
#### Railway
Then, inside the `documenso` folder, copy the example env file:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
```
cp .env.example .env
```
The following environment variables must be set:
- `NEXTAUTH_SECRET`
- `NEXT_PUBLIC_WEBAPP_URL`
- `NEXT_PRIVATE_DATABASE_URL`
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
- `NEXT_PRIVATE_SMTP_FROM_NAME`
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
Now you can install the dependencies and build it:
```
npm i
npm run build
npm run prisma:migrate-deploy
```
Finally, you can start it with:
```
cd apps/remix
npm run start
```
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
### Run as a service
You can use a systemd service file to run the app. Here is a simple example of the service running on port 3500 (using 3000 by default):
```bash
[Unit]
Description=documenso
After=network.target
[Service]
Environment=PATH=/path/to/your/node/binaries
Type=simple
User=www-data
WorkingDirectory=/var/www/documenso/apps/remix
ExecStart=/usr/bin/next start -p 3500
TimeoutSec=15
Restart=always
[Install]
WantedBy=multi-user.target
```
### Railway
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
### Render
#### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
### Koyeb
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
## Elestio
#### Elestio
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso)
## Troubleshooting
For troubleshooting self-hosted deployments, see the [Troubleshooting guide](https://docs.documenso.com/docs/self-hosting/maintenance/troubleshooting) and [Tips & Common Pitfalls](https://docs.documenso.com/docs/self-hosting/getting-started/tips).
### I'm not receiving any emails when using the developer quickstart.
When using the developer quickstart, an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing emails locally for you to view.
The Web UI can be found at http://localhost:9000, while the SMTP port will be on localhost:2500.
### Support IPv6
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command
For local docker run
```bash
docker run -it documenso:latest npm run start -- -H ::
```
For k8s or docker-compose
```yaml
containers:
- name: documenso
image: documenso:latest
imagePullPolicy: IfNotPresent
command:
- npm
args:
- run
- start
- --
- -H
- '::'
```
### I can't see environment variables in my package scripts.
Wrap your package script with the `with:env` script like such:
+1 -1
View File
@@ -60,7 +60,7 @@ We support a variety of deployment methods, and are actively working on adding m
## Railway
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
## Render
@@ -12,7 +12,7 @@ import { Callout } from 'fumadocs-ui/components/callout';
| 21 CFR Part 11 | Compliant (Enterprise) |
| SOC 2 | Compliant |
| ISO 27001 | Planned |
| HIPAA | Planned |
| HIPAA | Compliant (Enterprise) |
## 21 CFR Part 11
@@ -97,12 +97,12 @@ Documenso implements digital signatures with the following characteristics:
- **Timestamps**: RFC 3161 timestamps can be applied to signatures
- **Signature visualization**: Signed documents include visual signature representations
For specific implementation details and configuration options, refer to the [signing certificates](/signing-certificates/overview) documentation.
For specific implementation details and configuration options, refer to the [signing certificates](/docs/concepts/signing-certificates) documentation.
Self-hosted deployments can configure their own signing certificates and timestamp authorities to meet specific compliance requirements.
## Related
- [Legal Validity](/compliance/legal-validity) - Legal frameworks for electronic signatures
- [Signing Certificates Overview](/signing-certificates/overview) - Certificate configuration
- [Audit Log](/features/audit-log) - Document activity tracking
- [E-Sign Compliance](/docs/compliance/esign) - Legal frameworks for electronic signatures
- [Signing Certificates](/docs/concepts/signing-certificates) - Certificate configuration
- [Signing Workflow](/docs/concepts/signing-workflow) - Document activity and audit trail
@@ -167,5 +167,5 @@ To enable sequential signing:
## Related
- [Add Recipients](/users/documents/add-recipients) - How to add recipients to a document
- [Field Types](/concepts/field-types) - Learn about the different field types you can assign to recipients
- [Add Recipients](/docs/users/documents/add-recipients) - How to add recipients to a document
- [Field Types](/docs/concepts/field-types) - Learn about the different field types you can assign to recipients
@@ -0,0 +1,45 @@
---
title: Common Errors
description: A comprehensive troubleshooting matrix for Documenso API and Webhook integration errors.
---
This guide provides a comprehensive troubleshooting matrix for the standard error codes returned by the Documenso API. Use this reference to diagnose and resolve integration issues related to envelopes, recipients, and webhooks.
## Application Error Codes
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `ALREADY_EXISTS` | The resource you are attempting to create already exists. | Verify if the entity (e.g., user, envelope, webhook) has already been instantiated. Use a `PUT` or `PATCH` request to update the existing resource instead of `POST`. |
| `EXPIRED_CODE` | The provided access code or token has expired. | Generate a new access code or request a new invitation link before retrying the request. |
| `INVALID_BODY` | The request payload is malformed. | Inspect your JSON payload structure. Ensure it strictly adheres to the expected schema and that no required fields are missing. |
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
| `INVALID_CAPTCHA` | Security token (Captcha) validation failed. | Ensure the Captcha token is correctly generated on the client side and transmitted without alteration in your request. |
| `UNAUTHORIZED` | Missing or invalid authentication (401). | Verify that your API key is correct, active, and properly formatted in the `Authorization` header (e.g., `Bearer <YOUR_API_KEY>`). |
| `FORBIDDEN` | Access to the resource is denied (403). | Ensure your API key or user account has the necessary permissions and roles to execute this specific action. |
| `UNKNOWN_ERROR` | An unexpected internal server error occurred (500). | Retry the request later. If the issue persists, contact technical support with your request payload and the timestamp of the incident. |
| `RETRY_EXCEPTION` | The operation failed temporarily but can be retried. | Implement an automatic retry logic in your integration, ideally using an exponential backoff strategy. |
| `SCHEMA_FAILED` | Strict data schema validation failed. | Verify that the data types sent (string, number, boolean) exactly match the OpenAPI specification. |
| `TOO_MANY_REQUESTS` | Rate limit exceeded (429). | Reduce the frequency of your API calls. Implement rate-limiting handling based on the response headers. |
| `TWO_FACTOR_AUTH_FAILED` | Two-factor authentication (2FA) failed. | Verify the provided 2FA code. Ensure it was entered correctly and has not expired. |
| `WEBHOOK_INVALID_REQUEST` | The webhook-related request is invalid. | Check your receiving endpoint configuration. Ensure the URL is correct and that your server accepts `POST` requests from Documenso. |
## Envelope State Errors
The following errors occur when attempting to perform actions on an envelope that are incompatible with its current state.
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
## See Also
- [Documents API](/docs/developers/api/documents)
- [Webhooks](/docs/developers/webhooks)
@@ -1,4 +1,14 @@
{
"title": "API Reference",
"pages": ["documents", "recipients", "fields", "templates", "teams", "rate-limits", "versioning", "developer-mode"]
"pages": [
"documents",
"recipients",
"fields",
"templates",
"teams",
"rate-limits",
"versioning",
"developer-mode",
"common-errors"
]
}
@@ -0,0 +1,81 @@
---
title: iframe
description: Embed the signing experience directly in your application using an iframe.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<Callout type="warn" title="iframes are not recommended">
Embedding via iframe is not recommended. We strongly recommend using the [official SDKs](/docs/developers/embedding/sdks) instead.
</Callout>
### Basic iframe Embedding
```html
<iframe
src="https://app.documenso.com/embed/sign/abc123xyz"
width="100%"
height="800"
frameborder="0"
allow="clipboard-write"
></iframe>
```
<Callout title="Use the correct embed URL">
The URL you embed depends on the embed mode youre using (for example direct links vs sign-token embeds). Use the
embed URL provided by Documenso for your flow.
</Callout>
### iframe Customization
You can customize the embedded signing experience by passing **encoded options in the iframe URL fragment** (everything
after `#`).
Documenso expects the fragment to be **base64** of:
- `encodeURIComponent(JSON.stringify(options))`
#### Supported options
| Option | Type | Description |
| ------ | ---- | ----------- |
| `name` | `string` | Prefill signer name. |
| `email` | `string` | Prefill signer email. |
| `lockName` | `boolean` | Lock the name field (prevents editing). |
| `lockEmail` | `boolean` | Lock the email field (prevents editing). |
| `language` | `string` | Force the embed language (e.g. `en`). |
| `darkModeDisabled` | `boolean` | Disable dark mode behavior. |
| `allowDocumentRejection` | `boolean` | Allow or disallow document rejection. |
| `css` | `string` | Inject custom CSS into the embed. |
| `cssVars` | `object` | Override embed CSS variables (see the CSS Variables page). |
#### Example
```ts
const buildEmbedSrc = (host: string, token: string) => {
const options = {
name: 'Ada Lovelace',
email: 'ada@example.com',
lockName: true,
lockEmail: true,
language: 'en',
darkModeDisabled: false,
allowDocumentRejection: true,
css: ':root { --radius: 12px; }',
cssVars: {},
};
const encodedOptions = btoa(encodeURIComponent(JSON.stringify(options)));
return `${new URL(`/embed/sign/${token}`, host).toString()}#${encodedOptions}`;
};
```
A complete example can be found in the [Embeds repository](https://github.com/documenso/embeds/blob/main/packages/mitosis/src/sign-document.lite.tsx).
<Callout type="info" title="Why use the URL fragment?">
The fragment is **not sent to the server** as part of the HTTP request, but it is available to the embedded app in
the browser. This makes it a convenient way to pass client-side configuration without changing the base embed URL.
</Callout>
@@ -1,4 +1,4 @@
{
"title": "Embedding",
"pages": ["sdks", "direct-links", "css-variables", "editor"]
"pages": ["sdks", "direct-links", "css-variables", "editor", "iframe"]
}
@@ -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
@@ -8,6 +8,7 @@
"privacy",
"terms",
"security",
"verify-email",
"support"
]
}
@@ -0,0 +1,68 @@
---
title: Verifying Emails from Documenso
description: How to confirm that an email is genuinely from Documenso, and what to do if you receive a suspicious message.
---
import { Callout } from 'fumadocs-ui/components/callout';
## Check the Sender Domain
All email sent by Documenso originates from one of the following domains. If you receive an email claiming to be from Documenso and the sender address does not end in one of these domains, treat it as suspicious.
| Domain | Used for |
| ------------------------ | -------------------------------------------------------------- |
| `app.documenso.com` | Transactional email |
| `documensomail.com` | Transactional email |
| `documensoemail.com` | Transactional email |
| Custom domain | [Enterprise organisations](/docs/users/organisations/email-domains) using a custom email domain |
Typical sender addresses include:
- `noreply@app.documenso.com`
- `noreply@free.documensomail.com`
- `noreply@send.documensoemail.com`
<Callout type="warn">
A misspelling such as `documenso-email.com`, `documensoemaiI.com` (capital i instead of l), or any other variation is not a Documenso domain.
</Callout>
## Types of Email Documenso Sends
Documenso sends email only for the following purposes:
- **Account verification** — confirming your email address when you sign up or change it
- **Password reset** — a link to reset your password that you requested
- **Document invitations** — notifying you that a document has been shared with you to sign, approve, or view
- **Signing reminders** — follow-up reminders for pending document actions
- **Completed document notifications** — confirmation that all parties have signed a document
- **Team invitations** — inviting you to join an organisation or team
## What Documenso Will Never Do
- Ask for your password via email
- Send you an attachment and ask you to open it to verify your identity
- Ask you to confirm payment details or billing information over email
- Send unsolicited marketing emails if you have not opted in
## How to Tell If an Email Is Legitimate
1. **Check the sender address** — the domain must be `documenso.com` or `documensomail.com`
2. **Look at the link destination** — hover over any link before clicking; it should point to `app.documenso.com`
3. **Watch for urgency or threats** — legitimate Documenso emails do not threaten account suspension to pressure you into clicking a link immediately
4. **Verify the action yourself** — if in doubt, log in to [app.documenso.com](https://app.documenso.com) directly (not via the email link) and check whether the document or notification exists there
## Report a Suspicious Email
If you receive an email that appears to impersonate Documenso:
1. Do not click any links or download any attachments
2. Forward the email as an attachment to **support@documenso.com**
3. Delete the email from your inbox
You can also report phishing emails directly to your email provider using their built-in reporting tools.
## Related
- [Security Policy](/docs/policies/security) — Documenso's security practices and vulnerability disclosure process
- [Create an Account](/docs/users/getting-started/create-account) — What to expect during sign-up
- [Security Settings](/docs/users/settings/security) — Enable two-factor authentication and manage sessions
@@ -1,6 +1,6 @@
---
title: Storage Configuration
description: Configure file storage for uploaded documents and signed PDFs using database storage (default) or S3-compatible object storage.
description: Configure file storage for uploaded documents and signed PDFs using database storage (default), S3-compatible object storage, or Azure Blob Storage.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
@@ -10,10 +10,11 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Storage Options
| Backend | Best For | Scalability | Configuration |
| ---------- | -------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None required |
| `s3` | Production, large files, backups | High | Required |
| Backend | Best For | Scalability | Configuration |
| ------------ | --------------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None required |
| `s3` | Production, large files, backups | High | Required |
| `azure-blob` | Production on Azure, native Blob access | High | Required |
Select the storage backend with the `NEXT_PUBLIC_UPLOAD_TRANSPORT` environment variable:
@@ -23,6 +24,9 @@ NEXT_PUBLIC_UPLOAD_TRANSPORT=database
# S3-compatible storage
NEXT_PUBLIC_UPLOAD_TRANSPORT=s3
# Azure Blob Storage (native)
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
```
---
@@ -283,6 +287,111 @@ NEXT_PRIVATE_UPLOAD_REGION=us-east-1
---
## Azure Blob Storage
Azure Blob Storage is supported as a native transport (not S3-compatible). Documenso uses the official `@azure/storage-blob` SDK and signs SAS URLs with the Storage Account key for browser uploads and downloads.
### Required Variables
| Variable | Description |
| --------------------------------------- | ------------------------------------------------- |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Set to `azure-blob` |
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME` | Azure Storage Account name |
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY` | Azure Storage Account access key |
| `NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER` | Container name where uploads are stored |
### Optional Variables
| Variable | Description | Default |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT` | Custom Blob endpoint URL. Useful for local development against Azurite (for example `http://127.0.0.1:10000`). | `https://<account>.blob.core.windows.net` |
### Azure Setup
{/* prettier-ignore */}
<Steps>
<Step>
### Create a Storage Account and Container
Create a Storage Account in the Azure Portal or via the Azure CLI, then create a container inside it:
```bash
az storage account create \
--name yourstorageaccount \
--resource-group your-rg \
--location eastus \
--sku Standard_LRS
az storage container create \
--name documenso-documents \
--account-name yourstorageaccount
```
</Step>
<Step>
### Configure CORS on the container
The browser uploads documents directly to Azure Blob using a SAS URL, and downloads them the same way, so the Storage Account needs CORS rules that allow your application origin:
```bash
az storage cors add \
--services b \
--methods GET PUT \
--origins https://your-documenso-domain.com \
--allowed-headers "Content-Type" "x-ms-blob-type" "Authorization" \
--exposed-headers "*" \
--max-age 3600 \
--account-name yourstorageaccount
```
</Step>
<Step>
### Configure Environment Variables
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=yourstorageaccount
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=your-account-key
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
```
</Step>
</Steps>
### Local Development with Azurite
Azurite is the official Azure Storage emulator. It supports the Blob REST API with account-key authentication.
```bash
docker run -d --name azurite \
-p 10000:10000 -p 10001:10001 -p 10002:10002 \
mcr.microsoft.com/azure-storage/azurite
```
Create the container against the well-known development account:
```bash
az storage container create \
--name documenso-documents \
--connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
```
Configure environment variables to point at the emulator:
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=devstoreaccount1
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT=http://127.0.0.1:10000
```
<Callout type="info">
The Azurite key shown above is the public well-known development key, published by Microsoft for emulator use. Never reuse it in production.
</Callout>
---
## CloudFront CDN (Optional)
Use Amazon CloudFront to serve documents with lower latency and reduced S3 costs. CloudFront integration uses signed URLs for secure access.
@@ -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
@@ -196,6 +202,14 @@ Documenso provides health check endpoints for monitoring:
| `/api/health` | Checks database connectivity and certificate status |
| `/api/certificate-status` | Returns whether a signing certificate is configured and usable |
Both endpoints return a JSON response with a `status` field:
| Status | Meaning |
| ----------- | -------------------------------------------------------------------- |
| `"ok"` | Everything is working properly |
| `"warning"` | Application is running but there are certificate issues |
| `"error"` | Critical issues (database unreachable, missing configuration, etc.) |
### Docker Health Check
Add a health check to your container:
@@ -24,7 +24,7 @@ Before deploying, you need:
The fastest way to deploy Documenso on Railway is using the official template:
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
This template automatically provisions:
@@ -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:
@@ -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
@@ -39,7 +39,11 @@ Navigate to [documen.so/free](https://documen.so/free) to create a free account.
Provide your name, email address, and create a password. Alternatively, sign up with Google for faster access.
{/* TODO: Add screenshot of registration form */}
<img
src="/get-started-images/documenso-registration-form.webp"
alt="Documenso registration form with name, email, and password fields"
style={{width: '500px', height: '650px', objectFit: 'contain' }}
/>
</Step>
@@ -7,14 +7,14 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
<Callout type="error">
Account deletion is permanent and irreversible. All documents, signatures, templates, and account
data will be permanently removed. Any active subscription will be cancelled.
Account deletion is permanent and irreversible. Your account, signatures, and personal data will be
permanently removed, and any active subscription will be cancelled. How your organisations and
documents are handled is explained below.
</Callout>
## Before Deleting
- Download any documents you need to keep
- Cancel any active subscriptions
- Disable two-factor authentication (required before deletion)
## Delete Your Account
@@ -36,6 +36,31 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
If you have two-factor authentication enabled, you must disable it before deleting your account.
</Callout>
## What Happens to Your Organisations
When you delete your account, the organisations you **own** are permanently deleted along with all of
their teams. If an owned organisation has an active subscription, it is scheduled for cancellation at
the end of the current billing period.
Organisations that you are only a **member** of are not deleted. You are simply removed from them, and
the organisation continues to operate as normal.
## What Happens to Your Documents
The way your documents and templates are handled depends on whether you owned the organisation they
belong to:
- **Organisations you owned** — Completed and in-progress documents are retained in an anonymized form
(reassigned to an internal system account) so the other parties keep their records. Draft documents
and templates are permanently removed.
- **Organisations you were a member of** — Your documents and templates are transferred to the
organisation owner, so they remain accessible to the organisation after you leave.
<Callout type="warn">
Documents that are retained in anonymized form are no longer associated with your account and cannot
be recovered or accessed by you after deletion. Download anything you need to keep beforehand.
</Callout>
---
## See Also
+2 -2
View File
@@ -16,7 +16,7 @@
"fumadocs-ui": "16.5.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.2.4",
"next": "16.2.6",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
@@ -29,7 +29,7 @@
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"postcss": "^8.5.14",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.2.4"
"next": "16.2.6"
},
"devDependencies": {
"@types/node": "^20",
+8 -1
View File
@@ -73,5 +73,12 @@ if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
echo "╚═════════════════════════════════════════════════════════════════════╝"
fi
NEXT_PUBLIC_WEBAPP_URL=$(load_env_var "NEXT_PUBLIC_WEBAPP_URL")
if [ -z "$NEXT_PUBLIC_WEBAPP_URL" ]; then
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
echo "[INFO]: NEXT_PUBLIC_WEBAPP_URL not set, defaulting to $NEXT_PUBLIC_WEBAPP_URL"
fi
echo "[INFO]: Starting Stripe webhook listener..."
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to "$NEXT_PUBLIC_WEBAPP_URL/api/stripe/webhook"
@@ -0,0 +1,155 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
export type AdminOrganisationSyncSubscriptionDialogProps = {
organisationId: string;
trigger?: React.ReactNode;
};
const ZAdminOrganisationSyncSubscriptionFormSchema = z.object({
syncClaims: z.boolean(),
});
type TAdminOrganisationSyncSubscriptionFormSchema = z.infer<typeof ZAdminOrganisationSyncSubscriptionFormSchema>;
export const AdminOrganisationSyncSubscriptionDialog = ({
organisationId,
trigger,
}: AdminOrganisationSyncSubscriptionDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const form = useForm<TAdminOrganisationSyncSubscriptionFormSchema>({
resolver: zodResolver(ZAdminOrganisationSyncSubscriptionFormSchema),
defaultValues: {
syncClaims: false,
},
});
const { mutateAsync: syncSubscription } = trpc.admin.organisation.subscription.sync.useMutation();
const onFormSubmit = async (values: TAdminOrganisationSyncSubscriptionFormSchema) => {
try {
await syncSubscription({
organisationId,
syncClaims: values.syncClaims,
});
toast({
title: t`Subscription synced`,
description: t`The organisation subscription has been synced with Stripe.`,
duration: 5000,
});
await navigate(0);
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`Failed to sync subscription`,
description: error.message,
variant: 'destructive',
duration: 10000,
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sync Stripe subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fetch the latest subscription data from Stripe and apply it to this organisation.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="syncClaims"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<Checkbox
id="admin-sync-subscription-sync-claims"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="admin-sync-subscription-sync-claims"
className="font-normal text-muted-foreground text-sm leading-snug"
>
<Trans>
Sync claims. This will overwrite the current claim with the one resolved from the Stripe
subscription.
</Trans>
</label>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Sync</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -67,7 +67,7 @@ export const AdminSwapSubscriptionDialog = ({
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
const { mutateAsync: swapSubscription } = trpc.admin.organisation.subscription.swap.useMutation();
const onSubmit = async () => {
if (!selectedOrgId) {
@@ -0,0 +1,152 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateUserRequestSchema } from '@documenso/trpc/server/admin-router/create-user.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
export type AdminUserCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZFormSchema = ZCreateUserRequestSchema;
type TFormSchema = z.infer<typeof ZFormSchema>;
export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const form = useForm<TFormSchema>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
email: '',
name: '',
},
});
const { mutateAsync: createUser } = trpc.admin.user.create.useMutation();
const onFormSubmit = async (data: TFormSchema) => {
try {
const result = await createUser(data);
await navigate(`/admin/users/${result.userId}`);
setOpen(false);
toast({
title: t`Success`,
description: t`User created and welcome email sent`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: error.message || t`We encountered an error while creating the user. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create User</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create User</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create a new user. A welcome email will be sent with a link to set their password.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" data-testid="dialog-create-user-button" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -2,6 +2,7 @@ import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
@@ -28,6 +29,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [backportEmailTransport, setBackportEmailTransport] = useState(false);
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
onSuccess: () => {
@@ -67,19 +69,33 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
await updateClaim({
id: claim.id,
data,
backportEmailTransport,
})
}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<>
<div className="flex items-center space-x-2">
<Checkbox
id="backport-email-transport"
checked={backportEmailTransport}
onCheckedChange={(checked) => setBackportEmailTransport(checked === true)}
/>
<label htmlFor="backport-email-transport" className="text-muted-foreground text-sm">
<Trans>Backport email transport</Trans>
</label>
</div>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
</>
}
/>
</DialogContent>
@@ -1,5 +1,6 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
@@ -30,6 +31,7 @@ import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useCurrentTeam } from '~/providers/team';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
@@ -99,9 +101,12 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be re-sent at this time. Please try again.`),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -0,0 +1,95 @@
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
EmailTransportForm,
type EmailTransportFormValues,
emailTransportFormToConfig,
} from '../forms/email-transport-form';
export type EmailTransportCreateDialogProps = {
trigger?: React.ReactNode;
};
export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: createTransport, isPending } = trpc.admin.emailTransport.create.useMutation({
onSuccess: () => {
toast({
title: t`Transport created.`,
});
setOpen(false);
},
onError: (error) => {
toast({
title: t`Failed to create transport.`,
description: error.message,
variant: 'destructive',
});
},
});
const onFormSubmit = async (values: EmailTransportFormValues) => {
await createTransport({
name: values.name,
fromName: values.fromName,
fromAddress: values.fromAddress,
config: emailTransportFormToConfig(values),
});
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="flex-shrink-0">
<Trans>Add transport</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fill in the details to create a new email transport.</Trans>
</DialogDescription>
</DialogHeader>
<EmailTransportForm
onFormSubmit={onFormSubmit}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,114 @@
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
export type EmailTransportDeleteDialogProps = {
transportId: string;
transportName: string;
subscriptionClaimCount: number;
organisationClaimCount: number;
trigger: React.ReactNode;
};
export const EmailTransportDeleteDialog = ({
transportId,
transportName,
subscriptionClaimCount,
organisationClaimCount,
trigger,
}: EmailTransportDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const isInUse = subscriptionClaimCount + organisationClaimCount > 0;
const { mutateAsync: deleteTransport, isPending } = trpc.admin.emailTransport.delete.useMutation({
onSuccess: () => {
toast({
title: t`Transport deleted.`,
});
setOpen(false);
},
onError: () => {
toast({
title: t`Failed to delete transport.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Are you sure you want to delete the following transport?</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">{transportName}</AlertDescription>
</Alert>
{isInUse && (
<Alert variant="destructive">
<AlertDescription>
<Trans>Warning, this email transport is currently being used by:</Trans>
<ul className="mt-2 list-disc pl-5">
{subscriptionClaimCount > 0 && (
<li>
<Plural value={subscriptionClaimCount} one="# Subscription claim" other="# Subscription claims" />
</li>
)}
{organisationClaimCount > 0 && (
<li>
<Plural value={organisationClaimCount} one="# Organisation claim" other="# Organisation claims" />
</li>
)}
</ul>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () => deleteTransport({ id: transportId })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,126 @@
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZSendTestEmailFormSchema = z.object({
to: z.string().email(),
});
type TSendTestEmailFormSchema = z.infer<typeof ZSendTestEmailFormSchema>;
export type EmailTransportSendTestDialogProps = {
transportId: string;
trigger: React.ReactNode;
};
export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTransportSendTestDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: sendTest } = trpc.admin.emailTransport.sendTest.useMutation({
onSuccess: () => {
toast({
title: t`Test email sent.`,
});
setOpen(false);
},
onError: (error) => {
toast({
title: t`Test failed.`,
description: error.message,
variant: 'destructive',
});
},
});
const form = useForm<TSendTestEmailFormSchema>({
resolver: zodResolver(ZSendTestEmailFormSchema),
defaultValues: {
to: '',
},
});
const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => {
await sendTest({ id: transportId, to });
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Send Test Email</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Send a test email using this transport to verify the configuration.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" placeholder={t`test@example.com`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Send</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,104 @@
import { trpc } from '@documenso/trpc/react';
import type { TFindEmailTransportsResponse } from '@documenso/trpc/server/admin-router/email-transport/find-email-transports.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
EmailTransportForm,
type EmailTransportFormValues,
emailTransportFormToConfig,
} from '../forms/email-transport-form';
export type EmailTransportUpdateDialogProps = {
transport: TFindEmailTransportsResponse['data'][number];
trigger: React.ReactNode;
};
export const EmailTransportUpdateDialog = ({ transport, trigger }: EmailTransportUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: updateTransport, isPending } = trpc.admin.emailTransport.update.useMutation();
const onFormSubmit = async (values: EmailTransportFormValues) => {
try {
await updateTransport({
id: transport.id,
data: {
name: values.name,
fromName: values.fromName,
fromAddress: values.fromAddress,
config: emailTransportFormToConfig(values),
},
});
toast({
title: t`Transport updated.`,
});
setOpen(false);
} catch {
toast({
title: t`Failed to save transport.`,
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Edit Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Modify the details of the email transport.</Trans>
</DialogDescription>
</DialogHeader>
<EmailTransportForm
isEdit
defaultValues={{
// Pre-fill the non-secret connection settings; secrets stay blank
// and are preserved on save unless re-entered.
...(transport.config ?? {}),
name: transport.name,
fromName: transport.fromName,
fromAddress: transport.fromAddress,
type: transport.type,
}}
onFormSubmit={onFormSubmit}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Save changes</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};
@@ -1,6 +1,7 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -37,6 +38,7 @@ import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type EnvelopeDistributeDialogProps = {
onDistribute?: () => Promise<void>;
@@ -66,7 +68,7 @@ export const EnvelopeDistributeDialog = ({
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
const { toast } = useToast();
const { t } = useLingui();
const { t, i18n } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
@@ -174,9 +176,13 @@ export const EnvelopeDistributeDialog = ({
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: t`Something went wrong`,
description: t`This envelope could not be distributed at this time. Please try again.`,
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -1,4 +1,5 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
@@ -25,7 +26,7 @@ import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
@@ -47,7 +48,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
const recipients = envelope.recipients;
const { toast } = useToast();
const { t } = useLingui();
const { t, i18n } = useLingui();
const [isOpen, setIsOpen] = useState(false);
@@ -77,9 +78,12 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: t`Something went wrong`,
description: t`This envelope could not be resent at this time. Please try again.`,
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -222,7 +222,7 @@ export const ManagePublicTemplateDialog = ({
.with({ currentStep: 'SELECT_TEMPLATE' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
<DialogTitle className="w-full max-w-full whitespace-pre-line break-words">
{team?.name ? (
<Trans>{team.name} direct signing templates</Trans>
) : (
@@ -4,7 +4,7 @@ import {
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
@@ -35,8 +35,8 @@ import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
@@ -180,22 +180,11 @@ export function TemplateUseDialog({
await navigate(documentPath);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('DOCUMENT_SEND_FAILED', () => msg`The document was created but could not be sent to recipients.`)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
() =>
msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
)
.with(AppErrorCode.NOT_FOUND, () => msg`The template or one of its recipients could not be found.`)
.with(AppErrorCode.LIMIT_EXCEEDED, () => msg`You have reached your document limit for this plan.`)
.otherwise(() => msg`An error occurred while creating document from template.`);
const errorMessage = getTemplateUseErrorMessage(error.code);
toast({
title: _(msg`Error`),
description: _(errorMessage),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});
}
@@ -3,6 +3,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -42,6 +43,7 @@ import { useSearchParams } from 'react-router';
import { BrandingLogo } from '~/components/general/branding-logo';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
@@ -259,9 +261,12 @@ export const EmbedDirectTemplateClientPage = ({
);
}
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});
}
@@ -116,11 +116,12 @@ export const EditorFieldDropdownForm = ({
}
const newValues = [...currentValues];
const removedValue = currentValues[index].value;
newValues.splice(index, 1);
form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
if (form.getValues('defaultValue') === removedValue) {
form.setValue('defaultValue', undefined);
}
};
@@ -0,0 +1,317 @@
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(),
port: z.coerce.number().int().positive().optional(),
secure: z.boolean().optional(),
ignoreTLS: z.boolean().optional(),
username: z.string().optional(),
password: z.string().optional(),
service: z.string().optional(),
apiKey: z.string().optional(),
apiKeyUser: z.string().optional(),
endpoint: z.string().optional(),
});
export type EmailTransportFormValues = z.infer<typeof ZEmailTransportFormSchema>;
type EmailTransportFormProps = {
defaultValues?: Partial<EmailTransportFormValues>;
isEdit?: boolean;
onFormSubmit: (values: EmailTransportFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
};
export const EmailTransportForm = ({
defaultValues,
isEdit = false,
onFormSubmit,
formSubmitTrigger,
}: EmailTransportFormProps) => {
const { t } = useLingui();
const form = useForm<EmailTransportFormValues>({
resolver: zodResolver(ZEmailTransportFormSchema),
defaultValues: {
name: '',
fromName: '',
fromAddress: '',
type: 'SMTP_AUTH',
secure: false,
ignoreTLS: false,
...defaultValues,
},
});
const type = form.watch('type');
const secretPlaceholder = isEdit ? t`Leave blank to keep current` : undefined;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`e.g. Resend (free plans)`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="fromName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>From name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fromAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>From address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Transport type</Trans>
</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={isEdit}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="SMTP_AUTH">SMTP (auth)</SelectItem>
<SelectItem value="SMTP_API">SMTP (api)</SelectItem>
<SelectItem value="RESEND">Resend</SelectItem>
<SelectItem value="MAILCHANNELS">MailChannels</SelectItem>
</SelectContent>
</Select>
{isEdit && (
<FormDescription>
<Trans>Transport type cannot be changed after creation.</Trans>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{(type === 'SMTP_AUTH' || type === 'SMTP_API') && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Host</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Port</Trans>
</FormLabel>
<FormControl>
<Input type="number" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{type === 'SMTP_AUTH' && (
<>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === 'SMTP_API' && (
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>API key</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{(type === 'RESEND' || type === 'MAILCHANNELS') && (
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>API key</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === 'MAILCHANNELS' && (
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Endpoint (optional)</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{formSubmitTrigger}
</fieldset>
</form>
</Form>
);
};
/**
* Maps flat form values to the tRPC `config` discriminated union.
*/
export const emailTransportFormToConfig = (values: EmailTransportFormValues) => {
switch (values.type) {
case 'SMTP_AUTH':
return {
type: 'SMTP_AUTH' as const,
host: values.host ?? '',
port: values.port ?? 587,
secure: values.secure ?? false,
ignoreTLS: values.ignoreTLS ?? false,
username: values.username || undefined,
password: values.password || undefined,
service: values.service || undefined,
};
case 'SMTP_API':
return {
type: 'SMTP_API' as const,
host: values.host ?? '',
port: values.port ?? 587,
secure: values.secure ?? false,
apiKey: values.apiKey || '',
apiKeyUser: values.apiKeyUser || undefined,
};
case 'RESEND':
return { type: 'RESEND' as const, apiKey: values.apiKey || '' };
case 'MAILCHANNELS':
return {
type: 'MAILCHANNELS' as const,
apiKey: values.apiKey || '',
endpoint: values.endpoint || undefined,
};
}
};
@@ -197,7 +197,9 @@ export const PublicProfileForm = ({ className, profile, onProfileUpdate }: Publi
return (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormLabel>
<Trans>Bio</Trans>
</FormLabel>
<FormControl>
<Textarea {...field} placeholder={_(msg`Write a description to display on your public profile`)} />
</FormControl>
+23 -15
View File
@@ -89,7 +89,6 @@ export const SignInForm = ({
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const twoFactorTurnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
@@ -197,13 +196,31 @@ export const SignInForm = ({
};
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
const $turnstile = isTwoFactorAuthenticationDialogOpen ? twoFactorTurnstileRef.current : turnstileRef.current;
try {
let token: string | undefined;
if (turnstileSiteKey) {
token = await $turnstile?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signIn({
email,
password,
totpCode,
backupCode,
captchaToken: captchaToken ?? undefined,
captchaToken: token ?? undefined,
redirectPath,
});
} catch (err) {
@@ -214,10 +231,6 @@ export const SignInForm = ({
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
setIsTwoFactorAuthenticationDialogOpen(true);
// Turnstile tokens are single-use. Clear the consumed one so the
// dialog's fresh widget mounts cleanly and the dialog can't be
// submitted with the stale token before a new one is issued.
setCaptchaToken(null);
return;
}
@@ -247,8 +260,7 @@ export const SignInForm = ({
variant: 'destructive',
});
turnstileRef.current?.reset();
setCaptchaToken(null);
$turnstile?.reset();
}
};
@@ -358,11 +370,9 @@ export const SignInForm = ({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
)}
@@ -499,11 +509,9 @@ export const SignInForm = ({
<Turnstile
ref={twoFactorTurnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
</div>
@@ -518,7 +526,7 @@ export const SignInForm = ({
)}
</Button>
<Button type="submit" loading={isSubmitting} disabled={Boolean(turnstileSiteKey) && !captchaToken}>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</DialogFooter>
+20 -8
View File
@@ -20,7 +20,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
@@ -49,6 +49,7 @@ export const ZSignUpFormSchema = z
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
SIGNUP_DISPOSABLE_EMAIL: msg`Disposable email addresses are not allowed. Please sign up with a permanent email address.`,
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
};
@@ -86,8 +87,6 @@ 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 = isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
const form = useForm<TSignUpFormSchema>({
@@ -105,12 +104,28 @@ export const SignUpForm = ({
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
try {
let token: string | undefined;
if (turnstileSiteKey) {
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signUp({
name,
email,
password,
signature,
captchaToken: captchaToken ?? undefined,
captchaToken: token ?? undefined,
});
await navigate(returnTo ? returnTo : '/unverified-account');
@@ -140,7 +155,6 @@ export const SignUpForm = ({
});
turnstileRef.current?.reset();
setCaptchaToken(null);
}
};
@@ -316,11 +330,9 @@ export const SignUpForm = ({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
)}
@@ -1,5 +1,6 @@
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -13,6 +14,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
@@ -20,6 +22,8 @@ import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import type { z } from 'zod';
import { ClaimLimitFields } from '../general/claim-limit-fields';
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
type SubscriptionClaimFormProps = {
@@ -49,10 +53,22 @@ export const SubscriptionClaimForm = ({
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
flags: subscriptionClaim.flags,
documentRateLimits: subscriptionClaim.documentRateLimits,
documentQuota: subscriptionClaim.documentQuota,
emailRateLimits: subscriptionClaim.emailRateLimits,
emailQuota: subscriptionClaim.emailQuota,
apiRateLimits: subscriptionClaim.apiRateLimits,
apiQuota: subscriptionClaim.apiQuota,
emailTransportId: subscriptionClaim.emailTransportId ?? null,
},
});
const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 });
const transports = transportsData?.data ?? [];
const NONE_VALUE = '__none__';
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
@@ -145,6 +161,30 @@ export const SubscriptionClaimForm = ({
)}
/>
<FormField
control={form.control}
name="recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
@@ -203,6 +243,42 @@ export const SubscriptionClaimForm = ({
)}
</div>
<ClaimLimitFields control={form.control} disabled={form.formState.isSubmitting} />
<FormField
control={form.control}
name="emailTransportId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email transport</Trans>
</FormLabel>
<Select
value={field.value ?? NONE_VALUE}
onValueChange={(value) => field.onChange(value === NONE_VALUE ? null : value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Default (system mailer)`} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}>{t`Default (system mailer)`}</SelectItem>
{transports.map((transport) => (
<SelectItem key={transport.id} value={transport.id}>
{transport.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
<Trans>Plans without a transport use the system default mailer.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{formSubmitTrigger}
</fieldset>
</form>
@@ -0,0 +1,171 @@
import {
SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
type TSiteSettingsEmailBlocklistSchema,
} from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
const ZEmailBlocklistFormSchema = z.object({
enabled: z.boolean(),
domains: z.string(),
});
type TEmailBlocklistFormSchema = z.infer<typeof ZEmailBlocklistFormSchema>;
/**
* Splits a comma-separated string into a normalised list of domains.
* Normalisation (trim, lowercase, strip leading "@", dedupe) is applied
* server-side by the schema as well — this is for display consistency.
*/
const parseDomainsInput = (value: string): string[] => {
return Array.from(
new Set(
value
.split(',')
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
.filter((entry) => entry.length > 0),
),
);
};
type AdminEmailBlocklistSectionProps = {
emailBlocklist: TSiteSettingsEmailBlocklistSchema | undefined;
};
export const AdminEmailBlocklistSection = ({ emailBlocklist }: AdminEmailBlocklistSectionProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TEmailBlocklistFormSchema>({
resolver: zodResolver(ZEmailBlocklistFormSchema),
defaultValues: {
enabled: emailBlocklist?.enabled ?? false,
domains: (emailBlocklist?.data?.domains ?? []).join(', '),
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBlocklistUpdate = async ({ enabled, domains }: TEmailBlocklistFormSchema) => {
try {
const parsedDomains = parseDomainsInput(domains);
await updateSiteSetting({
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
enabled,
data: {
domains: parsedDomains,
},
});
// Reflect the normalised value back in the form.
form.reset({
enabled,
domains: parsedDomains.join(', '),
});
toast({
title: _(msg`Email Blocklist Updated`),
description: _(msg`The email blocklist has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the email blocklist. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Email Blocklist</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Block signups from additional email domains on top of the bundled disposable email list. Subdomains are
matched automatically (e.g. blocking "bad.com" also blocks "foo.bad.com").
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBlocklistUpdate)}>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="mt-4" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Blocked Domains</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" placeholder="bad.com, spam.net, throwaway.io" {...field} />
</FormControl>
<FormDescription>
<Trans>Comma-separated list of email domains to block from signing up.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Blocklist</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,197 @@
import {
SITE_SETTINGS_BANNER_ID,
type TSiteSettingsBannerSchema,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { useCspNonce } from '~/utils/nonce';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
type AdminSiteBannerSectionProps = {
banner: TSiteSettingsBannerSchema | undefined;
};
export const AdminSiteBannerSection = ({ banner }: AdminSiteBannerSectionProps) => {
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -54,7 +54,6 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
if (plan[interval] && plan[interval].isVisibleInApp) {
prices.push({
...plan[interval],
memberCount: plan.memberCount,
claim: plan.id,
});
}
@@ -120,12 +119,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
<Trans>Subscribe</Trans>
</IndividualPersonalLayoutCheckoutButton>
) : (
<BillingDialog
priceId={price.id}
planName={price.product.name}
memberCount={price.memberCount}
claim={price.claim}
/>
<BillingDialog priceId={price.id} planName={price.product.name} claim={price.claim} />
)}
</CardContent>
</MotionCard>
@@ -136,16 +130,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
);
};
const BillingDialog = ({
priceId,
planName,
claim,
}: {
priceId: string;
planName: string;
memberCount: number;
claim: string;
}) => {
const BillingDialog = ({ priceId, planName, claim }: { priceId: string; planName: string; claim: string }) => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useLingui();
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -12,6 +13,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
@@ -50,6 +54,9 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
const analytics = useAnalytics();
const navigate = useNavigate();
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const form = useForm<TClaimAccountFormSchema>({
values: {
name: defaultName ?? '',
@@ -61,7 +68,28 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
try {
await authClient.emailPassword.signUp({ name, email, password });
let token: string | undefined;
if (turnstileSiteKey) {
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signUp({
name,
email,
password,
captchaToken: token ?? undefined,
});
await navigate(`/unverified-account`);
@@ -87,6 +115,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
description: _(errorMessage),
variant: 'destructive',
});
turnstileRef.current?.reset();
}
};
@@ -141,6 +171,19 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
)}
/>
{turnstileSiteKey && (
<div className="mt-4">
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{
size: 'flexible',
appearance: 'always',
}}
/>
</div>
)}
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
<Trans>Claim account</Trans>
</Button>
@@ -0,0 +1,97 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react';
import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input';
type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
prefix?: string;
disabled?: boolean;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
disabled,
}: ClaimLimitFieldsProps<T>) => {
const { t } = useLingui();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>;
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
<FormField
control={control}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type="number"
min={0}
disabled={disabled}
value={field.value === null || field.value === undefined ? '' : field.value}
placeholder={t`Unlimited`}
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
const renderRateLimitField = (key: string, label: ReactNode) => (
<FormField
control={control}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
return (
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
{renderQuotaField(
'emailQuota',
<Trans>Monthly email quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
</div>
);
};
@@ -1,4 +1,5 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -17,6 +18,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form';
import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form';
@@ -120,9 +122,12 @@ export const DirectTemplatePageView = ({
await navigate(`/sign/${token}/complete`);
}
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});
@@ -97,7 +97,7 @@ export const DocumentSigningMobileWidget = () => {
layoutId="document-signing-mobile-widget-progress-bar"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
width: `${requiredRecipientFields.length === 0 ? 100 : 100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
@@ -50,6 +50,11 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
type DocumentSigningBranding = {
brandingEnabled: boolean;
brandingLogo: string;
};
export type DocumentSigningPageViewV1Props = {
recipient: RecipientWithFields;
document: DocumentAndSender;
@@ -57,6 +62,7 @@ export type DocumentSigningPageViewV1Props = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
branding: DocumentSigningBranding;
includeSenderDetails: boolean;
};
@@ -68,6 +74,7 @@ export const DocumentSigningPageViewV1 = ({
isRecipientsTurn,
allRecipients = [],
includeSenderDetails,
branding,
}: DocumentSigningPageViewV1Props) => {
const { documentData, documentMeta } = document;
@@ -142,7 +149,7 @@ export const DocumentSigningPageViewV1 = ({
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
const sortedRecipients = [...allRecipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
@@ -168,10 +175,12 @@ export const DocumentSigningPageViewV1 = ({
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
const hasCustomBrandingLogo = branding.brandingEnabled && Boolean(branding.brandingLogo);
return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
{document.team.teamGlobalSettings.brandingEnabled && document.team.teamGlobalSettings.brandingLogo && (
{hasCustomBrandingLogo && (
<img
src={`/api/branding/logo/team/${document.teamId}`}
alt={`${document.team.name}'s Logo`}
@@ -150,7 +150,7 @@ export const DocumentSigningPageViewV2 = () => {
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
width: `${requiredRecipientFields.length === 0 ? 100 : 100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
@@ -294,7 +294,7 @@ export const EnvelopeSigningProvider = ({
return null;
}
const sortedRecipients = envelope.recipients.sort((a, b) => {
const sortedRecipients = [...envelope.recipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
@@ -1,6 +1,7 @@
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -25,9 +26,9 @@ import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type DocumentEditFormProps = {
className?: string;
@@ -387,9 +388,12 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
} catch (err) {
console.error(err);
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while sending the document.`),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
});
}
@@ -3,7 +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 { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
@@ -20,9 +20,9 @@ import { EnvelopeType } from '@prisma/client';
import { useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
export type DocumentUploadButtonLegacyProps = {
className?: string;
@@ -130,30 +130,11 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'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.`);
const errorMessage = getUploadErrorMessage(error.code);
toast({
title: _(msg`Error`),
description: _(errorMessage),
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -14,13 +14,22 @@ import {
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { CommandDialog } from '@documenso/ui/primitives/command';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
@@ -470,6 +479,22 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
}
};
const changeSelectedFieldsType = (type: FieldType) => {
const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id()))
.filter((field) => field !== undefined);
for (const field of fields) {
if (field.type !== type) {
editorFields.updateFieldByFormId(field.formId, {
type,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
id: undefined,
});
}
}
};
const duplicatedSelectedFields = () => {
const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id()))
@@ -554,6 +579,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
@@ -602,6 +628,7 @@ type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
handleDuplicateSelectedFieldsOnAllPages: () => void;
handleDeleteSelectedFields: () => void;
handleChangeRecipient: (recipientId: number) => void;
handleChangeFieldType: (type: FieldType) => void;
selectedFieldFormId: string[];
};
@@ -610,15 +637,40 @@ const FieldActionButtons = ({
handleDuplicateSelectedFieldsOnAllPages,
handleDeleteSelectedFields,
handleChangeRecipient,
handleChangeFieldType,
selectedFieldFormId,
...props
}: FieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
/**
* Decide the preselected field type in the command input.
*
* If all fields share the same type, use that as the default selection.
* Otherwise show no preselection.
*/
const preselectedFieldType = useMemo(() => {
if (selectedFieldFormId.length === 0) {
return null;
}
const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId));
if (fields.length === 0) {
return null;
}
const firstType = fields[0].type;
const isTypesSame = fields.every((field) => field.type === firstType);
return isTypesSame ? firstType : null;
}, [editorFields.localFields, selectedFieldFormId]);
/**
* Decide the preselected recipient in the command input.
*
@@ -656,6 +708,7 @@ const FieldActionButtons = ({
<div className="flex flex-col items-center" {...props}>
<div className="group flex w-fit items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
<button
type="button"
title={t`Change Recipient`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => setShowRecipientSelector(true)}
@@ -665,6 +718,17 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Change Field Type`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => setShowFieldTypeSelector(true)}
onTouchEnd={() => setShowFieldTypeSelector(true)}
>
<ShapesIcon className="h-3 w-3" />
</button>
<button
type="button"
title={t`Duplicate`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDuplicateSelectedFields}
@@ -674,6 +738,7 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Duplicate on all pages`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDuplicateSelectedFieldsOnAllPages}
@@ -683,6 +748,7 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Remove`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDeleteSelectedFields}
@@ -705,6 +771,41 @@ const FieldActionButtons = ({
fields={envelope.fields}
/>
</CommandDialog>
<CommandDialog position="start" open={showFieldTypeSelector} onOpenChange={setShowFieldTypeSelector}>
<Command defaultValue={preselectedFieldType ? t(FRIENDLY_FIELD_TYPE[preselectedFieldType]) : undefined}>
<CommandInput placeholder={t`Select a field type`} />
<CommandList>
<CommandEmpty>
<span className="inline-block px-4 text-muted-foreground">
{t`No field type matching this description was found.`}
</span>
</CommandEmpty>
<CommandGroup>
{fieldButtonList.map((field) => {
const FieldIcon = field.icon;
const label = t(FRIENDLY_FIELD_TYPE[field.type]);
return (
<CommandItem
key={field.type}
className="px-2"
onSelect={() => {
handleChangeFieldType(field.type);
setShowFieldTypeSelector(false);
}}
>
<FieldIcon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
</div>
);
};
@@ -16,6 +16,7 @@ import {
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -563,6 +564,9 @@ export const EnvelopeEditorRecipientForm = () => {
}
}, [formValues]);
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
return (
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
@@ -627,6 +631,17 @@ export const EnvelopeEditorRecipientForm = () => {
</CardHeader>
<CardContent>
{isOverRecipientLimit && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>
<Trans>
This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need
more.
</Trans>
</AlertDescription>
</Alert>
)}
<Form {...form}>
<div
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
@@ -9,7 +9,7 @@ import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZDocumentAccessAuthTypesSchema, ZDocumentActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { DocumentEmailEvents, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
type TDocumentMetaDateFormat,
ZDocumentMetaDateFormatSchema,
@@ -39,6 +39,7 @@ import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expira
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import { TemplateTypeSelect, TemplateTypeTooltip } from '@documenso/ui/components/template/template-type-select';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Combobox } from '@documenso/ui/primitives/combobox';
@@ -114,7 +115,7 @@ export const ZAddSettingsFormSchema = z.object({
}),
});
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'notifications' | 'security';
const tabs = [
{
@@ -130,10 +131,10 @@ const tabs = [
description: msg`Configure signing reminder settings for the document.`,
},
{
id: 'email',
title: msg`Email`,
id: 'notifications',
title: msg`Notifications`,
icon: MailIcon,
description: msg`Configure email settings for the document.`,
description: msg`Configure notification settings for the document.`,
},
{
id: 'security',
@@ -143,6 +144,18 @@ const tabs = [
},
] as const;
// Recipient-facing notification events. These are suppressed at send time
// when distributionMethod is not EMAIL (see extractDerivedDocumentEmailSettings),
// so the UI mirrors that by disabling the matching checkboxes.
const RECIPIENT_EMAIL_EVENTS = [
DocumentEmailEvents.RecipientSigningRequest,
DocumentEmailEvents.RecipientRemoved,
DocumentEmailEvents.RecipientSigned,
DocumentEmailEvents.DocumentPending,
DocumentEmailEvents.DocumentCompleted,
DocumentEmailEvents.DocumentDeleted,
] as const;
type TAddSettingsFormSchema = z.infer<typeof ZAddSettingsFormSchema>;
type EnvelopeEditorSettingsDialogProps = {
@@ -205,6 +218,8 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
);
const emailSettings = form.watch('meta.emailSettings');
const distributionMethod = form.watch('meta.distributionMethod');
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
const { data: emailData, isLoading: isLoadingEmails } = trpc.enterprise.organisation.email.find.useQuery(
{
@@ -334,7 +349,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
{tabs.map((tab) => {
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
if (tab.id === 'notifications' && !settings.allowConfigureDistribution) {
return null;
}
@@ -730,7 +745,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
)}
/>
))
.with({ activeTab: 'email', settings: { allowConfigureDistribution: true } }, () => (
.with({ activeTab: 'notifications', settings: { allowConfigureDistribution: true } }, () => (
<>
{settings.allowConfigureEmailSender && organisation.organisationClaim.flags.emailDomains && (
<FormField
@@ -747,6 +762,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
disabled={!isEmailDistribution}
>
<SelectTrigger loading={isLoadingEmails} className="bg-background">
<SelectValue />
@@ -783,7 +799,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} disabled={!isEmailDistribution} />
</FormControl>
<FormMessage />
@@ -804,7 +820,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} disabled={!isEmailDistribution} />
</FormControl>
<FormMessage />
@@ -832,7 +848,11 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
<Textarea
className="h-16 resize-none bg-background"
{...field}
disabled={!isEmailDistribution}
/>
</FormControl>
<FormMessage />
@@ -843,7 +863,19 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
hiddenEvents={isEmailDistribution ? undefined : RECIPIENT_EMAIL_EVENTS}
/>
{!isEmailDistribution && (
<Alert variant="warning">
<AlertDescription>
<Trans>
Email distribution needs to be enabled in the general settings tab to configure recipient
email related settings.
</Trans>
</AlertDescription>
</Alert>
)}
</>
))
.with({ activeTab: 'security' }, () => (
@@ -27,27 +27,25 @@ export const EnvelopeSignerHeader = () => {
const { envelopeData, envelope, recipientFieldsRemaining, recipient } = useRequiredEnvelopeSigningContext();
const isEmbedSigning = useEmbedSigningContext() !== null;
const hasCustomBrandingLogo = envelopeData.settings.brandingEnabled && Boolean(envelopeData.settings.brandingLogo);
return (
<nav className="embed--DocumentWidgetHeader flex max-w-screen flex-row justify-between border-border border-b bg-background px-4 py-3 md:px-6">
{/* Left side - Logo and title */}
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
{!isEmbedSigning && (
<Link to="/" className="flex-shrink-0">
{envelopeData.settings.brandingEnabled && envelopeData.settings.brandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt={`${envelope.team.name}'s Logo`}
className="h-6 w-auto"
/>
) : (
<>
<BrandingLogo className="hidden h-6 w-auto md:block" />
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
</>
)}
</Link>
)}
{!isEmbedSigning &&
(hasCustomBrandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt={`${envelope.team.name}'s Logo`}
className="h-6 w-auto flex-shrink-0"
/>
) : (
<Link to="/" className="flex-shrink-0">
<BrandingLogo className="hidden h-6 w-auto md:block" />
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
</Link>
))}
<h1 title={envelope.title} className="min-w-0 truncate font-semibold text-base text-foreground md:hidden">
{envelope.title}
@@ -9,6 +9,10 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import {
createFieldCanvasStyleCache,
type FieldCanvasStyleCache,
} from '@documenso/lib/universal/field-renderer/field-canvas-style';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -22,7 +26,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole, type Signature, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { match } from 'ts-pattern';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
@@ -57,17 +61,31 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
recipientFieldsRemaining,
showPendingFieldTooltip,
signField: signFieldInternal,
email,
email: emailState,
setEmail,
fullName,
fullName: fullNameState,
setFullName,
signature,
signature: signatureState,
setSignature,
selectedAssistantRecipientFields,
selectedAssistantRecipient,
isDirectTemplate,
} = useRequiredEnvelopeSigningContext();
// Note: We're using refs here due to the closure within the signField function.
const fullName = useRef(fullNameState);
const email = useRef(emailState);
const signature = useRef(signatureState);
useEffect(() => {
fullName.current = fullNameState;
email.current = emailState;
signature.current = signatureState;
}, [fullNameState, emailState, signatureState]);
const cachedRenderFields = useRef<Map<number, Field & { signature?: Signature | null }>>(new Map());
const prevShowPendingFieldTooltip = useRef(showPendingFieldTooltip);
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
@@ -121,7 +139,10 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
});
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
const unsafeRenderFieldOnLayer = (
unparsedField: Field & { signature?: Signature | null },
fieldCanvasStyleCache: FieldCanvasStyleCache,
) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@@ -129,11 +150,9 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
const color = fieldToRender.fieldMeta?.readOnly
? 'readOnly'
: showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)
? 'orange'
: 'green';
const isValidating = showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender);
const color = fieldToRender.fieldMeta?.readOnly ? 'readOnly' : isValidating ? 'orange' : 'green';
const { fieldGroup } = renderField({
scale,
@@ -145,6 +164,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY),
isValidating,
signature: unparsedField.signature,
},
translations: getClientSideFieldTranslations(i18n),
@@ -152,6 +172,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
pageHeight: unscaledViewport.height,
color,
mode: 'sign',
fieldCanvasStyleCache,
});
const handleFieldGroupClick = (e: KonvaEventObject<Event>) => {
@@ -169,8 +190,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
return;
}
let localEmail: string | null = email;
let localFullName: string | null = fullName;
let localEmail: string | null = email.current;
let localFullName: string | null = fullName.current;
let placeholderEmail: string | null = null;
if (recipient.role === RecipientRole.ASSISTANT) {
@@ -180,7 +201,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
// Allows us let the user set a different email than their current logged in email.
if (isDirectTemplate) {
placeholderEmail = sessionData?.user?.email || email || recipient.email;
placeholderEmail = sessionData?.user?.email || email.current || recipient.email;
if (!placeholderEmail || placeholderEmail === DIRECT_TEMPLATE_RECIPIENT_EMAIL) {
placeholderEmail = null;
@@ -205,7 +226,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
return;
}
handleCheckboxFieldClick({ field, clickedCheckboxIndex })
void handleCheckboxFieldClick({ field, clickedCheckboxIndex })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
@@ -243,7 +264,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
* NUMBER FIELD.
*/
.with({ type: FieldType.NUMBER }, (field) => {
handleNumberFieldClick({ field, number: null })
void handleNumberFieldClick({ field, number: null })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
@@ -258,7 +279,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
* TEXT FIELD.
*/
.with({ type: FieldType.TEXT }, (field) => {
handleTextFieldClick({ field, text: null })
void handleTextFieldClick({ field, text: null })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
@@ -273,7 +294,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
* EMAIL FIELD.
*/
.with({ type: FieldType.EMAIL }, (field) => {
handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
void handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
@@ -294,7 +315,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
.with({ type: FieldType.INITIALS }, (field) => {
const initials = localFullName ? extractInitials(localFullName) : null;
handleInitialsFieldClick({ field, initials })
void handleInitialsFieldClick({ field, initials })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
@@ -309,7 +330,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
* NAME FIELD.
*/
.with({ type: FieldType.NAME }, (field) => {
handleNameFieldClick({ field, name: localFullName })
void handleNameFieldClick({ field, name: localFullName })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
@@ -328,14 +349,12 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
* DROPDOWN FIELD.
*/
.with({ type: FieldType.DROPDOWN }, (field) => {
handleDropdownFieldClick({ field, text: null })
void handleDropdownFieldClick({ field, text: null })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
loadingSpinnerGroup.destroy();
})
.finally(() => {
loadingSpinnerGroup.destroy();
@@ -358,32 +377,34 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
* SIGNATURE FIELD.
*/
.with({ type: FieldType.SIGNATURE }, (field) => {
handleSignatureFieldClick({
void handleSignatureFieldClick({
field,
fullName,
signature,
fullName: fullName.current,
signature: signature.current,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
})
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
if (!payload) {
return;
}
if (payload.value) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => {
await signField(field.id, payload, authOptions);
fieldGroup.add(loadingSpinnerGroup);
loadingSpinnerGroup.destroy();
},
actionTarget: field.type,
});
if (payload.value) {
await executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => {
await signField(field.id, payload, authOptions);
setSignature(payload.value);
} else {
await signField(field.id, payload);
}
loadingSpinnerGroup.destroy();
},
actionTarget: field.type,
});
setSignature(payload.value);
} else {
await signField(field.id, payload);
}
})
.finally(() => {
@@ -397,9 +418,12 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
fieldGroup.on('pointerdown', handleFieldGroupClick);
};
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
const renderFieldOnLayer = (
unparsedField: Field & { signature?: Signature | null },
fieldCanvasStyleCache: FieldCanvasStyleCache,
) => {
try {
unsafeRenderFieldOnLayer(unparsedField);
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
} catch (err) {
console.error(err);
setRenderError(true);
@@ -412,15 +436,28 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
return;
}
// Render current recipient fields.
const fieldCanvasStyleCache = createFieldCanvasStyleCache();
// Render current recipient fields which have changed or are not currently rendered.
for (const field of localPageFields) {
renderFieldOnLayer(field);
const existingCachedField = cachedRenderFields.current.get(field.id);
const isFieldCurrentlyRendered = pageLayer.current.findOne(`#${field.id}`);
if (
!isFieldCurrentlyRendered ||
!existingCachedField ||
existingCachedField.inserted !== field.inserted ||
existingCachedField.customText !== field.customText
) {
renderFieldOnLayer(field, fieldCanvasStyleCache);
cachedRenderFields.current.set(field.id, field);
}
}
// Render other recipient signed and inserted fields.
for (const field of localPageOtherRecipientFields) {
try {
renderField({
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
@@ -438,7 +475,13 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
color: 'readOnly',
editable: false,
mode: 'sign',
fieldCanvasStyleCache,
});
// Other-recipient fields are display-only — they have no click handlers
// and shouldn't intercept events meant for the current recipient's
// fields. Disable hit detection on the entire group.
fieldGroup.listening(false);
} catch (err) {
console.error('Unable to render one or more fields belonging to other recipients.');
console.error(err);
@@ -490,10 +533,19 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
return;
}
// When the pending-field tooltip toggles, all unsigned required fields need to
// be re-rendered so their stroke color updates (green <-> orange). Field-level
// properties like `inserted` and `customText` haven't changed, so the cache
// would otherwise skip them — clear it to force a fresh render.
if (prevShowPendingFieldTooltip.current !== showPendingFieldTooltip) {
cachedRenderFields.current.clear();
prevShowPendingFieldTooltip.current = showPendingFieldTooltip;
}
renderFields();
pageLayer.current.batchDraw();
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
}, [localPageFields, showPendingFieldTooltip]);
/**
* Rerender the whole page if the selected assistant recipient changes.
@@ -505,6 +557,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
// Rerender the whole page.
pageLayer.current.destroyChildren();
cachedRenderFields.current.clear();
renderFields();
@@ -5,7 +5,7 @@ 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 { AppError } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
@@ -20,9 +20,9 @@ import { Loader } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
export interface EnvelopeDropZoneWrapperProps {
children: ReactNode;
@@ -109,27 +109,11 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => 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.`);
const errorMessage = getUploadErrorMessage(error.code);
toast({
title: t`Error`,
description: errorMessage,
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -2,7 +2,7 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
@@ -17,9 +17,9 @@ import { EnvelopeType } from '@prisma/client';
import { useMemo, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
import { getUploadErrorMessage } from '~/utils/toast-error-messages';
export type EnvelopeUploadButtonProps = {
className?: string;
@@ -112,27 +112,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => 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.`);
const errorMessage = getUploadErrorMessage(error.code);
toast({
title: t`Error`,
description: errorMessage,
title: i18n._(errorMessage.title),
description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -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 || (
@@ -0,0 +1,117 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import { useState } from 'react';
import { match } from 'ts-pattern';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type OrganisationUsagePanelProps = {
organisationId: string;
monthlyStats: Pick<
OrganisationMonthlyStat,
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
organisationClaim: OrganisationClaim;
};
export const OrganisationUsagePanel = ({
organisationId,
monthlyStats,
organisationClaim,
}: OrganisationUsagePanelProps) => {
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
// Resetting a counter only affects the current month (the server hardcodes the
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [
{
counter: 'document' as const,
label: <Trans>Documents</Trans>,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email' as const,
label: <Trans>Emails</Trans>,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api' as const,
label: <Trans>API requests</Trans>,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3>
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{rows.map((row) => {
const percent =
row.effectiveLimit && row.effectiveLimit > 0
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
: 0;
return (
<div key={row.counter} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{row.label}</span>
<span className="text-muted-foreground">
{row.used} /{' '}
{match(row.effectiveLimit)
.with(null, () => <Trans>Unlimited</Trans>)
.with(0, () => <Trans>Blocked</Trans>)
.otherwise(String)}
</span>
</div>
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div>
)}
</div>
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
</div>
</div>
</div>
);
};
@@ -0,0 +1,38 @@
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = {
organisationId: string;
counter: 'document' | 'email' | 'api';
};
export const OrganisationUsageResetButton = ({ organisationId, counter }: OrganisationUsageResetButtonProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutateAsync: reset, isPending } = trpc.admin.organisation.stats.reset.useMutation({
onSuccess: async () => {
toast({ title: t`Counter reset.` });
await revalidate();
},
onError: () => {
toast({ title: t`Failed to reset counter.`, variant: 'destructive' });
},
});
return (
<Button
type="button"
variant="outline"
size="sm"
loading={isPending}
onClick={() => reset({ organisationId, counter })}
>
<Trans>Reset</Trans>
</Button>
);
};
@@ -1,5 +1,6 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -53,9 +54,9 @@ export const OrganisationBillingBanner = () => {
}
};
const subscriptionStatus = organisation?.subscription?.status;
const bannerVariant = getBannerVariant(organisation);
if (!organisation || subscriptionStatus === undefined || subscriptionStatus === SubscriptionStatus.ACTIVE) {
if (!organisation || bannerVariant === null) {
return null;
}
@@ -63,27 +64,28 @@ export const OrganisationBillingBanner = () => {
<>
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': subscriptionStatus === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground': subscriptionStatus === SubscriptionStatus.INACTIVE,
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': bannerVariant === 'PAST_DUE',
'bg-destructive text-destructive-foreground':
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
.with(SubscriptionStatus.INACTIVE, () => <Trans>Restricted Access</Trans>)
{match(bannerVariant)
.with('PAST_DUE', () => <Trans>Payment overdue</Trans>)
.with('INACTIVE', () => <Trans>Restricted Access</Trans>)
.with('PENDING_PAYMENT', () => <Trans>Payment required</Trans>)
.exhaustive()}
</div>
<Button
variant="outline"
className={cn({
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500': bannerVariant === 'PAST_DUE',
'text-destructive-foreground hover:bg-destructive hover:text-white':
subscriptionStatus === SubscriptionStatus.INACTIVE,
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
})}
disabled={isPending}
onClick={() => setIsOpen(true)}
@@ -95,8 +97,8 @@ export const OrganisationBillingBanner = () => {
</div>
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => (
{match(bannerVariant)
.with('PAST_DUE', () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -117,7 +119,7 @@ export const OrganisationBillingBanner = () => {
)}
</DialogContent>
))
.with(SubscriptionStatus.INACTIVE, () => (
.with('INACTIVE', () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -153,8 +155,66 @@ export const OrganisationBillingBanner = () => {
)}
</DialogContent>
))
.otherwise(() => null)}
.with('PENDING_PAYMENT', () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Payment required</Trans>
</DialogTitle>
<DialogDescription>
<Trans>This organisation is awaiting payment. Complete checkout to unlock it.</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription>
<Trans>
If there is any issue with your subscription, please contact us at{' '}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
</Trans>
</AlertDescription>
</Alert>
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<DialogFooter>
<DialogClose asChild>
<Button asChild>
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Manage Billing</Trans>
</Link>
</Button>
</DialogClose>
</DialogFooter>
)}
</DialogContent>
))
.exhaustive()}
</Dialog>
</>
);
};
type BannerVariant = 'PAST_DUE' | 'INACTIVE' | 'PENDING_PAYMENT';
const getBannerVariant = (organisation: ReturnType<typeof useOptionalCurrentOrganisation>): BannerVariant | null => {
if (!organisation) {
return null;
}
if (isOrganisationPendingPayment(organisation)) {
return 'PENDING_PAYMENT';
}
const subscriptionStatus = organisation.subscription?.status;
if (subscriptionStatus === SubscriptionStatus.PAST_DUE) {
return 'PAST_DUE';
}
if (subscriptionStatus === SubscriptionStatus.INACTIVE) {
return 'INACTIVE';
}
return null;
};
@@ -0,0 +1,168 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle } from 'lucide-react';
import { useState } from 'react';
export const OrganisationQuotaBanner = () => {
const [isOpen, setIsOpen] = useState(false);
const organisation = useOptionalCurrentOrganisation();
const { data: quotaFlags } = trpc.organisation.getQuotaFlags.useQuery(
{ organisationId: organisation?.id ?? '' },
{
enabled: Boolean(organisation),
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
...SKIP_QUERY_BATCH_META,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: false,
},
);
const isAnyQuotaExceeded = Boolean(
quotaFlags?.isDocumentQuotaExceeded || quotaFlags?.isEmailQuotaExceeded || quotaFlags?.isApiQuotaExceeded,
);
const isAnyQuotaNearing = Boolean(
quotaFlags?.isDocumentQuotaNearing || quotaFlags?.isEmailQuotaNearing || quotaFlags?.isApiQuotaNearing,
);
// Every member of the organisation sees the banner when a quota is exhausted or
// nearing its limit. When both states apply, "exceeded" wins for the banner copy
// and the dialog lists both exceeded and nearing items.
// Note: Skipping free plan banner for now because their quota can incorrectly show as exceeded.
if (
!organisation ||
!quotaFlags ||
(!isAnyQuotaExceeded && !isAnyQuotaNearing) ||
organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE
) {
return null;
}
return (
<>
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': !isAnyQuotaExceeded,
'bg-destructive text-destructive-foreground': isAnyQuotaExceeded,
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
{isAnyQuotaExceeded ? (
<Trans>Your organisation has exceeded a fair use limit</Trans>
) : (
<Trans>Your organisation is approaching a fair use limit</Trans>
)}
</div>
<Button
variant="outline"
className={cn({
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500': !isAnyQuotaExceeded,
'text-destructive-foreground hover:bg-destructive hover:text-white': isAnyQuotaExceeded,
})}
onClick={() => setIsOpen(true)}
size="sm"
>
<Trans>Learn more</Trans>
</Button>
</div>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isAnyQuotaExceeded ? <Trans>Fair use limit exceeded</Trans> : <Trans>Approaching fair use limit</Trans>}
</DialogTitle>
<DialogDescription>
{isAnyQuotaExceeded ? (
<Trans>
Your organisation has exceeded a fair use limit. Please contact{' '}
<a className="text-primary" href={`mailto:${SUPPORT_EMAIL}`}>
support
</a>{' '}
to review your plan's limits.
</Trans>
) : (
<Trans>
Your organisation is approaching a fair use limit. If you expect to need higher limits, please contact{' '}
<a className="text-primary" href={`mailto:${SUPPORT_EMAIL}`}>
support
</a>{' '}
to review your plan's limits.
</Trans>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription>
<ul className="list-inside list-disc text-sm">
{quotaFlags.isDocumentQuotaExceeded && (
<li className="list-disc">
<Trans>Document creation has been temporarily paused</Trans>
</li>
)}
{quotaFlags.isEmailQuotaExceeded && (
<li className="list-disc">
<Trans>Email sending has been temporarily paused</Trans>
</li>
)}
{quotaFlags.isApiQuotaExceeded && (
<li className="list-disc">
<Trans>API requests have been temporarily paused</Trans>
</li>
)}
{quotaFlags.isDocumentQuotaNearing && (
<li className="list-disc">
<Trans>Document usage is approaching fair use limits</Trans>
</li>
)}
{quotaFlags.isEmailQuotaNearing && (
<li className="list-disc">
<Trans>Email usage is approaching fair use limits</Trans>
</li>
)}
{quotaFlags.isApiQuotaNearing && (
<li className="list-disc">
<Trans>API usage is approaching fair use limits</Trans>
</li>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,61 @@
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react';
type RateLimitEntryValue = { window: string; max: number };
type RateLimitArrayInputProps = {
value: RateLimitEntryValue[];
onChange: (value: RateLimitEntryValue[]) => void;
disabled?: boolean;
};
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const entries = value ?? [];
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(next);
};
const removeEntry = (index: number) => {
onChange(entries.filter((_, i) => i !== index));
};
const addEntry = () => {
onChange([...entries, { window: '5m', max: 100 }]);
};
return (
<div className="space-y-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="w-24"
placeholder="5m"
value={entry.window}
disabled={disabled}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="w-32"
type="number"
min={1}
value={entry.max}
disabled={disabled}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit</Trans>
</Button>
</div>
);
};
@@ -0,0 +1,179 @@
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Trans, useLingui } from '@lingui/react/macro';
import { EditIcon, MoreHorizontalIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { EmailTransportDeleteDialog } from '../dialogs/email-transport-delete-dialog';
import { EmailTransportSendTestDialog } from '../dialogs/email-transport-send-test-dialog';
import { EmailTransportUpdateDialog } from '../dialogs/email-transport-update-dialog';
export const AdminEmailTransportsTable = () => {
const { t, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.emailTransport.find.useQuery({
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 20,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`Name`,
accessorKey: 'name',
},
{
header: t`Type`,
accessorKey: 'type',
},
{
header: t`From`,
cell: ({ row }) => `${row.original.fromName} <${row.original.fromAddress}>`,
},
{
header: t`Used by claims`,
cell: ({ row }) => row.original._count.subscriptionClaims + row.original._count.organisationClaims,
},
{
header: t`Created`,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>
<Trans>Actions</Trans>
</DropdownMenuLabel>
<EmailTransportUpdateDialog
transport={row.original}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</div>
</DropdownMenuItem>
}
/>
<EmailTransportSendTestDialog
transportId={row.original.id}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send test</Trans>
</div>
</DropdownMenuItem>
}
/>
<EmailTransportDeleteDialog
transportId={row.original.id}
transportName={row.original.name}
subscriptionClaimCount={row.original._count.subscriptionClaims}
organisationClaimCount={row.original._count.organisationClaims}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</div>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-2 w-6 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};
@@ -0,0 +1,269 @@
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useLingui } from '@lingui/react/macro';
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react';
import { useMemo } from 'react';
import { Link, useSearchParams } from 'react-router';
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
type OrderByDirection = 'asc' | 'desc';
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
if (
value === 'documentCount' ||
value === 'emailCount' ||
value === 'apiCount' ||
value === 'emailReports' ||
value === 'totalCount'
) {
return value;
}
return undefined;
};
const parseOrderByDirection = (value: string | undefined): OrderByDirection => {
return value === 'asc' ? 'asc' : 'desc';
};
/**
* Number of days to divide the period's usage by to get a per-day average.
*
* For the in-progress (current) month we divide by today's UTC day-of-month so the
* average reflects elapsed days only. For a fully-elapsed past month we divide by the
* total number of days in that month.
*/
const getPeriodDivisor = (period: string): number => {
if (period === currentMonthlyPeriod()) {
return new Date().getUTCDate();
}
const [yearStr, monthStr] = period.split('-');
const year = Number(yearStr);
const month = Number(monthStr);
if (Number.isNaN(year) || Number.isNaN(month)) {
return new Date().getUTCDate();
}
// Day 0 of the following month resolves to the last day of `month`.
return new Date(Date.UTC(year, month, 0)).getUTCDate();
};
export type OrganisationStatsDisplayMode = 'usage' | 'quotas' | 'averages';
type AdminOrganisationStatsTableProps = {
displayMode?: OrganisationStatsDisplayMode;
};
export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrganisationStatsTableProps) => {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
// Default to the current month.
const period = searchParams?.get('period') ?? currentMonthlyPeriod();
const claimId = searchParams?.get('claimId') || undefined;
const orderByColumn = parseOrderByColumn(searchParams?.get('orderByColumn') ?? undefined);
const orderByDirection = parseOrderByDirection(searchParams?.get('orderByDirection') ?? undefined);
const { data, isLoading, isLoadingError } = trpc.admin.organisation.stats.find.useQuery({
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
period,
claimId,
orderByColumn,
orderByDirection,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const handleColumnSort = (column: OrderByColumn) => {
const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc';
// Use the functional updater so we merge onto the latest params. Reading the
// captured `searchParams` here would drop filters (e.g. claimId) that changed
// after this handler was memoised into the column definitions.
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
next.set('orderByColumn', column);
next.set('orderByDirection', nextDirection);
next.set('page', '1');
return next;
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
const divisor = getPeriodDivisor(period);
const formatPerDay = (used: number) => {
const perDay = divisor > 0 ? used / divisor : 0;
const rounded = Math.round(perDay * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
};
const renderUsageCell = (used: number, quota: number | null) => {
if (displayMode === 'averages') {
return formatPerDay(used);
}
if (displayMode === 'quotas') {
return (
<span>
{used}/{quota === null ? '∞' : quota}
</span>
);
}
return <span>{used}</span>;
};
const sortableHeader = (label: string, column: OrderByColumn) => (
<button
type="button"
className="flex cursor-pointer items-center whitespace-nowrap"
onClick={() => handleColumnSort(column)}
>
{label}
{orderByColumn === column ? (
orderByDirection === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDownIcon className="ml-2 h-4 w-4" />
)}
</button>
);
return [
{
header: t`Organisation`,
accessorKey: 'organisationName',
cell: ({ row }) => (
<Link to={`/admin/organisations/${row.original.organisationId}`} className="text-sm hover:underline">
{row.original.organisationName}
</Link>
),
},
{
header: t`Claim`,
accessorKey: 'originalClaimId',
cell: ({ row }) => <span className="text-muted-foreground text-sm">{row.original.originalClaimId ?? '—'}</span>,
},
{
header: t`Period`,
accessorKey: 'period',
cell: ({ row }) => <span className="text-sm">{row.original.period}</span>,
},
{
header: () => sortableHeader(t`Documents`, 'documentCount'),
accessorKey: 'documentCount',
cell: ({ row }) => renderUsageCell(row.original.documentCount, row.original.documentQuota),
},
{
header: () => sortableHeader(t`Emails`, 'emailCount'),
accessorKey: 'emailCount',
cell: ({ row }) => renderUsageCell(row.original.emailCount, row.original.emailQuota),
},
{
header: () => sortableHeader(t`API`, 'apiCount'),
accessorKey: 'apiCount',
cell: ({ row }) => renderUsageCell(row.original.apiCount, row.original.apiQuota),
},
{
header: () => sortableHeader(t`Reports`, 'emailReports'),
accessorKey: 'emailReports',
cell: ({ row }) => row.original.emailReports,
},
{
header: () => sortableHeader(t`Total`, 'totalCount'),
accessorKey: 'totalCount',
cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>,
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
// `searchParams` must be a dependency: `handleColumnSort` closes over `setSearchParams`,
// whose functional updater is bound to the `searchParams` captured at creation time.
// Without this, changing a filter (e.g. claimId) wouldn't refresh the memoised handler,
// and sorting would merge onto stale params and drop the active filter.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, orderByColumn, orderByDirection, period, displayMode, searchParams]);
return (
<div>
<DataTable
columns={columns}
data={results.data}
rowClassName="text-sm"
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 5,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-32 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};
@@ -238,7 +238,7 @@ export const AdminOrganisationsTable = ({
}}
>
{(table) =>
!hidePaginationUntilOverflow || 1 > table.getPageCount() ? (
!hidePaginationUntilOverflow || table.getPageCount() > 1 ? (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
) : null
}
@@ -207,7 +207,7 @@ export const OrganisationInsightsTable = ({
<SummaryCard
icon={TrendingUp}
title={_(msg`Documents Completed`)}
value={insights.summary.volumeThisPeriod}
value={`${insights.summary.volumeThisPeriod}/${insights.summary.documentsThisPeriod}`}
/>
</div>
)}
@@ -269,7 +269,7 @@ const SummaryCard = ({
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
value: number;
value: number | string;
subtitle?: string;
}) => (
<div className="flex items-start gap-x-2 rounded-lg border bg-card px-4 py-3">
@@ -1,6 +1,7 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -21,8 +22,8 @@ export const UserBillingOrganisationsTable = () => {
return organisations.filter((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole));
}, [organisations]);
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
return match(status)
const getSubscriptionStatusDisplay = (organisation: (typeof billingOrganisations)[number]) => {
return match(organisation.subscription?.status)
.with(SubscriptionStatus.ACTIVE, () => ({
label: t({ message: `Active`, context: `Subscription status` }),
variant: 'default' as const,
@@ -35,10 +36,19 @@ export const UserBillingOrganisationsTable = () => {
label: t({ message: `Inactive`, context: `Subscription status` }),
variant: 'neutral' as const,
}))
.otherwise(() => ({
label: t({ message: `Free`, context: `Subscription status` }),
variant: 'neutral' as const,
}));
.otherwise(() => {
if (isOrganisationPendingPayment(organisation)) {
return {
label: t({ message: `Free (Pending)`, context: `Subscription status` }),
variant: 'warning' as const,
};
}
return {
label: t({ message: `Free`, context: `Subscription status` }),
variant: 'neutral' as const,
};
});
};
const columns = useMemo(() => {
@@ -62,9 +72,7 @@ export const UserBillingOrganisationsTable = () => {
header: t`Subscription Status`,
accessorKey: 'subscription',
cell: ({ row }) => {
const subscription = row.original.subscription;
const status = subscription?.status;
const { label, variant } = getSubscriptionStatusDisplay(status);
const { label, variant } = getSubscriptionStatusDisplay(row.original);
return <Badge variant={variant}>{label}</Badge>;
},
@@ -13,6 +13,7 @@ import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { OrganisationBillingBanner } from '~/components/general/organisations/organisation-billing-banner';
import { OrganisationQuotaBanner } from '~/components/general/organisations/organisation-quota-banner';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import { TeamProvider } from '~/providers/team';
@@ -109,6 +110,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
<TeamProvider team={currentTeam || null}>
<OrganisationBillingBanner />
<OrganisationQuotaBanner />
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && !hideHeader && <AppBanner banner={banner} />}
@@ -10,6 +10,7 @@ import {
BarChart3,
Building2Icon,
FileStack,
LineChartIcon,
MailIcon,
Settings,
Trophy,
@@ -128,6 +129,17 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
<Button
variant="ghost"
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/email-transports') && 'bg-secondary')}
asChild
>
<Link to="/admin/email-transports">
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Transports</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/email-domains') && 'bg-secondary')}
@@ -153,6 +165,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/organisation-stats') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/organisation-stats">
<LineChartIcon className="mr-2 h-5 w-5" />
<Trans>Organisation Stats</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/site-settings') && 'bg-secondary')}
@@ -131,7 +131,7 @@ export default function AdminDocumentsPage() {
<div>
<Input
type="search"
placeholder={_(msg`Search by document title`)}
placeholder={_(msg`Search by document title, team:123 or user:123`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
@@ -0,0 +1,59 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminEmailTransportsTable } from '~/components/tables/admin-email-transports-table';
export default function AdminEmailTransportsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader title={t`Email Transports`} subtitle={t`Manage all email transports`} hideDivider>
<EmailTransportCreateDialog />
</SettingsHeader>
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by name or from address`}
className="mb-4"
/>
<AdminEmailTransportsTable />
</div>
</div>
);
}
@@ -0,0 +1,208 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import {
AdminOrganisationStatsTable,
type OrganisationStatsDisplayMode,
} from '~/components/tables/admin-organisation-stats-table';
const ALL_CLAIMS_VALUE = 'all';
/**
* The earliest UTC calendar month for which stats exist (the month the feature launched).
* Months before this never have data, so there's no point offering them in the filter.
*/
const EARLIEST_PERIOD = { year: 2026, month: 5 };
/**
* Generate every UTC calendar month from `EARLIEST_PERIOD` up to the current month as
* `YYYY-MM` strings, newest first.
*/
const generatePeriodOptions = (): string[] => {
const periods: string[] = [];
const now = new Date();
let year = now.getUTCFullYear();
let month = now.getUTCMonth() + 1;
while (year > EARLIEST_PERIOD.year || (year === EARLIEST_PERIOD.year && month >= EARLIEST_PERIOD.month)) {
periods.push(`${year}-${String(month).padStart(2, '0')}`);
month -= 1;
if (month === 0) {
month = 12;
year -= 1;
}
}
return periods;
};
export default function OrganisationStats() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const [displayMode, setDisplayMode] = useState<OrganisationStatsDisplayMode>('usage');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
const selectedClaim = searchParams?.get('claimId') ?? ALL_CLAIMS_VALUE;
const { data: claimsData, isLoading: isLoadingClaims } = trpc.admin.claims.find.useQuery({
perPage: 100,
});
const claimOptions = claimsData?.data ?? [];
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
params.delete('page');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPeriodChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('period', value);
params.delete('page');
setSearchParams(params);
};
const onClaimChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value === ALL_CLAIMS_VALUE) {
params.delete('claimId');
} else {
params.set('claimId', value);
}
params.delete('page');
setSearchParams(params);
};
return (
<div>
<SettingsHeader
hideDivider
title={t`Organisation Stats`}
subtitle={t`View, sort and filter monthly usage stats across organisations`}
/>
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation name, URL or ID`}
className="flex-1"
/>
<Select value={selectedClaim} onValueChange={onClaimChange}>
<SelectTrigger className="w-full sm:w-48" loading={isLoadingClaims}>
<SelectValue placeholder={t`All claims`} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_CLAIMS_VALUE}>{t`All claims`}</SelectItem>
{claimOptions.map((claim) => (
<SelectItem key={claim.id} value={claim.id}>
{claim.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedPeriod} onValueChange={onPeriodChange}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder={t`Period`} />
</SelectTrigger>
<SelectContent>
{periodOptions.map((period) => (
<SelectItem key={period} value={period}>
{period}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-4">
<AdminOrganisationStatsTable displayMode={displayMode} />
</div>
<RadioGroup
value={displayMode}
onValueChange={(value) =>
setDisplayMode(value === 'quotas' ? 'quotas' : value === 'averages' ? 'averages' : 'usage')
}
className="mt-4 flex flex-col gap-3 rounded-lg border border-border p-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="display-usage" value="usage" />
<label htmlFor="display-usage" className="text-muted-foreground text-sm">
<Trans>Show usage</Trans>
</label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="display-quotas" value="quotas" />
<label htmlFor="display-quotas" className="text-muted-foreground text-sm">
<Trans>Show usage with quotas</Trans>
</label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="display-averages" value="averages" />
<label htmlFor="display-averages" className="text-muted-foreground text-sm">
<Trans>Show daily averages for documents, emails and API usages</Trans>
</label>
</div>
</RadioGroup>
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<Trans>
Documents, emails and api values may not be accurate since they record the amount of times the action was
attempted. Meaning these values may go over the actual quota, get rejected, and will still be recorded.
</Trans>
</AlertDescription>
</Alert>
</div>
);
}
@@ -24,6 +24,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -40,9 +41,12 @@ 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 { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { OrganisationUsagePanel } from '~/components/general/organisation-usage-panel';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/organisations.$id';
@@ -293,6 +297,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
</DetailsValue>
</DetailsCard>
</div>
<div className="mt-4">
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
/>
</div>
</div>
<div className="mt-6 rounded-lg border p-4">
@@ -367,7 +379,16 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
)}
{organisation.subscription && (
<div>
<div className="flex flex-col gap-2 sm:flex-row">
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" asChild>
<Link
target="_blank"
@@ -552,6 +573,10 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 });
const transports = transportsData?.data ?? [];
const NONE_VALUE = '__none__';
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
@@ -565,7 +590,24 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
teamCount: organisation.organisationClaim.teamCount,
memberCount: organisation.organisationClaim.memberCount,
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
recipientCount: organisation.organisationClaim.recipientCount,
flags: organisation.organisationClaim.flags,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentRateLimits: organisation.organisationClaim.documentRateLimits as NonNullable<
TUpdateOrganisationBillingFormSchema['claims']
>['documentRateLimits'],
documentQuota: organisation.organisationClaim.documentQuota,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailRateLimits: organisation.organisationClaim.emailRateLimits as NonNullable<
TUpdateOrganisationBillingFormSchema['claims']
>['emailRateLimits'],
emailQuota: organisation.organisationClaim.emailQuota,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
apiRateLimits: organisation.organisationClaim.apiRateLimits as NonNullable<
TUpdateOrganisationBillingFormSchema['claims']
>['apiRateLimits'],
apiQuota: organisation.organisationClaim.apiQuota,
emailTransportId: organisation.organisationClaim.emailTransportId ?? null,
},
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
},
@@ -745,6 +787,30 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
@@ -803,6 +869,42 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
</div>
<ClaimLimitFields control={form.control} prefix="claims." />
<FormField
control={form.control}
name="claims.emailTransportId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email transport</Trans>
</FormLabel>
<Select
value={field.value ?? NONE_VALUE}
onValueChange={(value) => field.onChange(value === NONE_VALUE ? null : value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Default (system mailer)`} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}>{t`Default (system mailer)`}</SelectItem>
{transports.map((transport) => (
<SelectItem key={transport.id} value={transport.id}>
{transport.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
<Trans>Organisations without a transport use the system default mailer.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -1,210 +1,36 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID } from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export async function loader() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
const settings = await getSiteSettings();
return { banner };
const banner = settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID);
const emailBlocklist = settings.find((setting) => setting.id === SITE_SETTINGS_EMAIL_BLOCKLIST_ID);
return { banner, emailBlocklist };
}
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentProps) {
const { banner, emailBlocklist } = loaderData;
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<SettingsHeader title={_(msg`Site Settings`)} subtitle={_(msg`Manage your site settings here`)} />
<div className="mt-8">
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<div className="mt-8 space-y-12">
<AdminSiteBannerSection banner={banner} />
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
</div>
</div>
);
@@ -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>
@@ -6,6 +6,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router';
import type Stripe from 'stripe';
import { match, P } from 'ts-pattern';
@@ -23,12 +25,51 @@ export default function TeamsSettingBillingPage() {
const organisation = useCurrentOrganisation();
const [searchParams, setSearchParams] = useSearchParams();
const utils = trpc.useUtils();
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
trpc.enterprise.billing.subscription.get.useQuery({
organisationId: organisation.id,
});
if (isLoadingSubscription || !subscriptionQuery) {
const { mutateAsync: syncSubscription, isPending: isSyncingSubscription } =
trpc.enterprise.billing.subscription.sync.useMutation();
const hasTriggeredCheckoutSyncRef = useRef(false);
const isCheckoutSuccess = searchParams.get('success') === 'true';
/**
* Eagerly sync the subscription from Stripe when returning from a successful
* checkout, since the webhook may not have arrived yet.
*/
useEffect(() => {
if (!isCheckoutSuccess || hasTriggeredCheckoutSyncRef.current) {
return;
}
hasTriggeredCheckoutSyncRef.current = true;
void syncSubscription({ organisationId: organisation.id })
.catch(() => {
// Non-fatal, webhooks will converge the subscription state shortly.
})
.finally(() => {
void utils.enterprise.billing.invalidate();
setSearchParams(
(params) => {
params.delete('success');
return params;
},
{ replace: true },
);
});
}, [isCheckoutSuccess, organisation.id]);
if (isLoadingSubscription || !subscriptionQuery || isSyncingSubscription) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -1,6 +1,7 @@
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { TrpcProvider } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -21,7 +22,11 @@ export default function Layout() {
return undefined;
}
if (organisation?.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) {
const isRestricted =
(organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
isOrganisationPendingPayment(organisation);
if (isRestricted) {
return {
quota: {
documents: 0,
@@ -42,7 +47,7 @@ export default function Layout() {
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
};
}, [organisation?.subscription]);
}, [organisation]);
if (!team) {
return (
@@ -0,0 +1,92 @@
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import type { Route } from './+types/report.$token';
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
// Only validate the token on GET. The report itself is performed by an explicit
// mutation (triggered by the recipient clicking the button), so an automated email
// link scanner / prefetcher cannot register a report simply by fetching the URL.
const recipient = await prisma.recipient.findFirst({
where: { token },
select: { id: true },
});
if (!recipient) {
throw new Response('Not Found', { status: 404 });
}
return {
token,
};
}
export default function ReportSenderPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
const { t } = useLingui();
const { toast } = useToast();
const [isReported, setIsReported] = useState(false);
const { mutate: reportSender, isPending } = trpc.envelope.recipient.report.useMutation({
onSuccess: () => setIsReported(true),
onError: () => {
toast({
title: t`Something went wrong`,
description: t`We were unable to report this sender at this time. Please try again later.`,
variant: 'destructive',
});
},
});
if (isReported) {
return (
<div className="-mx-4 flex flex-col items-center px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28">
<h1 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl">
<Trans>Sender reported</Trans>
</h1>
<p className="mt-4 max-w-[60ch] text-center text-muted-foreground leading-normal">
<Trans>
Thank you for letting us know, we have flagged this sender for review. If you have any concerns please feel
free to reach out to our{' '}
<a className="text-documenso-700 underline" href={`mailto:${SUPPORT_EMAIL}`}>
support team
</a>
.
</Trans>
</p>
</div>
);
}
return (
<div className="-mx-4 flex flex-col items-center px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28">
<h1 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl">
<Trans>Report this sender?</Trans>
</h1>
<p className="mt-4 max-w-[60ch] text-center text-muted-foreground leading-normal">
<Trans>
If you did not expect this email or believe it is spam, you can report the sender to our team for review.
</Trans>
</p>
<Button className="mt-6" loading={isPending} onClick={() => reportSender({ token })}>
<Trans>Report sender</Trans>
</Button>
</div>
);
}
@@ -164,6 +164,10 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientSignature,
isRecipientsTurn,
includeSenderDetails: settings.includeSenderDetails,
branding: {
brandingEnabled: settings.brandingEnabled,
brandingLogo: settings.brandingLogo,
},
} as const;
};
@@ -338,6 +342,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
isRecipientsTurn,
allRecipients,
includeSenderDetails,
branding,
recipientWithFields,
} = data;
@@ -360,8 +365,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>
<span className="mt-1.5 block">"{document.title}"</span>
is no longer available to sign
<span className="mt-1.5 block">"{document.title}"</span> is no longer available to sign
</Trans>
</h2>
@@ -410,6 +414,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
includeSenderDetails={includeSenderDetails}
branding={branding}
/>
</div>
</DocumentSigningAuthProvider>
@@ -446,8 +451,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>
<span className="mt-1.5 block">"{envelope.title}"</span>
is no longer available to sign
<span className="mt-1.5 block">"{envelope.title}"</span> is no longer available to sign
</Trans>
</h2>
@@ -298,10 +298,10 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
presignToken: token,
mode: 'edit' as const,
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
brandingLogo,
customBrandingLogo: Boolean(brandingLogo),
user: embedAuthoringOptions.user,
}),
[token],
[token, brandingLogo, embedAuthoringOptions.user],
);
const editorConfig = useMemo(() => {
@@ -40,6 +40,6 @@ export const handleInitialsFieldClick = async (
return {
type: FieldType.INITIALS,
value: initials,
value: initialsToInsert,
};
};
@@ -0,0 +1,97 @@
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { match } from 'ts-pattern';
export type ToastMessageDescriptor = {
title: MessageDescriptor;
description: MessageDescriptor;
};
export const RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE = {
title: msg`Too many recipients`,
description: msg`This document has too many recipients. Please remove some recipients or contact support if you need more.`,
};
export const FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE = {
title: msg`Fair use limit exceeded`,
description: msg`Your organisation has reached its plan's fair use limit. Please contact your organisation administrator or support to continue.`,
};
export const getDistributeErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`An error occurred while distributing the document.`,
}));
};
export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
}));
};
export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('INVALID_DOCUMENT_FILE', () => ({
title: msg`Error`,
description: msg`You cannot upload encrypted PDFs.`,
}))
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
title: msg`Error`,
description: msg`You have reached your document limit for this month. Please upgrade your plan.`,
}))
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => ({
title: msg`Error`,
description: msg`You have reached the limit of the number of files per envelope.`,
}))
.with('UNSUPPORTED_FILE_TYPE', () => ({
title: msg`Error`,
description: msg`This file type isn't supported. Please upload a PDF or Word document.`,
}))
.with('CONVERSION_SERVICE_UNAVAILABLE', () => ({
title: msg`Error`,
description: msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
}))
.with('CONVERSION_FAILED', () => ({
title: msg`Error`,
description: msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
}))
.otherwise(() => ({
title: msg`Error`,
description: msg`An error occurred while uploading your document.`,
}));
};
export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with('DOCUMENT_SEND_FAILED', () => ({
title: msg`Error`,
description: msg`The document was created but could not be sent to recipients.`,
}))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
}))
.with(AppErrorCode.NOT_FOUND, () => ({
title: msg`Error`,
description: msg`The template or one of its recipients could not be found.`,
}))
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
title: msg`Error`,
description: msg`You have reached your document limit for this plan. Please upgrade your plan.`,
}))
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Error`,
description: msg`An error occurred while creating document from template.`,
}));
};
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.11.0"
"version": "2.12.0"
}
+5 -258
View File
@@ -1,261 +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` | Master switch. Set to `true` to disable all signup methods (incl. organisation OIDC portal). |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Set to `true` to disable email/password signup only. SSO signup is unaffected. |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Set to `true` to block new accounts via Google. Existing Google-linked users can still sign in. |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Set to `true` to block new accounts via Microsoft. Existing linked users can still sign in. |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Set to `true` to block new accounts via OIDC (incl. organisation portal). Existing users unaffected.|
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). |
- [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
+1582 -1294
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.11.0",
"version": "2.12.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",
+12 -3
View File
@@ -95,13 +95,22 @@ export const authenticatedMiddleware = <
{ metadata, logger: apiLogger },
);
} catch (err) {
console.log({ err });
apiLogger.info(infoToLog);
apiLogger.info({
...infoToLog,
error: err,
});
let message = 'Unauthorized';
if (err instanceof AppError) {
if (err.code === AppErrorCode.TOO_MANY_REQUESTS) {
return {
status: 429,
body: { message: err.message },
headers: err.headers,
} as const;
}
message = err.message;
}
@@ -0,0 +1,268 @@
import { encryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Locator, type Page, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
// ─── Cleanup ─────────────────────────────────────────────────────────────────
// Transports seeded by the current test, deleted afterwards. Deleting a transport
// referenced by a claim is safe: the FK is `onDelete: SetNull`.
const transportIdsToCleanup: string[] = [];
test.afterEach(async () => {
if (transportIdsToCleanup.length > 0) {
await prisma.emailTransport.deleteMany({ where: { id: { in: transportIdsToCleanup } } });
transportIdsToCleanup.length = 0;
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
const seedTransport = async (label: string) => {
const transport = await prisma.emailTransport.create({
data: {
id: generateDatabaseId('email_transport'),
name: `e2e-transport-${label}-${nanoid()}`,
type: 'RESEND',
fromName: 'Seeded Transport',
fromAddress: 'seeded@example.com',
config: encryptEmailTransportConfig({ type: 'RESEND', apiKey: `re_${nanoid()}` }),
},
});
transportIdsToCleanup.push(transport.id);
return transport;
};
const seedSubscriptionClaim = (name: string) =>
prisma.subscriptionClaim.create({
data: {
name,
teamCount: 1,
memberCount: 1,
envelopeItemCount: 10,
recipientCount: 10,
flags: {},
documentRateLimits: [],
emailRateLimits: [],
apiRateLimits: [],
},
});
/**
* Seeds an organisation whose `OrganisationClaim` is descended (via
* `originalSubscriptionClaimId`) from the supplied subscription claim. This is
* the relationship the backport `updateMany` keys on.
*/
const seedOrgForClaim = async (subscriptionClaimId: string) => {
const { organisation } = await seedUser();
await prisma.organisationClaim.update({
where: { id: organisation.organisationClaim.id },
data: {
originalSubscriptionClaimId: subscriptionClaimId,
emailTransportId: null,
},
});
return organisation;
};
const openClaimUpdateDialog = async (page: Page, claimName: string) => {
// The update dialog lives inside the table row. Wait for the debounced search
// refetch to land BEFORE opening it, otherwise the table re-renders mid-flow
// and unmounts the dialog.
const searchSettled = page
.waitForResponse((r) => r.url().includes('claims.find') && r.url().includes(claimName), { timeout: 15_000 })
.catch(() => undefined);
await page.getByPlaceholder('Search by claim ID or name').fill(claimName);
await searchSettled;
const row = page.getByRole('row', { name: claimName });
await expect(row).toBeVisible();
// The actions dropdown trigger is the last button in the row (the first is the
// ID copy button).
await row.getByRole('button').last().click();
await page.getByRole('menuitem', { name: 'Update' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Update Subscription Claim' })).toBeVisible();
return dialog;
};
/**
* Picks an option from an open Radix Select listbox. The email-transport list is
* populated by a `find` query that can keep re-rendering (it loads up to 100
* transports), so the target option's box may still be shifting — wait for it,
* best-effort scroll it into view, then force the click.
*/
const chooseOption = async (page: Page, name: string) => {
const option = page.getByRole('option', { name });
await option.waitFor({ state: 'visible' });
await option.scrollIntoViewIfNeeded().catch(() => undefined);
await option.click({ force: true });
};
const selectEmailTransport = async (page: Page, dialog: Locator, transportName: string) => {
await dialog.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
await chooseOption(page, transportName);
};
// ─── Subscription claim: NO backport ─────────────────────────────────────────
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITHOUT backport does not touch organisation claims', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const transport = await seedTransport('no-backport');
const claimName = `e2e-claim-no-backport-${nanoid()}`;
const claim = await seedSubscriptionClaim(claimName);
const organisation = await seedOrgForClaim(claim.id);
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
const dialog = await openClaimUpdateDialog(page, claimName);
await selectEmailTransport(page, dialog, transport.name);
// Backport checkbox left UNCHECKED.
await expect(dialog.getByRole('checkbox', { name: 'Backport email transport' })).not.toBeChecked();
await dialog.getByRole('button', { name: 'Update Claim' }).click();
await expect(dialog).not.toBeVisible();
// The subscription claim itself was updated (proves the mutation ran).
await expect
.poll(async () => {
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
return updated.emailTransportId;
})
.toBe(transport.id);
// The organisation claim was NOT backported.
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { id: organisation.organisationClaim.id },
});
expect(orgClaim.emailTransportId).toBeNull();
});
// ─── Subscription claim: WITH backport ───────────────────────────────────────
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITH backport propagates to organisation claims', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const transport = await seedTransport('backport');
const claimName = `e2e-claim-backport-${nanoid()}`;
const claim = await seedSubscriptionClaim(claimName);
const organisation = await seedOrgForClaim(claim.id);
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
const dialog = await openClaimUpdateDialog(page, claimName);
await selectEmailTransport(page, dialog, transport.name);
// Enable backporting.
const backportCheckbox = dialog.getByRole('checkbox', { name: 'Backport email transport' });
await backportCheckbox.click();
await expect(backportCheckbox).toBeChecked();
await dialog.getByRole('button', { name: 'Update Claim' }).click();
await expect(dialog).not.toBeVisible();
// Both the subscription claim AND the descendant organisation claim are updated.
await expect
.poll(async () => {
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
return updated.emailTransportId;
})
.toBe(transport.id);
await expect
.poll(async () => {
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { id: organisation.organisationClaim.id },
});
return orgClaim.emailTransportId;
})
.toBe(transport.id);
});
// ─── Organisation claim transport (set directly on the org page) ─────────────
test('[ADMIN][EMAIL_TRANSPORT]: setting the email transport on an organisation claim persists', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const transport = await seedTransport('org-claim');
const { organisation } = await seedUser();
// Ensure a known starting point.
await prisma.organisationClaim.update({
where: { id: organisation.organisationClaim.id },
data: { emailTransportId: null },
});
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
// Scope to the billing/claims form (the one containing the "Email transport" field);
// the page has a second form (name/url) with its own "Update" button.
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
await billingForm.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
await chooseOption(page, transport.name);
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
await expect
.poll(async () => {
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { id: organisation.organisationClaim.id },
});
return orgClaim.emailTransportId;
})
.toBe(transport.id);
});
// ─── Organisation claim transport can be reset to the system mailer ──────────
test('[ADMIN][EMAIL_TRANSPORT]: clearing an organisation claim transport resets it to the system mailer', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const transport = await seedTransport('org-clear');
const { organisation } = await seedUser();
// Start with the transport already assigned.
await prisma.organisationClaim.update({
where: { id: organisation.organisationClaim.id },
data: { emailTransportId: transport.id },
});
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
// The select currently shows the transport name; switch back to the default.
await billingForm.getByRole('combobox').filter({ hasText: transport.name }).click();
await chooseOption(page, 'Default (system mailer)');
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
await expect
.poll(async () => {
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { id: organisation.organisationClaim.id },
});
return orgClaim.emailTransportId;
})
.toBeNull();
});

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