mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 12:22:14 +10:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 948d1bbf12 | |||
| 40d20ad068 | |||
| a99bdf5e20 | |||
| 4f346d3c2d | |||
| d5ce222482 | |||
| 9b59f1a273 | |||
| 15549a6758 | |||
| eb45d1e5a9 | |||
| 0aa84cecc8 | |||
| 702e747375 | |||
| 3887aa67c8 | |||
| b84b87cea6 | |||
| ac0a0086d6 | |||
| 8c11266747 | |||
| 3c0345f755 | |||
| 3e47f1913c | |||
| 9ccf50ed95 | |||
| ecc98fbd41 | |||
| 58f0f5da43 | |||
| d5c6cf4ad5 | |||
| 90462bf414 | |||
| 791a54bb8b | |||
| 5f4e0ccf6b | |||
| 583e35c768 | |||
| 1e129580b8 | |||
| 184cbd6770 | |||
| b53295a9d5 | |||
| 8448e333cf | |||
| 03b5fe6117 | |||
| f60698a353 | |||
| 7c48ae6ff4 | |||
| 4ee789ea37 | |||
| ebf5b75a19 | |||
| 0ecde7ac1e | |||
| c41e387220 | |||
| 7f796ed74e | |||
| 0a21598fec | |||
| 240bef1a66 | |||
| 9583e79056 | |||
| 993a494784 | |||
| 743d31651f | |||
| ce96238464 | |||
| 8b8e7e9f2e | |||
| 50006ca053 | |||
| c3135a3ce7 | |||
| d2f60b13fd | |||
| c50a01d004 | |||
| 4bda501d51 | |||
| a7713f7228 | |||
| 536142be03 | |||
| 44c4826e92 | |||
| 61138cdd81 | |||
| 22ceff43e3 | |||
| a84da2f2c7 | |||
| 7e8da85bd8 | |||
| d304d8720c | |||
| 9da2db2e67 | |||
| 993df7dc21 | |||
| 807d094cf2 | |||
| 3cef238f46 | |||
| 886c40a46b | |||
| b1b82b775c | |||
| 0fe697c26c | |||
| 7c0031679a | |||
| 6bb0496224 | |||
| eedf483957 | |||
| 5421b0d1cc | |||
| fa2c53bd72 | |||
| 6ac67e646c | |||
| 6a20fefd7b | |||
| 43fe558459 | |||
| 0a6b0452dc | |||
| fec5d55250 | |||
| f1b235819e | |||
| d0f9f68689 | |||
| f93a98e9a5 | |||
| c0ea4c60e4 | |||
| 2cb4cc29ea | |||
| d9b5f01e21 | |||
| bc3acba72c | |||
| 247a0158bd | |||
| 9e0b567686 | |||
| 8f6be474a9 | |||
| 8f5bdef384 | |||
| 999942014e | |||
| 194b2134cc | |||
| b8df02750b |
@@ -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.
|
||||
+9
-1
@@ -48,7 +48,7 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
|
||||
# [[SIGNING]]
|
||||
# The transport to use for document signing. Available options: local (default) | gcloud-hsm
|
||||
# The transport to use for document signing. Available options: local (default) | gcloud-hsm | csc
|
||||
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
|
||||
# OPTIONAL: The passphrase to use for the local file-based signing transport.
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=
|
||||
@@ -70,6 +70,14 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS=
|
||||
# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport.
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH=
|
||||
# OPTIONAL: The base URL of the Cloud Signature Consortium (CSC) provider for the csc signing transport.
|
||||
NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=
|
||||
# OPTIONAL: The OAuth client ID registered with the CSC provider for the csc signing transport.
|
||||
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=
|
||||
# OPTIONAL: The OAuth client secret registered with the CSC provider for the csc signing transport.
|
||||
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
|
||||
# OPTIONAL: Default signature level for envelopes created on a CSC instance when the caller doesn't specify one. Available options: AES (default) | QES. Explicit AES/QES requests always pass through unchanged.
|
||||
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
|
||||
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
|
||||
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
|
||||
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
|
||||
|
||||
Vendored
+1
-1
@@ -29,6 +29,6 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
[](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
|
||||
|
||||
[](https://railway.app/template/bG6D4p)
|
||||
|
||||
### Render
|
||||
#### Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
|
||||
### Koyeb
|
||||
#### Koyeb
|
||||
|
||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||
|
||||
## Elestio
|
||||
#### Elestio
|
||||
|
||||
[](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
@@ -60,7 +60,7 @@ We support a variety of deployment methods, and are actively working on adding m
|
||||
|
||||
## Railway
|
||||
|
||||
[](https://railway.app/template/DjrRRX)
|
||||
[](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 you’re 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
|
||||
@@ -186,9 +186,9 @@ Documenso requires a certificate to digitally sign documents.
|
||||
|
||||
### Transport Selection
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------------------------- | ---------------------------------------- | ------- |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local` or `gcloud-hsm` | `local` |
|
||||
| Variable | Description | Default |
|
||||
| -------------------------------- | ------------------------------------------------- | ------- |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local`, `gcloud-hsm`, or `csc` | `local` |
|
||||
|
||||
### Local Signing
|
||||
|
||||
@@ -210,11 +210,36 @@ Documenso requires a certificate to digitally sign documents.
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | Base64-encoded certificate chain |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | Google Secret Manager path for certificate retrieval |
|
||||
|
||||
### Cloud Signature Consortium (CSC)
|
||||
|
||||
Routes signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures (AES/QES). Instance-wide; set `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` to enable. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup walkthrough.
|
||||
|
||||
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Without a valid license, the instance will refuse to start in `csc` mode.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller doesn't specify one. `AES` or `QES`. Explicit requests pass through. | `AES` |
|
||||
|
||||
The OAuth callback URL registered with the CSC provider is fixed at `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` — register this exact URL with the TSP.
|
||||
|
||||
#### Derived Public Variables
|
||||
|
||||
The following client-visible variable is **derived automatically** from the private transport at server startup. Do not set it manually — any value set in the environment is overwritten on boot.
|
||||
|
||||
| Variable | Derived from | Value |
|
||||
| ------------------------------------- | -------------------------------------------------- | ------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` | `NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc'` | `'true'` when CSC mode is active, else `'false'` |
|
||||
|
||||
The authoring UI uses this flag to gate features that AES/QES envelopes cannot support (parallel signing, assistant role, dictate next signer). Deriving it from the private transport prevents the client-side flag from drifting from the real server-side configuration.
|
||||
|
||||
### Signature Options
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------------- | ----------------------------------------------------------- | ---------- |
|
||||
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures | |
|
||||
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures. Optional for `local` / `gcloud-hsm` (signatures omit the timestamp when unset). **Required** when `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` — the instance refuses to start without it. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes#timestamp-authority-resolution). | |
|
||||
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info embedded in PDF signatures | Webapp URL |
|
||||
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Use `adbe.pkcs7.detached` instead of `ETSI.CAdES.detached` | `false` |
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
title: CSC (AES / QES)
|
||||
description: Configure Cloud Signature Consortium signing for Advanced and Qualified Electronic Signatures via a third-party Trust Service Provider.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
The `csc` signing transport routes signatures through a third-party Trust Service Provider (TSP) using the [Cloud Signature Consortium API v1.0.4.0](https://cloudsignatureconsortium.org/). Each recipient authenticates directly with the TSP (Strong Customer Authentication) and the TSP returns a per-recipient signature bound to the document hash. Documenso assembles the resulting PAdES signature inside the PDF.
|
||||
|
||||
This transport enables **Advanced Electronic Signatures (AES)** and **Qualified Electronic Signatures (QES)** under eIDAS. See [Signature Levels](/docs/compliance/signature-levels) for the legal framework.
|
||||
|
||||
<Callout type="warn">
|
||||
CSC mode is **instance-wide**: one CSC provider per Documenso install. All envelopes created
|
||||
while the instance runs in `csc` mode use AES or QES. Switching `NEXT_PRIVATE_SIGNING_TRANSPORT`
|
||||
is a one-way operational migration — see [Switching Transports](#switching-transports).
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn">
|
||||
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The
|
||||
instance refuses to start in `csc` mode without it.
|
||||
</Callout>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### A TSP account
|
||||
|
||||
Establish a relationship with a CSC-compatible Trust Service Provider. The TSP issues qualified or advanced certificates to your signers, holds the private keys in its HSM, and exposes a CSC v1.0.4.0-compliant API.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### OAuth client credentials
|
||||
|
||||
Register Documenso as an OAuth client with the TSP. You will receive a client ID and client secret, and must supply Documenso's callback URL when registering:
|
||||
|
||||
```
|
||||
${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback
|
||||
```
|
||||
|
||||
The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL` and the route mount path. There is no env var to override it; ensuring the registered URL matches your instance's webapp URL exactly is the operator's responsibility.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Enterprise Edition license
|
||||
|
||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### S3 storage (strongly recommended)
|
||||
|
||||
CSC produces multiple `DocumentData` rows per envelope item (one per recipient signature, plus the materialised and source rows). Database-backed storage base64-inflates each row by ~33% and is impractical at meaningful PDF sizes. Configure [S3 storage](/docs/self-hosting/configuration/storage) before enabling CSC.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Set to `csc` | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller does not specify one. `AES` or `QES`. Explicit requests always pass through. | `AES` |
|
||||
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | **Required.** Comma-separated RFC 3161 TSA URLs. Always used for B-LTA archival timestamps at seal time, and also serves as the B-T sign-time fallback when the TSP does not expose `signatures/timestamp`. The instance refuses to start in CSC mode without it. See [Timestamp Authority Resolution](#timestamp-authority-resolution). | |
|
||||
|
||||
<Callout type="info">
|
||||
`NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` is set automatically from
|
||||
`NEXT_PRIVATE_SIGNING_TRANSPORT` at server startup. Do not set it manually — see
|
||||
[Environment Variables](/docs/self-hosting/configuration/environment#derived-public-variables).
|
||||
</Callout>
|
||||
|
||||
## Configuration Example
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_SIGNING_TRANSPORT=csc
|
||||
NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=https://api.example-tsp.com/csc/v1
|
||||
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=documenso-prod
|
||||
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=...
|
||||
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=QES
|
||||
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=http://timestamp.example.com
|
||||
```
|
||||
|
||||
Register `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` (e.g. `https://sign.example.com/api/csc/oauth/callback`) as the OAuth callback URL with the TSP.
|
||||
|
||||
## Default Signature Level
|
||||
|
||||
`NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` selects the legal tier applied to envelopes that do not specify one explicitly. It is a default, not a capability gate: callers may still create AES or QES envelopes explicitly regardless of this setting.
|
||||
|
||||
| Configured value | Caller passes nothing | Caller passes `AES` | Caller passes `QES` |
|
||||
| ---------------- | --------------------- | ------------------- | ------------------- |
|
||||
| `AES` (default) | Envelope is `AES` | Envelope is `AES` | Envelope is `QES` |
|
||||
| `QES` | Envelope is `QES` | Envelope is `AES` | Envelope is `QES` |
|
||||
|
||||
Any value other than `AES` or `QES` causes the instance to refuse to start. This prevents silent qualified-to-advanced downgrades from a typo.
|
||||
|
||||
## Timestamp Authority Resolution
|
||||
|
||||
AES/QES envelopes use TSA-attested timestamps in two distinct phases. Resolution differs per phase.
|
||||
|
||||
### Sign time — PAdES B-T per recipient
|
||||
|
||||
Each recipient's CMS embeds a signature timestamp (CMS unsigned attribute) so proven time is bound to the recipient's signature itself. Resolution order:
|
||||
|
||||
1. If the TSP advertises `signatures/timestamp` in its `info` response (CSC §11.10), the TSP endpoint is used. The call is authorised with **this recipient's** service-scope bearer token — the same one authorising the `signatures/signHash` call alongside it.
|
||||
2. Otherwise, the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is used (RFC 3161 over HTTP).
|
||||
|
||||
Selection is made at boot from the discovered transport, not at runtime; there is no try-then-fall-through. If the chosen source fails, the recipient's sign attempt fails.
|
||||
|
||||
### Seal time — PAdES B-LTA archival
|
||||
|
||||
The seal-document job emits a single archival `/DocTimeStamp` over the fully-signed envelope (plus DSS for the existing signatures and the timestamp's own chain). This phase is **env-only**: the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is always used.
|
||||
|
||||
The archival anchor is the operator's long-term trust anchor and SHOULD point at a dedicated qualified archival TSA (e.g. DigiCert) independent of the per-recipient TSP. We deliberately do not fall back to the TSP at seal time: archive longevity should not be coupled to a TSP that may rotate or revoke, and the seal-document job has no recipient context to carry a service-scope bearer.
|
||||
|
||||
### Boot-time guard
|
||||
|
||||
The instance refuses to start in CSC mode unless `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set (`CSC_PROVIDER_NO_TSA` at transport construction). The env var is required unconditionally — even when the TSP advertises its own `signatures/timestamp`, seal-time B-LTA archival uses the env TSA. Catching this at boot prevents the failure mode where an envelope signs successfully at B-T and then hangs in `WAITING_FOR_SIGNATURE_COMPLETION` when the seal job throws.
|
||||
|
||||
## Switching Transports
|
||||
|
||||
`NEXT_PRIVATE_SIGNING_TRANSPORT` is a one-way operational migration. Existing envelopes route per the `signatureLevel` column they were created with — the runtime branching looks at the envelope, not the env var. After a switch:
|
||||
|
||||
- Envelopes already at `SES` continue to use the new transport for sealing, but the new transport's signer must produce SES-compatible signatures (only `local` and `gcloud-hsm` qualify).
|
||||
- Envelopes already at `AES` / `QES` will fail at sign or seal time if the new transport is not `csc`.
|
||||
|
||||
Plan migrations during a quiet window with no in-flight envelopes.
|
||||
|
||||
## Behavioural Notes
|
||||
|
||||
CSC mode changes a number of envelope-authoring behaviours that operators should communicate to users.
|
||||
|
||||
### Mutation lock at distribution
|
||||
|
||||
For AES/QES envelopes, all authoring routes refuse mutations once the envelope leaves DRAFT. This locks the PDF before any recipient begins Strong Customer Authentication, closing the PDF-swap window that would otherwise allow an owner to replace the PDF between view and sign and break the legal "what you see is what you sign" guarantee.
|
||||
|
||||
In practice: edit envelope, recipients, fields, and items freely while DRAFT; once sent, no changes are accepted (including from the API).
|
||||
|
||||
### Sequential signing only
|
||||
|
||||
Parallel signing produces conflicting incremental updates over the same base PDF, breaking the per-recipient `/ByteRange` invariant. The signing order is forced to `SEQUENTIAL` on AES/QES envelopes — at the schema layer, at send time, and in the UI (the parallel-signing toggle is hidden).
|
||||
|
||||
### Assistant role and Dictate Next Signer disabled
|
||||
|
||||
Both features modify the recipient set after the envelope is sent, which is incompatible with the AES/QES mutation lock. They are hidden in the UI and rejected at the server schema layer.
|
||||
|
||||
### Sidecar PDFs at download
|
||||
|
||||
The signed PDF must remain byte-identical to what each recipient's TSP signature authorised — Documenso cannot decorate it after signing. Audit logs and the Certificate of Completion are generated on demand and delivered as separate PDFs:
|
||||
|
||||
- `GET /sign/{token}/download` returns the signed PDF only (or a ZIP for multi-item envelopes).
|
||||
- `GET /sign/{token}/download?version=bundle` returns a ZIP containing the signed PDFs, audit log PDF, and Certificate of Completion.
|
||||
- The completion email attaches all three.
|
||||
|
||||
## Recipient Flow
|
||||
|
||||
For context when supporting end users, here is what a recipient experiences on an AES/QES envelope:
|
||||
|
||||
1. Opens the email link, lands on the signing page.
|
||||
2. Documenso redirects to the TSP for Strong Customer Authentication (first visit only; cached for the session lifetime).
|
||||
3. Fills fields as normal.
|
||||
4. Clicks Sign → redirected to the TSP for a second authentication round (issues a per-document Signature Activation Data token).
|
||||
5. Returns to Documenso; the signing call completes within ~15 seconds.
|
||||
6. Sees the standard completion screen.
|
||||
|
||||
If the TSP returns no eligible credentials for the recipient (e.g. they have not enrolled), they see a blocking page directing them to enrol with the TSP and retry.
|
||||
|
||||
## Error Codes
|
||||
|
||||
CSC-specific error codes surfaced through the standard error channels:
|
||||
|
||||
| Code | Meaning | Recovery |
|
||||
| -------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| `CSC_UNLICENSED` | License flag absent at transport-create | Operator: enable Enterprise Edition, restart |
|
||||
| `CSC_PROVIDER_INFO_FAILED` | `info` discovery failed at startup | Operator: check TSP availability and `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` |
|
||||
| `CSC_PROVIDER_NO_TSA` | `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is unset | Operator: configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` |
|
||||
| `CSC_CREDENTIAL_LIST_EMPTY`| TSP returned no credentials for the user | Recipient: enrol with the TSP |
|
||||
| `CSC_CERT_INVALID` | Certificate refused at credential validation | Recipient: contact the TSP |
|
||||
| `CSC_ALGORITHM_REFUSED` | Signature algorithm fails policy | Operator/recipient: TSP does not meet policy (see below) |
|
||||
| `CSC_SAD_EXPIRED_PRE_SIGN` | Signature Activation Data expired before signing | Recipient: retry from Sign |
|
||||
| `CSC_TSP_TIMEOUT` | 15-second synchronous timeout reached | Recipient: retry (idempotent — the TSP enforces single-use SAD binding) |
|
||||
| `CSC_EMBED_FAILED` | Sign-time digest diverged from prep capture | Recipient: retry from Sign |
|
||||
| `CSC_BASE_DOCUMENT_MUTATED`| Document data changed between prep and sign | Operator: investigate (structural guard violation) |
|
||||
| `CSC_INSTANCE_MODE_MISMATCH`| Envelope created with wrong level for transport | Caller: use a level matching the instance transport |
|
||||
| `CSC_REQUEST_FAILED` | TSP HTTP transport failure — network error, non-2xx, or malformed response | Operator: check TSP availability; carries the TSP HTTP status and error in the message |
|
||||
|
||||
## Algorithm Policy
|
||||
|
||||
Documenso refuses TSP credentials that do not meet the following minimums, at the OAuth callback boundary and again at sign time:
|
||||
|
||||
| Class | Allowed | Refused |
|
||||
| ----- | ---------------------------------- | ------------------------------------------------------ |
|
||||
| RSA | `key.len >= 2048` | Missing `key.len`, `key.len < 2048` |
|
||||
| ECDSA | P-256, P-384, P-521 | Missing `key.curve`, P-192, P-224, other curves |
|
||||
| Hash | SHA-256, SHA-384, SHA-512 | SHA-1, MD5 |
|
||||
| Other | — | DSA |
|
||||
|
||||
This is the union of CSC v1.0.4.0 §11.5 requirements and current cryptographic guidance.
|
||||
|
||||
## Related
|
||||
|
||||
- [Signature Levels](/docs/compliance/signature-levels) — AES / QES legal framework
|
||||
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) — overview of all signing transports
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) — full env reference
|
||||
- [Enterprise Edition](/docs/policies/enterprise-edition) — license requirements
|
||||
@@ -24,6 +24,11 @@ Self-hosted Documenso instances require a signing certificate. You can generate
|
||||
description="Hardware-based key protection with Google Cloud KMS."
|
||||
href="/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm"
|
||||
/>
|
||||
<Card
|
||||
title="CSC (AES / QES)"
|
||||
description="Route signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures."
|
||||
href="/docs/self-hosting/configuration/signing-certificate/csc-qes"
|
||||
/>
|
||||
<Card
|
||||
title="Timestamp Server"
|
||||
description="Add trusted timestamps and customise signature appearance."
|
||||
@@ -38,7 +43,7 @@ Self-hosted Documenso instances require a signing certificate. You can generate
|
||||
|
||||
## Certificate Options
|
||||
|
||||
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM']}>
|
||||
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM', 'CSC (AES / QES)']}>
|
||||
<Tab value="Self-Signed">
|
||||
|
||||
A self-signed certificate is sufficient for most use cases where your industry has no special signing regulations.
|
||||
@@ -79,6 +84,18 @@ For organisations requiring hardware-based key protection, Documenso supports Go
|
||||
|
||||
See [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) for setup instructions.
|
||||
|
||||
</Tab>
|
||||
<Tab value="CSC (AES / QES)">
|
||||
|
||||
For Advanced and Qualified Electronic Signatures under eIDAS, Documenso integrates with third-party Trust Service Providers via the Cloud Signature Consortium API. Each recipient authenticates directly with the TSP, which holds the private key and issues the signature.
|
||||
|
||||
- Per-recipient identity verification by an accredited TSP
|
||||
- Legally equivalent to a handwritten signature within the EU (QES)
|
||||
- Requires an [Enterprise Edition](/docs/policies/enterprise-edition) license
|
||||
- Instance-wide setting; one CSC provider per Documenso install
|
||||
|
||||
See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for setup instructions.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Signing Certificate",
|
||||
"pages": ["...index", "local", "google-cloud-hsm", "timestamp-server", "troubleshooting"]
|
||||
"pages": ["...index", "local", "google-cloud-hsm", "csc-qes", "timestamp-server", "troubleshooting"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Storage Configuration
|
||||
description: Configure file storage for uploaded documents and signed PDFs using database storage (default) or S3-compatible object storage.
|
||||
description: Configure file storage for uploaded documents and signed PDFs using database storage (default), S3-compatible object storage, or Azure Blob Storage.
|
||||
---
|
||||
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
@@ -10,10 +10,11 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
## Storage Options
|
||||
|
||||
| Backend | Best For | Scalability | Configuration |
|
||||
| ---------- | -------------------------------- | ----------- | ------------- |
|
||||
| `database` | Small deployments, simplicity | Limited | None required |
|
||||
| `s3` | Production, large files, backups | High | Required |
|
||||
| Backend | Best For | Scalability | Configuration |
|
||||
| ------------ | --------------------------------------- | ----------- | ------------- |
|
||||
| `database` | Small deployments, simplicity | Limited | None required |
|
||||
| `s3` | Production, large files, backups | High | Required |
|
||||
| `azure-blob` | Production on Azure, native Blob access | High | Required |
|
||||
|
||||
Select the storage backend with the `NEXT_PUBLIC_UPLOAD_TRANSPORT` environment variable:
|
||||
|
||||
@@ -23,6 +24,9 @@ NEXT_PUBLIC_UPLOAD_TRANSPORT=database
|
||||
|
||||
# S3-compatible storage
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT=s3
|
||||
|
||||
# Azure Blob Storage (native)
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
|
||||
```
|
||||
|
||||
---
|
||||
@@ -283,6 +287,111 @@ NEXT_PRIVATE_UPLOAD_REGION=us-east-1
|
||||
|
||||
---
|
||||
|
||||
## Azure Blob Storage
|
||||
|
||||
Azure Blob Storage is supported as a native transport (not S3-compatible). Documenso uses the official `@azure/storage-blob` SDK and signs SAS URLs with the Storage Account key for browser uploads and downloads.
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description |
|
||||
| --------------------------------------- | ------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Set to `azure-blob` |
|
||||
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME` | Azure Storage Account name |
|
||||
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY` | Azure Storage Account access key |
|
||||
| `NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER` | Container name where uploads are stored |
|
||||
|
||||
### Optional Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| `NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT` | Custom Blob endpoint URL. Useful for local development against Azurite (for example `http://127.0.0.1:10000`). | `https://<account>.blob.core.windows.net` |
|
||||
|
||||
### Azure Setup
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Create a Storage Account and Container
|
||||
|
||||
Create a Storage Account in the Azure Portal or via the Azure CLI, then create a container inside it:
|
||||
|
||||
```bash
|
||||
az storage account create \
|
||||
--name yourstorageaccount \
|
||||
--resource-group your-rg \
|
||||
--location eastus \
|
||||
--sku Standard_LRS
|
||||
|
||||
az storage container create \
|
||||
--name documenso-documents \
|
||||
--account-name yourstorageaccount
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure CORS on the container
|
||||
|
||||
The browser uploads documents directly to Azure Blob using a SAS URL, and downloads them the same way, so the Storage Account needs CORS rules that allow your application origin:
|
||||
|
||||
```bash
|
||||
az storage cors add \
|
||||
--services b \
|
||||
--methods GET PUT \
|
||||
--origins https://your-documenso-domain.com \
|
||||
--allowed-headers "Content-Type" "x-ms-blob-type" "Authorization" \
|
||||
--exposed-headers "*" \
|
||||
--max-age 3600 \
|
||||
--account-name yourstorageaccount
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure Environment Variables
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
|
||||
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=yourstorageaccount
|
||||
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=your-account-key
|
||||
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Local Development with Azurite
|
||||
|
||||
Azurite is the official Azure Storage emulator. It supports the Blob REST API with account-key authentication.
|
||||
|
||||
```bash
|
||||
docker run -d --name azurite \
|
||||
-p 10000:10000 -p 10001:10001 -p 10002:10002 \
|
||||
mcr.microsoft.com/azure-storage/azurite
|
||||
```
|
||||
|
||||
Create the container against the well-known development account:
|
||||
|
||||
```bash
|
||||
az storage container create \
|
||||
--name documenso-documents \
|
||||
--connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
|
||||
```
|
||||
|
||||
Configure environment variables to point at the emulator:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
|
||||
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=devstoreaccount1
|
||||
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
|
||||
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
|
||||
NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT=http://127.0.0.1:10000
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
The Azurite key shown above is the public well-known development key, published by Microsoft for emulator use. Never reuse it in production.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## CloudFront CDN (Optional)
|
||||
|
||||
Use Amazon CloudFront to serve documents with lower latency and reduced S3 costs. CloudFront integration uses signed URLs for secure access.
|
||||
|
||||
@@ -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:
|
||||
|
||||
[](https://railway.app/template/bG6D4p)
|
||||
[](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
|
||||
|
||||
@@ -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 |
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "16.2.4"
|
||||
"next": "16.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type EnvelopeCancelDialogProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
trigger?: React.ReactNode;
|
||||
onCancel?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const EnvelopeCancelDialog = ({ id, title, trigger, onCancel }: EnvelopeCancelDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const { mutateAsync: cancelEnvelope, isPending } = trpcReact.envelope.cancel.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: t`Document cancelled`,
|
||||
description: t`"${title}" has been successfully cancelled`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
|
||||
await onCancel?.();
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be cancelled at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are about to cancel <strong>"{title}"</strong>
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>The document signing process will be stopped</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Recipients will be notified that the document was cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>The document will remain in your dashboard marked as Cancelled</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cancel-reason">
|
||||
<Trans>Reason (optional)</Trans>
|
||||
</Label>
|
||||
|
||||
<Textarea
|
||||
id="cancel-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={t`Add an optional reason for cancelling this document`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={() => void cancelEnvelope({ envelopeId: id, reason: reason || undefined })}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trans>Cancel document</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -166,7 +166,7 @@ export const EnvelopeDeleteDialog = ({
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>By deleting this document, the following will occur:</Trans>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
@@ -37,6 +38,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
onDistribute?: () => Promise<void>;
|
||||
@@ -66,7 +68,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -174,9 +176,13 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This envelope could not be distributed at this time. Please try again.`,
|
||||
title: i18n._(errorMessage.title),
|
||||
description: i18n._(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type EnvelopesBulkCancelDialogProps = {
|
||||
envelopeIds: string[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopesBulkCancelDialog = ({
|
||||
envelopeIds,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkCancelDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { mutateAsync: bulkCancelEnvelopes, isPending } = trpc.envelope.bulk.cancel.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
|
||||
if (result.failedIds.length > 0) {
|
||||
toast({
|
||||
title: t`Documents partially cancelled`,
|
||||
description: t`${plural(result.cancelledCount, {
|
||||
one: '# document cancelled.',
|
||||
other: '# documents cancelled.',
|
||||
})} ${plural(result.failedIds.length, {
|
||||
one: '# document could not be cancelled.',
|
||||
other: '# documents could not be cancelled.',
|
||||
})}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Documents cancelled`,
|
||||
description: plural(result.cancelledCount, {
|
||||
one: '# document has been cancelled.',
|
||||
other: '# documents have been cancelled.',
|
||||
}),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while cancelling the documents.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Cancel Documents</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="You are about to cancel the selected document."
|
||||
other="You are about to cancel # documents."
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>Only pending documents you have permission to manage will be cancelled.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>The document signing process will be stopped</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Recipients will be notified that the document was cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>The documents will remain in your dashboard marked as Cancelled</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bulk-cancel-reason">
|
||||
<Trans>Reason (optional)</Trans>
|
||||
</Label>
|
||||
|
||||
<Textarea
|
||||
id="bulk-cancel-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={t`Add an optional reason for cancelling these documents`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void bulkCancelEnvelopes({ envelopeIds, reason: reason || undefined });
|
||||
}}
|
||||
loading={isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trans>Cancel documents</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -222,7 +222,7 @@ export const ManagePublicTemplateDialog = ({
|
||||
.with({ currentStep: 'SELECT_TEMPLATE' }, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<DialogTitle className="w-full max-w-full whitespace-pre-line break-words">
|
||||
{team?.name ? (
|
||||
<Trans>{team.name} direct signing templates</Trans>
|
||||
) : (
|
||||
|
||||
@@ -16,6 +16,17 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* The reason a team member cannot be removed from the team. When set, the delete
|
||||
* dialog explains the reason instead of offering a confirm button.
|
||||
*/
|
||||
export type TeamMemberDeleteDisableReason =
|
||||
| 'TEAM_OWNER'
|
||||
| 'HIGHER_ROLE'
|
||||
| 'INHERIT_MEMBER_ENABLED'
|
||||
| 'INHERITED_MEMBER';
|
||||
|
||||
export type TeamMemberDeleteDialogProps = {
|
||||
teamId: number;
|
||||
@@ -23,7 +34,7 @@ export type TeamMemberDeleteDialogProps = {
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
memberEmail: string;
|
||||
isInheritMemberEnabled: boolean | null;
|
||||
disableReason?: TeamMemberDeleteDisableReason | null;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -34,7 +45,7 @@ export const TeamMemberDeleteDialog = ({
|
||||
memberId,
|
||||
memberName,
|
||||
memberEmail,
|
||||
isInheritMemberEnabled,
|
||||
disableReason,
|
||||
}: TeamMemberDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -86,10 +97,19 @@ export const TeamMemberDeleteDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isInheritMemberEnabled ? (
|
||||
{disableReason ? (
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>You cannot remove members from this team if the inherit member feature is enabled.</Trans>
|
||||
{match(disableReason)
|
||||
.with('TEAM_OWNER', () => <Trans>You cannot remove the organisation owner from the team.</Trans>)
|
||||
.with('HIGHER_ROLE', () => <Trans>You cannot remove a member with a role higher than your own.</Trans>)
|
||||
.with('INHERIT_MEMBER_ENABLED', () => (
|
||||
<Trans>You cannot remove members from this team while the inherit member feature is enabled.</Trans>
|
||||
))
|
||||
.with('INHERITED_MEMBER', () => (
|
||||
<Trans>This member is inherited from a group and cannot be removed from the team directly.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -109,11 +129,10 @@ export const TeamMemberDeleteDialog = ({
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
{!isInheritMemberEnabled && (
|
||||
{!disableReason && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={Boolean(isInheritMemberEnabled)}
|
||||
loading={isDeletingTeamMember}
|
||||
onClick={async () => deleteTeamMember({ teamId, memberId })}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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';
|
||||
@@ -12,11 +13,12 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form';
|
||||
import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form';
|
||||
@@ -35,7 +37,6 @@ export const DirectTemplatePageView = ({
|
||||
directTemplateRecipient,
|
||||
directTemplateToken,
|
||||
}: DirectTemplatePageViewProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { _ } = useLingui();
|
||||
@@ -117,12 +118,15 @@ export const DirectTemplatePageView = ({
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${token}/complete`);
|
||||
window.location.href = `/sign/${token}/complete`;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
|
||||
export type CscRecipientBlockedPageProps = {
|
||||
code: string;
|
||||
recipientToken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal page rendered when the service-scope CSC OAuth callback surfaces a
|
||||
* hard error the recipient can't resolve themselves (empty credential list,
|
||||
* invalid cert, refused algorithm). The blocking-error cookie is read +
|
||||
* cleared by the loader; this page only renders the message + retry CTA.
|
||||
*
|
||||
* The retry link kicks a fresh service-scope OAuth round-trip — useful when
|
||||
* the TSP-side issue is transient (e.g. the recipient's admin has since
|
||||
* provisioned a credential).
|
||||
*/
|
||||
export const CscRecipientBlockedPage = ({ code, recipientToken }: CscRecipientBlockedPageProps) => {
|
||||
const retryUrl = `/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(recipientToken)}`;
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
||||
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
|
||||
<Trans>No signing credentials available</Trans>
|
||||
) : code === AppErrorCode.CSC_CERT_INVALID ? (
|
||||
<Trans>Signing certificate is invalid</Trans>
|
||||
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
|
||||
<Trans>Signing algorithm is not supported</Trans>
|
||||
) : (
|
||||
<Trans>Unable to start the signing flow</Trans>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
|
||||
<Trans>
|
||||
Your signing provider returned no usable credentials for this account. Contact your administrator or signing
|
||||
provider for assistance.
|
||||
</Trans>
|
||||
) : code === AppErrorCode.CSC_CERT_INVALID ? (
|
||||
<Trans>
|
||||
Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or
|
||||
signing provider for assistance.
|
||||
</Trans>
|
||||
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
|
||||
<Trans>
|
||||
Your signing provider does not advertise a signing algorithm this document accepts. Contact your
|
||||
administrator or signing provider for assistance.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Something went wrong while preparing the remote signature. Please try again.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button asChild className="mt-8">
|
||||
<a href={retryUrl}>
|
||||
<Trans>Try again</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type CscRecipientSigningInProgressPageProps = {
|
||||
sessionId: string;
|
||||
recipientToken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rendered when the credential-scope OAuth callback has attached a SAD to the
|
||||
* server-side `CscSession` and set the `csc_sad_session` cookie. The page
|
||||
* auto-fires `enterprise.csc.signEnvelope` on mount and navigates to the
|
||||
* completion page on success. On failure, it surfaces an error message and
|
||||
* a retry CTA pointing at a fresh credential-scope OAuth round-trip.
|
||||
*/
|
||||
export const CscRecipientSigningInProgressPage = ({
|
||||
sessionId,
|
||||
recipientToken,
|
||||
}: CscRecipientSigningInProgressPageProps) => {
|
||||
const { mutateAsync: signEnvelope } = trpc.enterprise.csc.signEnvelope.useMutation();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Ref rather than state for the fire-once guard. Refs mutate synchronously,
|
||||
// so React StrictMode's double-invoke of the effect sees the updated value
|
||||
// on the second pass and short-circuits. A useState guard would still let
|
||||
// the second effect fire because the queued setState from the first run
|
||||
// hasn't been committed yet when the second one reads it — that double-fire
|
||||
// races two signEnvelope calls; whichever loses sees the SAD already
|
||||
// consumed and flashes "Signing failed" before the winning call's
|
||||
// navigation kicks in.
|
||||
const hasFiredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFiredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasFiredRef.current = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
await signEnvelope({ sessionId, recipientToken });
|
||||
|
||||
window.location.href = `/sign/${recipientToken}/complete`;
|
||||
} catch (err) {
|
||||
const parsed = AppError.parseError(err);
|
||||
setError(parsed.code || AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const retryUrl = `/api/csc/oauth/authorize?scope=credential&session=${encodeURIComponent(sessionId)}`;
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
||||
{error ? (
|
||||
<>
|
||||
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing failed</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
{error === AppErrorCode.CSC_TSP_TIMEOUT ? (
|
||||
<Trans>The signing provider did not respond in time. Please retry.</Trans>
|
||||
) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? (
|
||||
<Trans>
|
||||
Your signing authorisation expired before the signature could be applied. Please reauthorise to retry.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Something went wrong while applying your signature. Please retry.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button asChild className="mt-8">
|
||||
<a href={retryUrl}>
|
||||
<Trans>Reauthorise and retry</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2Icon className="h-12 w-12 animate-spin text-primary" />
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Applying your signature</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>Please don't close this tab. The signing provider is finalising your signature.</Trans>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+12
-5
@@ -27,7 +27,6 @@ import type { Field } from '@prisma/client';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
@@ -50,6 +49,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 +61,7 @@ export type DocumentSigningPageViewV1Props = {
|
||||
completedFields: CompletedField[];
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
branding: DocumentSigningBranding;
|
||||
includeSenderDetails: boolean;
|
||||
};
|
||||
|
||||
@@ -68,6 +73,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
includeSenderDetails,
|
||||
branding,
|
||||
}: DocumentSigningPageViewV1Props) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
@@ -77,7 +83,6 @@ export const DocumentSigningPageViewV1 = ({
|
||||
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||
: false;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
@@ -122,7 +127,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
if (documentMeta?.redirectUrl) {
|
||||
window.location.href = documentMeta.redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
window.location.href = `/sign/${recipient.token}/complete`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,7 +147,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 +173,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`}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+2
-3
@@ -17,7 +17,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZRejectDocumentFormSchema = z.object({
|
||||
@@ -41,7 +41,6 @@ export function DocumentSigningRejectDialog({
|
||||
}: DocumentSigningRejectDialogProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -74,7 +73,7 @@ export function DocumentSigningRejectDialog({
|
||||
if (onRejected) {
|
||||
await onRejected(reason);
|
||||
} else {
|
||||
await navigate(`/sign/${token}/rejected`);
|
||||
window.location.href = `/sign/${token}/rejected`;
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
})
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
<a href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
@@ -58,7 +58,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: false }, () => (
|
||||
|
||||
@@ -40,6 +40,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 dark:text-red-300',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: msg`Cancelled`,
|
||||
labelExtended: msg`Document cancelled`,
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 dark:text-red-300',
|
||||
},
|
||||
INBOX: {
|
||||
label: msg`Inbox`,
|
||||
labelExtended: msg`Document inbox`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+103
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -148,6 +148,11 @@ export default function EnvelopeEditorHeader() {
|
||||
<Trans>Rejected</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(DocumentStatus.CANCELLED, () => (
|
||||
<Badge variant="destructive" className="shrink-0">
|
||||
<Trans>Cancelled</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
{autosaveError && (
|
||||
|
||||
@@ -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', {
|
||||
|
||||
+42
-10
@@ -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}
|
||||
|
||||
+98
-45
@@ -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();
|
||||
|
||||
|
||||
+12
-3
@@ -89,7 +89,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
recipientDetails?: { name: string; email: string },
|
||||
) => {
|
||||
try {
|
||||
await completeDocument({
|
||||
const result = await completeDocument({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
accessAuthOptions,
|
||||
@@ -97,6 +97,15 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
});
|
||||
|
||||
// TSP envelopes can't be completed via the SES path; the mutation returns
|
||||
// a credential-scope OAuth URL the recipient must follow to acquire a SAD
|
||||
// before the sync sign mutation can run. Short-circuit here so the
|
||||
// analytics / completion handlers don't run with a still-unsigned doc.
|
||||
if (result.status === 'REDIRECT') {
|
||||
window.location.href = result.redirectUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: envelope.id,
|
||||
@@ -119,7 +128,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
if (envelope.documentMeta.redirectUrl) {
|
||||
window.location.href = envelope.documentMeta.redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
window.location.href = `/sign/${recipient.token}/complete`;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
@@ -197,7 +206,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${token}/complete`);
|
||||
window.location.href = `/sign/${token}/complete`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('err', err);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
))
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
<a href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
@@ -86,7 +86,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: true }, () => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/documen
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { formatDocumentsPath, isMemberManagerOrAbove } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
@@ -30,11 +30,13 @@ import {
|
||||
Pencil,
|
||||
Share,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { EnvelopeCancelDialog } from '~/components/dialogs/envelope-cancel-dialog';
|
||||
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
@@ -74,6 +76,12 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
// Cancelling a document is restricted server-side to the document owner or a
|
||||
// privileged team member (ADMIN/MANAGER). Mirror that here so plain MEMBERs
|
||||
// don't see a Cancel action that would fail on the server.
|
||||
const isPrivilegedTeamMember = isMemberManagerOrAbove(team.currentTeamRole);
|
||||
const canCancelDocument = isOwner || isPrivilegedTeamMember;
|
||||
|
||||
const { canTitleBeChanged } = getEnvelopeItemPermissions(
|
||||
{
|
||||
completedAt: row.completedAt,
|
||||
@@ -105,7 +113,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
recipient?.role !== RecipientRole.CC &&
|
||||
recipient?.role !== RecipientRole.ASSISTANT && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
<a href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
<>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
@@ -126,7 +134,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
<Trans>Approve</Trans>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -184,11 +192,23 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* No point displaying this if there's no functionality. */}
|
||||
{/* <DropdownMenuItem disabled>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Void
|
||||
</DropdownMenuItem> */}
|
||||
{canCancelDocument && isPending && (
|
||||
<EnvelopeCancelDialog
|
||||
id={row.envelopeId}
|
||||
title={row.title}
|
||||
onCancel={async () => {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
}}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
<Trans>Cancel</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EnvelopeDeleteDialog
|
||||
id={row.envelopeId}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
|
||||
@@ -24,6 +24,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
|
||||
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () => ({
|
||||
title: msg`Nothing cancelled`,
|
||||
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
|
||||
icon: XCircle,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
title: msg`We're all empty`,
|
||||
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
|
||||
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||
|
||||
export type EnvelopesTableBulkActionBarProps = {
|
||||
selectedCount: number;
|
||||
onMoveClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onCancelClick?: () => void;
|
||||
onClearSelection: () => void;
|
||||
};
|
||||
|
||||
@@ -13,6 +14,7 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
selectedCount,
|
||||
onMoveClick,
|
||||
onDeleteClick,
|
||||
onCancelClick,
|
||||
onClearSelection,
|
||||
}: EnvelopesTableBulkActionBarProps) => {
|
||||
const { t } = useLingui();
|
||||
@@ -34,6 +36,13 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
<Trans>Move to Folder</Trans>
|
||||
</Button>
|
||||
|
||||
{onCancelClick && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
|
||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } fr
|
||||
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useMemo, useTransition } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
@@ -200,7 +200,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
||||
})
|
||||
.with({ isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
<a href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
@@ -220,7 +220,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: true }, () => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
|
||||
import { TeamMemberDeleteDialog, type TeamMemberDeleteDisableReason } from '../dialogs/team-member-delete-dialog';
|
||||
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
|
||||
import { TeamInheritMemberAlert } from '../general/teams/team-inherit-member-alert';
|
||||
|
||||
@@ -86,6 +86,39 @@ export const TeamMembersTable = () => {
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
// A member is a direct team member when they belong to one of the team's
|
||||
// INTERNAL_TEAM groups. Otherwise they are inherited from an organisation or
|
||||
// custom group and cannot be managed directly from this team.
|
||||
const isMemberPartOfInternalTeamGroup = (memberId: string) =>
|
||||
groups.some(
|
||||
(group) =>
|
||||
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
|
||||
group.members.some((member) => member.id === memberId),
|
||||
);
|
||||
|
||||
// Determine why a member can't be removed from the team (if at all). The delete
|
||||
// dialog uses this to explain the reason instead of attempting a removal that
|
||||
// would fail.
|
||||
const getDeleteDisableReason = (member: (typeof results)['data'][number]): TeamMemberDeleteDisableReason | null => {
|
||||
if (organisation.ownerUserId === member.userId) {
|
||||
return 'TEAM_OWNER';
|
||||
}
|
||||
|
||||
if (!isTeamRoleWithinUserHierarchy(team.currentTeamRole, member.teamRole)) {
|
||||
return 'HIGHER_ROLE';
|
||||
}
|
||||
|
||||
if (memberAccessTeamGroup !== undefined) {
|
||||
return 'INHERIT_MEMBER_ENABLED';
|
||||
}
|
||||
|
||||
if (!isMemberPartOfInternalTeamGroup(member.id)) {
|
||||
return 'INHERITED_MEMBER';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
header: _(msg`Team Member`),
|
||||
@@ -111,15 +144,7 @@ export const TeamMembersTable = () => {
|
||||
},
|
||||
{
|
||||
header: _(msg`Source`),
|
||||
cell: ({ row }) => {
|
||||
const internalTeamGroupFound = groups.find(
|
||||
(group) =>
|
||||
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
|
||||
group.members.some((member) => member.id === row.original.id),
|
||||
);
|
||||
|
||||
return internalTeamGroupFound ? _(msg`Member`) : _(msg`Group`);
|
||||
},
|
||||
cell: ({ row }) => (isMemberPartOfInternalTeamGroup(row.original.id) ? _(msg`Member`) : _(msg`Group`)),
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
@@ -161,16 +186,9 @@ export const TeamMembersTable = () => {
|
||||
memberId={row.original.id}
|
||||
memberName={row.original.name ?? ''}
|
||||
memberEmail={row.original.email}
|
||||
isInheritMemberEnabled={memberAccessTeamGroup !== undefined}
|
||||
disableReason={getDeleteDisableReason(row.original)}
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
disabled={
|
||||
organisation.ownerUserId === row.original.userId ||
|
||||
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
|
||||
}
|
||||
title={_(msg`Remove team member`)}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()} title={_(msg`Remove team member`)}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user