Compare commits

...

61 Commits

Author SHA1 Message Date
01ff304e6e Merge branch 'main' into feat/add-attachments-reworked 2025-10-10 14:39:21 +03:00
7b17156e56 v1.12.10 2025-10-09 15:32:35 +11:00
86e89e137e fix: bump search limit and path formatting (#2069) 2025-10-09 15:11:43 +11:00
26f65dbdd7 v1.12.9 2025-10-07 17:07:11 +11:00
a902bec96d fix: use select account prompt for sso oidc (#2065)
Use the `select_account` prompt for SSO OIDC to avoid constantly asking
for credentials to be entered with a client has an existing session with
the SSO provider.
2025-10-07 17:06:28 +11:00
399f91de73 feat: improve invite email (#2030)
Improve the email sent to invite a user to approve/sign/assist/view a
document.
The current links "Reject Document" / "Sign Document" confuse some users
as they think the document will be rejected/signed just by clicking on
these buttons.
This change makes it more clear that they will have a chance to view the
document before they can reject/sign.
2025-10-06 16:19:16 +11:00
995bc9c362 feat: support 2fa for document completion (#2063)
Adds support for 2FA when completing a document, also adds support for
using email for 2FA when no authenticator has been associated with the
account.
2025-10-06 16:17:54 +11:00
3467317271 chore: extract translations (#2056)
## Description

Extract translations to be translated
2025-10-02 13:20:30 +10:00
a5eaa8ad47 v1.12.8 2025-09-29 23:22:59 +10:00
577691214b fix: add viewed call for embedded signing (#2055)
Adds the missing `viewedDocument` method call for embedded signing.

I believe this had previously been added but was lost as the result of a
merge conflict being resolved.
2025-09-29 23:22:36 +10:00
c7d21c6587 fix: update personal organisation email settings (#2048) 2025-09-29 10:11:00 +03:00
2aa391f917 v1.12.7 2025-09-26 09:57:34 +10:00
681540b501 fix: add removed date formats (#2049)
Add date formats that were removed in a prior pull request causing
issues with certain API requests.
2025-09-26 09:56:46 +10:00
f3305ac306 feat: show branding logo on signing page (#2031)
If the team has the branding enabled & a logo uploaded, it'll show on
the document signing page view.
2025-09-25 22:41:17 +10:00
68b4305b6a feat: add max file size for uploaded documents (#2044) 2025-09-25 22:40:00 +10:00
3de1ea0a02 feat: resend dialog improvements (#2034)
The checkboxes were difficult to see and the "Send reminder" button
wasn't disabled when no recipients were selected. This PR disables the
sending button when there's no selected recipient and improves the
checkboxes visibility.
2025-09-25 22:23:07 +10:00
b8fc47b719 v1.12.6 2025-09-25 22:10:20 +10:00
cfceebd78f feat: change organisation owner in admin panel (#2047)
Allows changing the owner of an organisation within the admin panel,
useful for support requests to change ownership from a testing account
to the main admin account.

<img width="890" height="431" alt="image"
src="https://github.com/user-attachments/assets/475bbbdd-0f26-4f74-aacf-3e793366551d"
/>
2025-09-25 17:13:47 +10:00
b9b3ddfb98 chore: update tests to use new date formats (#2045)
## Description

Update the tests to use the new date formats form this PR
https://github.com/documenso/documenso/pull/2038.
2025-09-25 16:55:31 +10:00
8590502338 fix: file upload error messages (#2041) 2025-09-24 16:06:41 +03:00
53f29daf50 fix: allow dates with and without time (#2038) 2025-09-24 14:46:04 +03:00
197d17ed7b v1.12.5 2025-09-23 21:00:48 +10:00
3c646d9475 feat: remove email requirement for recipients (#2040) 2025-09-23 17:13:52 +10:00
ed4dfc9b55 v1.12.4 2025-09-13 18:08:55 +10:00
32ce573de4 fix: incorrect certificate health logic (#2028) 2025-09-13 18:07:39 +10:00
2ecfdbdde5 v1.12.3 2025-09-12 23:02:59 +10:00
90e40e4368 chore: remove duplicate route 2025-09-11 16:03:56 +03:00
2c89b0805a chore: merge main 2025-09-11 14:58:42 +03:00
a3005f8616 fix: Include NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS in docker compose file. (#2022) 2025-09-10 22:55:56 +10:00
2c0d4f8789 chore: self hosting docs update and certificate issues (#1847) 2025-09-09 21:26:42 +10:00
7c8e93b53e feat: implement recipients autosuggestions (#1923) 2025-09-09 20:57:26 +10:00
93a3809f6a fix: add maxLength limits to document input fields (#1988) 2025-09-09 17:52:03 +10:00
4550bca3d3 fix: signature pad translation (#2007) 2025-09-09 17:14:44 +10:00
9ac7b94d9a feat: add organisation sso portal (#1946)
Allow organisations to manage an SSO OIDC compliant portal. This method
is intended to streamline the onboarding process and paves the way to
allow organisations to manage their members in a more strict way.
2025-09-09 17:14:07 +10:00
374f2c45b4 chore: add soc2 compliance (#2019)
added soc2 compliance to docs
2025-09-08 17:56:53 +02:00
bb5c2edefd feat: implement auto-save functionality for signers in document edit form (#1792) 2025-09-02 21:01:16 +10:00
19565c1821 fix: access audit logs for documents in folder (#1989) 2025-08-31 12:17:31 +10:00
2603ae8b90 fix: send signing request email after the document status is updated (#1944)
When sending a document for signing, emails for recipients are sent
before the document status is updated.
In this case, the job "send.signing.requested.email" fails because it
cannot find the document with a PENDING status.
2025-08-31 11:37:49 +10:00
7d257236a6 fix: default pagination on documents list API (#1929) 2025-08-28 16:20:27 +10:00
31c1a9a783 fix: preserve existing recipient properties when adding new recipient (#1987) 2025-08-28 16:19:14 +10:00
657db3bc84 fix: improve mobile signing ux (#2003)
Improves the mobile signing UX making actions available via the floating
navbar more obvious.

Also adds an automatic switch to the complete button once all fields
have been signed.
2025-08-28 16:15:52 +10:00
184ebdedf1 v1.12.2-rc.6 2025-08-26 11:17:43 +10:00
4012022f55 fix: element visible race condition (#1996)
On larger documents we could accidentally start trying to render fields
while not all pages of the PDF have loaded due to us checking for a
single page existing. This would cause an error to be thrown, hard
locking those documents.

This change resolves this by grabbing the highest page number from the
given fields and using it for the visibility check instead.
2025-08-26 11:08:43 +10:00
44f5da95b3 chore: refactor routes (#1992) 2025-08-25 21:00:35 +10:00
7eb882aea8 fix: email domain sender logic (#1993) 2025-08-25 20:59:37 +10:00
dbf10e5b7b chore: add agents file (#1991) 2025-08-25 11:32:15 +10:00
fe4d3ed1fd v1.12.2-rc.5 2025-08-25 09:48:04 +10:00
b8d07fd1a6 fix: refactor token router (#1981) 2025-08-25 08:25:01 +10:00
49fabeb0ec fix: refactor auth router (#1983) 2025-08-25 08:24:32 +10:00
5a5bfe6e34 fix: refactor admin router (#1982) 2025-08-25 08:23:48 +10:00
d7e5a9eec7 fix: refactor document router (#1990) 2025-08-25 08:23:12 +10:00
adefac81e2 fix: outdated docs (#1985) 2025-08-24 16:48:30 +10:00
67501b45cf feat: create document in a specific folder (#1965) 2025-08-23 00:12:17 +10:00
17b36ac8e4 feat: sync organization name with stripe (#1974) 2025-08-22 23:28:04 +10:00
80e452afa2 fix: get accurate pdf page size (#1980)
Handles edge cases with PDF media boxes and crop boxes, deals with
certain documents that had been uploaded with weird combos of sizings.
2025-08-22 22:50:41 +10:00
1cb9de8083 chore: remove 'use client' directives (#1979) 2025-08-22 02:20:41 +00:00
231ef9c27e chore: add support option (#1853) 2025-08-19 20:59:03 +10:00
6f35342a83 feat: reset user 2fa from admin panel (#1943) 2025-08-19 13:09:05 +10:00
a51110d276 fix: prevent document unsigning on edit (#1963) 2025-08-18 13:48:51 +10:00
7f81231467 fix: template e2e tests (#1969) 2025-08-18 12:42:36 +10:00
f53fad8cd7 feat: attachments 2025-07-25 12:11:49 +03:00
370 changed files with 17667 additions and 3493 deletions

View File

@ -136,3 +136,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=

5
.gitignore vendored
View File

@ -56,4 +56,7 @@ logs.json
# claude
.claude
CLAUDE.md
CLAUDE.md
# agents
.specs

57
AGENTS.md Normal file
View File

@ -0,0 +1,57 @@
# Agent Guidelines for Documenso
## Build/Test/Lint Commands
- `npm run build` - Build all packages
- `npm run lint` - Lint all packages
- `npm run lint:fix` - Auto-fix linting issues
- `npm run test:e2e` - Run E2E tests with Playwright
- `npm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode
- `npm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI
- `npm run format` - Format code with Prettier
- `npm run dev` - Start development server for Remix app
## Code Style Guidelines
- Use TypeScript for all code; prefer `type` over `interface`
- Use functional components with `const Component = () => {}`
- Never use classes; prefer functional/declarative patterns
- Use descriptive variable names with auxiliary verbs (isLoading, hasError)
- Directory names: lowercase with dashes (auth-wizard)
- Use named exports for components
- Never use 'use client' directive
- Never use 1-line if statements
- Structure files: exported component, subcomponents, helpers, static content, types
## Error Handling & Validation
- Use custom AppError class when throwing errors
- When catching errors on the frontend use `const error = AppError.parse(error)` to get the error code
- Use early returns and guard clauses
- Use Zod for form validation and react-hook-form for forms
- Use error boundaries for unexpected errors
## UI & Styling
- Use Shadcn UI, Radix, and Tailwind CSS with mobile-first approach
- Use `<Form>` `<FormItem>` elements with fieldset having `:disabled` attribute when loading
- Use Lucide icons with longhand names (HomeIcon vs Home)
## TRPC Routes
- Each route in own file: `routers/teams/create-team.ts`
- Associated types file: `routers/teams/create-team.types.ts`
- Request/response schemas: `Z[RouteName]RequestSchema`, `Z[RouteName]ResponseSchema`
- Only use GET and POST methods in OpenAPI meta
- Deconstruct input argument on its own line
- Prefer route names such as get/getMany/find/create/update/delete
- "create" routes request schema should have the ID and data in the top level
- "update" routes request schema should have the ID in the top level and the data in a nested "data" object
## Translations & Remix
- Use `<Trans>string</Trans>` for JSX translations from `@lingui/react/macro`
- Use `t\`string\`` macro for TypeScript translations
- Use `(params: Route.Params)` and `(loaderData: Route.LoaderData)` for routes
- Directly return data from loaders, don't use `json()`
- Use `superLoaderJson` when sending complex data through loaders such as dates or prisma decimals

View File

@ -214,8 +214,6 @@ For detailed instructions on how to configure and run the Docker container, plea
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
> Please note that the below deployment methods are for v0.9, we will update these to v1.0 once it has been released.
### Fetch, configure, and build
First, clone the code from Github:
@ -258,7 +256,7 @@ 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/web` folder.
> 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
@ -308,7 +306,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on
### 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 Next.js start command
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

View File

@ -10,15 +10,26 @@ For the digital signature of your documents you need a signing certificate in .p
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid.
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid.
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this:
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this:
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
```bash
# Set certificate password securely (won't appear in command history)
read -s -p "Enter certificate password: " CERT_PASS
echo
# Create the p12 certificate using the environment variable
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \
-password env:CERT_PASS \
-keypbe PBE-SHA1-3DES \
-certpbe PBE-SHA1-3DES \
-macalg sha1
```
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
4. **IMPORTANT**: A certificate password is required to prevent signing failures. Make sure to use a strong password (minimum 4 characters) when prompted. Certificates without passwords will cause "Failed to get private key bags" errors during document signing.
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
## Docker

View File

@ -25,7 +25,7 @@ The translation files are organized into folders represented by their respective
Each PO file contains translations which look like this:
```po
#: apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61
#: apps/remix/app/(signing)/sign/[token]/no-longer-available.tsx:61
msgid "Want to send slick signing links like this one? <0>Check out Documenso.</0>"
msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso.</0>"
```

View File

@ -54,7 +54,7 @@ Install the project dependencies as follows:
```bash
npm i
npm run build:web
npm run build
npm run prisma:migrate-deploy
```
@ -69,7 +69,7 @@ npm run start
This will start the server on `localhost:3000`. Any reverse proxy can handle the front end and SSL termination.
<Callout type="info">
If you want to run with another port than `3000`, you can start the application with `next -p <ANY PORT>` from the `apps/web` folder.
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.
</Callout>
</Steps>
@ -119,16 +119,89 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
```
### Update the Volume Binding
### Set Up Your Signing Certificate
The `cert.p12` file is required to sign and encrypt documents, so you must provide your key file. Update the volume binding in the `compose.yml` file to point to your key file:
<Callout type="warning">
This is the most common source of issues for self-hosters. Please follow these steps carefully.
</Callout>
```yaml
volumes:
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
```
The `cert.p12` file is required to sign and encrypt documents. You have three options:
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
#### Option A: Generate Certificate Inside Container (Recommended)
This method avoids file permission issues by creating the certificate directly inside the Docker container:
1. Start your containers:
```bash
docker-compose up -d
```
2. Set certificate password securely and generate certificate inside the container:
```bash
# Set certificate password securely (won't appear in command history)
read -s -p "Enter certificate password: " CERT_PASS
echo
# Generate certificate inside container using environment variable
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /tmp/private.key \
-out /tmp/certificate.crt \
-subj '/C=US/ST=State/L=City/O=Organization/CN=localhost' && \
openssl pkcs12 -export -out /app/certs/cert.p12 \
-inkey /tmp/private.key -in /tmp/certificate.crt \
-passout env:CERT_PASS && \
rm /tmp/private.key /tmp/certificate.crt
"
```
3. Add the certificate passphrase to your `.env` file:
```bash
NEXT_PRIVATE_SIGNING_PASSPHRASE="your_password_here"
```
4. Restart the container to apply changes:
```bash
docker-compose restart documenso
```
#### Option B: Use an Existing Certificate File
If you have an existing `.p12` certificate file:
1. **Place your certificate file** in an accessible location on your host system
2. **Set proper permissions:**
```bash
# Make sure the certificate is readable
chmod 644 /path/to/your/cert.p12
# For Docker, ensure proper ownership
chown 1001:1001 /path/to/your/cert.p12
```
3. **Update the volume binding** in the `compose.yml` file:
```yaml
volumes:
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
```
4. **Add certificate configuration** to your `.env` file:
```bash
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
NEXT_PRIVATE_SIGNING_PASSPHRASE=your_certificate_password
```
<Callout type="warning">
Your certificate MUST have a password. Certificates without passwords will cause "Failed to get
private key bags" errors.
</Callout>
After setting up your certificate, save the `compose.yml` file and run the following command to start the containers:
```bash
docker-compose --env-file ./.env up -d
@ -249,7 +322,7 @@ After=network.target
Environment=PATH=/path/to/your/node/binaries
Type=simple
User=www-data
WorkingDirectory=/var/www/documenso/apps/web
WorkingDirectory=/var/www/documenso/apps/remix
ExecStart=/usr/bin/next start -p 3500
TimeoutSec=15
Restart=always

View File

@ -19,13 +19,13 @@ device, and other FDA-regulated industries.
- [x] User Access Management
- [x] Quality Assurance Documentation
## SOC/ SOC II
## SOC 2
<Callout type="warning" emoji="">
Status: [Planned](https://github.com/documenso/backlog/issues/24)
<Callout type="info" emoji="">
Status: [Compliant](https://documen.so/trust)
</Callout>
SOC II is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
SOC 2 is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
and data privacy in cloud and IT service organizations, established by the American Institute of Certified
Public Accountants (AICPA).
@ -34,9 +34,9 @@ Public Accountants (AICPA).
<Callout type="warning" emoji="⏳">
Status: [Planned](https://github.com/documenso/backlog/issues/26)
</Callout>
ISO 27001 is an international standard for managing information security, specifying requirements for
establishing, implementing, maintaining, and continually improving an information security management
system (ISMS).
ISO 27001 is an international standard for managing information security, specifying requirements
for establishing, implementing, maintaining, and continually improving an information security
management system (ISMS).
### HIPAA

View File

@ -18,6 +18,11 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
<Callout type="info">
The maximum file size for uploaded documents is 150MB in production. In staging, the limit is
50MB.
</Callout>
![Documenso dashboard](/document-signing/documenso-documents-dashboard.webp)
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.

View File

@ -3,5 +3,6 @@
"members": "Members",
"groups": "Groups",
"teams": "Teams",
"sso": "SSO",
"billing": "Billing"
}
}

View File

@ -0,0 +1,4 @@
{
"index": "Configuration",
"microsoft-entra-id": "Microsoft Entra ID"
}

View File

@ -0,0 +1,149 @@
---
title: SSO Portal
description: Learn how to set up a custom SSO login portal for your organisation.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Organisation SSO Portal
The SSO Portal provides a dedicated login URL for your organisation that integrates with any OIDC compliant identity provider. This feature provides:
- **Single Sign-On**: Access Documenso using your own authentication system
- **Automatic onboarding**: New users will be automatically added to your organisation when they sign in through the portal
- **Delegated account management**: Your organisation has full control over the users who sign in through the portal
<Callout type="warning">
Anyone who signs in through your portal will be added to your organisation as a member.
</Callout>
## Getting Started
To set up the SSO Portal, you need to be an organisation owner, admin, or manager.
<Callout type="info">
**Enterprise Only**: This feature is only available to Enterprise customers.
</Callout>
<Steps>
### Access Organisation SSO Settings
![Organisation SSO Portal settings](/organisations/organisations-sso-settings.webp)
### Configure SSO Portal
See the [Microsoft Entra ID](/users/organisations/sso/microsoft-entra-id) guide to find the values for the following fields.
#### Issuer URL
Enter the OpenID discovery endpoint URL for your provider. Here are some common examples:
- **Google Workspace**: `https://accounts.google.com/.well-known/openid-configuration`
- **Microsoft Entra ID**: `https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration`
- **Okta**: `https://{your-domain}.okta.com/.well-known/openid-configuration`
- **Auth0**: `https://{your-domain}.auth0.com/.well-known/openid-configuration`
#### Client Credentials
Enter the client ID and client secret provided by your identity provider:
- **Client ID**: The unique identifier for your application
- **Client Secret**: The secret key for authenticating your application
#### Default Organisation Role
Select the default Organisation role that new users will receive when they first sign in through the portal.
#### Allowed Email Domains
Specify which email domains are allowed to sign in through your SSO portal. Separate domains with spaces:
```
your-domain.com another-domain.com
```
Leave this field empty to allow all domains.
### Configure Your Identity Provider
You'll need to configure your identity provider with the following information:
- Redirect URI
- Scopes
These values are found at the top of the page.
### Save Configuration
Toggle the "Enable SSO portal" switch to activate the feature for your organisation.
Click "Update" to save your SSO portal configuration. The portal will be activated once all required fields are completed.
</Steps>
## Testing Your SSO Portal
Once configured, you can test your SSO portal by:
1. Navigating to your portal URL found at the top of the organisation SSO portal settings page
2. Sign in with a test account from your configured domain
3. Verifying that the user is properly provisioned with the correct organisation role
## Best Practices
### Reduce Friction
Create a custom subdomain for your organisation's SSO portal. For example, you can create a subdomain like `documenso.your-organisation.com` which redirects to the portal link.
### Security Considerations
Please note that anyone who signs in through your portal will be added to your organisation as a member.
- **Domain Restrictions**: Use allowed domains to prevent unauthorized access
- **Role Assignment**: Carefully consider the default organisation role for new users
## Troubleshooting
### Common Issues
**"Invalid issuer URL"**
- Verify the issuer URL is correct and accessible
- Ensure the URL follows the OpenID Connect discovery format
**"Client authentication failed"**
- Check that your client ID and client secret are correct
- Verify that your application is properly registered with your identity provider
**"User not provisioned"**
- Check that the user's email domain is in the allowed domains list
- Verify the default organisation role is set correctly
**"Redirect URI mismatch"**
- Ensure the redirect URI in Documenso matches exactly what's configured in your identity provider
- Check for any trailing slashes or protocol mismatches
### Getting Help
If you encounter issues with your SSO portal configuration:
1. Review your identity provider's documentation for OpenID Connect setup
2. Check the Documenso logs for detailed error messages
3. Contact your identity provider's support for provider-specific issues
<Callout type="info">
For additional support for SSO Portal configuration, contact our support team at
support@documenso.com.
</Callout>
## Identity Provider Guides
For detailed setup instructions for specific identity providers:
- [Microsoft Entra ID](/users/organisations/sso/microsoft-entra-id) - Complete guide for Azure AD configuration

View File

@ -0,0 +1,76 @@
---
title: Microsoft Entra ID
description: Learn how to configure Microsoft Entra ID (Azure AD) for your organisation's SSO portal.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Microsoft Entra ID Configuration
Microsoft Entra ID (formerly Azure Active Directory) is a popular identity provider for enterprise SSO. This guide will walk you through creating an app registration and configuring it for use with your Documenso SSO portal.
## Prerequisites
- Access to Microsoft Entra ID (Azure AD) admin center
- Access to your Documenso organisation as an administrator or manager
<Callout type="warning">Each user in your Azure AD will need an email associated with it.</Callout>
## Creating an App Registration
<Steps>
### Access Azure Portal
1. Navigate to the Azure Portal
2. Sign in with your Microsoft Entra ID administrator account
3. Search for "Azure Active Directory" or "Microsoft Entra ID" in the search bar
4. Click on "Microsoft Entra ID" from the results
### Create App Registration
1. In the left sidebar, click on "App registrations"
2. Click the "New registration" button
### Configure App Registration
Fill in the registration form with the following details:
- **Name**: Your preferred name (e.g. `Documenso SSO Portal`)
- **Supported account types**: Choose based on your needs
- **Redirect URI (Web)**: Found in the Documenso SSO portal settings page
Click "Register" to create the app registration.
### Get Client ID
After registration, you'll be taken to the app's overview page. The **Application (client) ID** is displayed prominently - this is your Client ID for Documenso.
### Create Client Secret
1. In the left sidebar, click on "Certificates & secrets"
2. Click "New client secret"
3. Add a description (e.g., "Documenso SSO Secret")
4. Choose an expiration period (recommended 12-24 months)
5. Click "Add"
Make sure you copy the "Secret value", not the "Secret ID", you won't be able to access it again after you leave the page.
</Steps>
## Getting Your OpenID Configuration URL
1. In the Azure portal, go to "Microsoft Entra ID"
2. Click on "Overview" in the left sidebar
3. Click the "Endpoints" in the horizontal tab
4. Copy the "OpenID Connect metadata document" value
## Configure Documenso SSO Portal
Now you have all the information needed to configure your Documenso SSO portal:
- **Issuer URL**: The "OpenID Connect metadata document" value from the previous step
- **Client ID**: The Application (client) ID from your app registration
- **Client Secret**: The secret value you copied during creation

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@ -34,7 +34,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
const [reason, setReason] = useState('');
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
trpc.admin.deleteDocument.useMutation();
trpc.admin.document.delete.useMutation();
const handleDeleteDocument = async () => {
try {

View File

@ -3,12 +3,12 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserDeleteDialogProps = {
className?: string;
user: User;
user: TGetUserResponse;
};
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
@ -35,7 +35,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
const [email, setEmail] = useState('');
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
trpc.admin.deleteUser.useMutation();
trpc.admin.user.delete.useMutation();
const onDeleteAccount = async () => {
try {

View File

@ -3,11 +3,11 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserDisableDialogProps = {
className?: string;
userToDisable: User;
userToDisable: TGetUserResponse;
};
export const AdminUserDisableDialog = ({
@ -37,7 +37,7 @@ export const AdminUserDisableDialog = ({
const [email, setEmail] = useState('');
const { mutateAsync: disableUser, isPending: isDisablingUser } =
trpc.admin.disableUser.useMutation();
trpc.admin.user.disable.useMutation();
const onDisableAccount = async () => {
try {

View File

@ -3,11 +3,11 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserEnableDialogProps = {
className?: string;
userToEnable: User;
userToEnable: TGetUserResponse;
};
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
@ -34,7 +34,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
const [email, setEmail] = useState('');
const { mutateAsync: enableUser, isPending: isEnablingUser } =
trpc.admin.enableUser.useMutation();
trpc.admin.user.enable.useMutation();
const onEnableAccount = async () => {
try {

View File

@ -0,0 +1,159 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { Alert, AlertDescription, AlertTitle } 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 { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserResetTwoFactorDialogProps = {
className?: string;
user: TGetUserResponse;
};
export const AdminUserResetTwoFactorDialog = ({
className,
user,
}: AdminUserResetTwoFactorDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [email, setEmail] = useState('');
const [open, setOpen] = useState(false);
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
trpc.admin.user.resetTwoFactor.useMutation();
const onResetTwoFactor = async () => {
try {
await resetTwoFactor({
userId: user.id,
});
toast({
title: _(msg`2FA Reset`),
description: _(msg`The user's two factor authentication has been reset successfully.`),
duration: 5000,
});
await revalidate();
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(
() => msg`An error occurred while resetting two factor authentication for the user.`,
);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
}
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
setEmail('');
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<div>
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will
disable two factor authentication for the user.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Reset 2FA</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Reset Two Factor Authentication</Trans>
</DialogTitle>
</DialogHeader>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before
proceeding.
</Trans>
</AlertDescription>
</Alert>
<div>
<DialogDescription>
<Trans>
To confirm, please enter the accounts email address <br />({user.email}).
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="destructive"
disabled={email !== user.email}
onClick={onResetTwoFactor}
loading={isResettingTwoFactor}
>
<Trans>Reset 2FA</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};

View File

@ -49,7 +49,7 @@ export const DocumentDeleteDialog = ({
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
onSuccess: async () => {
void refreshLimits();

View File

@ -36,11 +36,12 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
const { data: document, isLoading } = trpcReact.document.get.useQuery(
{
documentId: id,
},
{
queryHash: `document-duplicate-dialog-${id}`,
enabled: open === true,
},
);
@ -55,7 +56,7 @@ export const DocumentDuplicateDialog = ({
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
trpcReact.document.duplicate.useMutation({
onSuccess: async ({ documentId }) => {
toast({
title: _(msg`Document Duplicated`),

View File

@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient, SigningStatus } from '@prisma/client';
import { History } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
@ -71,7 +71,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
@ -85,6 +85,11 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
formState: { isSubmitting },
} = form;
const selectedRecipients = useWatch({
control: form.control,
name: 'recipients',
});
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
@ -151,7 +156,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full"
className="h-5 w-5 rounded-full border border-neutral-400"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
@ -182,7 +187,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
<Button
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans>
</Button>
</div>

View File

@ -65,9 +65,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
});
const { mutateAsync: createPasskeyRegistrationOptions, isPending } =
trpc.auth.createPasskeyRegistrationOptions.useMutation();
trpc.auth.passkey.createRegistrationOptions.useMutation();
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
const { mutateAsync: createPasskey } = trpc.auth.passkey.create.useMutation();
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
setFormError(null);

View File

@ -15,7 +15,6 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
isTemplateRecipientEmailPlaceholder,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -46,50 +45,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -278,14 +249,7 @@ export function TemplateUseDialog({
)}
<FormControl>
<Input
{...field}
placeholder={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: _(msg`Email`)
}
/>
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
</FormControl>
<FormMessage />
</FormItem>
@ -306,6 +270,7 @@ export function TemplateUseDialog({
<FormControl>
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Name`)}
/>
</FormControl>

View File

@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({
onSuccess() {
onDelete?.();
},

View File

@ -172,6 +172,8 @@ export const ConfigureFieldsView = ({
name: 'fields',
});
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
@ -540,7 +542,9 @@ export const ConfigureFieldsView = ({
<div>
<PDFViewer documentData={normalizedDocumentData} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex(
(r) => r.id === field.recipientId,

View File

@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({
token,
updatedAt,
documentData,
recipient,
recipient: _recipient,
fields,
metadata,
hidePoweredBy = false,
@ -91,8 +91,12 @@ export const EmbedDirectTemplateClientPage = ({
localFields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
@ -343,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
{isExpanded ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
>
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
</Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={isThrottled || (hasSignatureField && !signatureValid)}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
@ -442,7 +461,9 @@ export const EmbedDirectTemplateClientPage = ({
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>

View File

@ -50,8 +50,10 @@ export const EmbedDocumentFields = ({
onSignField,
onUnsignField,
}: EmbedDocumentFieldsProps) => {
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (

View File

@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
@ -106,6 +106,8 @@ export const EmbedSignDocumentClientPage = ({
fields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation();
@ -116,6 +118,8 @@ export const EmbedSignDocumentClientPage = ({
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
const assistantSignersId = useId();
const onNextFieldClick = () => {
@ -305,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({
)}
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
{isExpanded ? (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
>
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
@ -465,7 +486,9 @@ export const EmbedSignDocumentClientPage = ({
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>

View File

@ -92,6 +92,8 @@ export const MultiSignDocumentSigningView = ({
[],
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
@ -357,7 +359,9 @@ export const MultiSignDocumentSigningView = ({
</div>
{hasDocumentLoaded && (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip
key={pendingFields[0].id}

View File

@ -3,10 +3,11 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, OrganisationType } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
@ -86,8 +87,10 @@ export const DocumentPreferencesForm = ({
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { user, organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
const placeholderEmail = user.email ?? 'user@example.com';
@ -331,7 +334,7 @@ export const DocumentPreferencesForm = ({
)}
/>
{!isPersonalLayoutMode && (
{!isPersonalLayoutMode && !isPersonalOrganisation && (
<FormField
control={form.control}
name="includeSenderDetails"

View File

@ -114,7 +114,7 @@ export const SignInForm = ({
}, [returnTo]);
const { mutateAsync: createPasskeySigninOptions } =
trpc.auth.createPasskeySigninOptions.useMutation();
trpc.auth.passkey.createSigninOptions.useMutation();
const form = useForm<TSignInFormSchema>({
values: {

View File

@ -0,0 +1,138 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZSupportTicketSchema = z.object({
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type TSupportTicket = z.infer<typeof ZSupportTicketSchema>;
export type SupportTicketFormProps = {
organisationId: string;
teamId?: string | null;
onSuccess?: () => void;
onClose?: () => void;
};
export const SupportTicketForm = ({
organisationId,
teamId,
onSuccess,
onClose,
}: SupportTicketFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: submitSupportTicket, isPending } =
trpc.profile.submitSupportTicket.useMutation();
const form = useForm<TSupportTicket>({
resolver: zodResolver(ZSupportTicketSchema),
defaultValues: {
subject: '',
message: '',
},
});
const isLoading = form.formState.isLoading || isPending;
const onSubmit = async (data: TSupportTicket) => {
const { subject, message } = data;
try {
await submitSupportTicket({
subject,
message,
organisationId,
teamId,
});
toast({
title: t`Support ticket created`,
description: t`Your support request has been submitted. We'll get back to you soon!`,
});
if (onSuccess) {
onSuccess();
}
form.reset();
} catch (err) {
toast({
title: t`Failed to create support ticket`,
description: t`An error occurred. Please try again later.`,
variant: 'destructive',
});
}
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={isLoading} className="flex flex-col gap-4">
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Subject</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Message</Trans>
</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-2 flex flex-row gap-2">
<Button type="submit" size="sm" loading={isLoading}>
<Trans>Submit</Trans>
</Button>
{onClose && (
<Button variant="outline" size="sm" type="button" onClick={onClose}>
<Trans>Close</Trans>
</Button>
)}
</div>
</fieldset>
</form>
</Form>
</>
);
};

View File

@ -13,7 +13,7 @@ import type { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -47,7 +47,7 @@ export const EXPIRATION_DATES = {
ONE_YEAR: msg`12 months`,
} as const;
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.pick({
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
tokenName: true,
expirationDate: true,
});
@ -75,7 +75,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
const [noExpirationDate, setNoExpirationDate] = useState(false);
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data);
},

View File

@ -1,5 +1,3 @@
'use client';
import { DateTime } from 'luxon';
import type { TooltipProps } from 'recharts';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';

View File

@ -64,7 +64,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const [pages, setPages] = useState<string[]>([]);
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
trpcReact.document.search.useQuery(
{
query: search,
},

View File

@ -79,6 +79,8 @@ export const DirectTemplateSigningForm = ({
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
const fieldsRequiringValidation = useMemo(() => {
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
}, [localFields]);
@ -221,7 +223,9 @@ export const DirectTemplateSigningForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
<Trans>Click to insert field</Trans>
@ -413,11 +417,11 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit}
onSignatureComplete={async () => handleSubmit()}
documentTitle={template.title}
fields={localFields}
fieldsValidated={fieldsValidated}
role={directRecipient.role}
recipient={directRecipient}
/>
</div>
</DocumentFlowFormContainerFooter>

View File

@ -0,0 +1,312 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
type FormStep = 'method-selection' | 'code-input';
type TwoFactorMethod = 'email' | 'authenticator';
const ZAccessAuth2FAFormSchema = z.object({
token: z.string().length(6, { message: 'Token must be 6 characters long' }),
});
type TAccessAuth2FAFormSchema = z.infer<typeof ZAccessAuth2FAFormSchema>;
export type AccessAuth2FAFormProps = {
onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void;
token: string;
error?: string | null;
};
export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => {
const [step, setStep] = useState<FormStep>('method-selection');
const [selectedMethod, setSelectedMethod] = useState<TwoFactorMethod | null>(null);
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const [millisecondsRemaining, setMillisecondsRemaining] = useState<number | null>(null);
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useRequiredDocumentSigningAuthContext();
const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } =
trpc.document.accessAuth.request2FAEmail.useMutation();
const form = useForm({
resolver: zodResolver(ZAccessAuth2FAFormSchema),
defaultValues: {
token: '',
},
});
const hasAuthenticatorEnabled = user?.twoFactorEnabled === true;
const onMethodSelect = async (method: TwoFactorMethod) => {
setSelectedMethod(method);
if (method === 'email') {
try {
const result = await request2FAEmail({
token: token,
});
setExpiresAt(result.expiresAt);
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
setStep('code-input');
} catch (error) {
toast({
title: _(msg`An error occurred`),
description: _(
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
),
variant: 'destructive',
});
return;
}
}
setStep('code-input');
};
const onFormSubmit = (data: TAccessAuth2FAFormSchema) => {
if (!selectedMethod) {
return;
}
// Prepare the auth options for the completion attempt
const accessAuthOptions: TRecipientAccessAuth = {
type: 'TWO_FACTOR_AUTH',
token: data.token, // Just the user's code - backend will validate using method type
method: selectedMethod,
};
onSubmit(accessAuthOptions);
};
const onGoBack = () => {
setStep('method-selection');
setSelectedMethod(null);
setExpiresAt(null);
setMillisecondsRemaining(null);
};
const onResendEmail = async () => {
if (selectedMethod !== 'email') {
return;
}
try {
const result = await request2FAEmail({
token: token,
});
setExpiresAt(result.expiresAt);
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
} catch (error) {
toast({
title: _(msg`An error occurred`),
description: _(
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
const interval = setInterval(() => {
if (expiresAt) {
setMillisecondsRemaining(expiresAt.valueOf() - Date.now());
}
}, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
return (
<div className="py-4">
{step === 'method-selection' && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">
<Trans>Choose verification method</Trans>
</h3>
<p className="text-muted-foreground text-sm">
<Trans>Please select how you'd like to receive your verification code.</Trans>
</p>
</div>
{error && (
<Alert variant="destructive" padding="tight" className="text-sm">
{error}
</Alert>
)}
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="flex h-auto w-full justify-start gap-3 p-4"
onClick={async () => onMethodSelect('email')}
disabled={isRequesting2FAEmail}
>
<MailIcon className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">
<Trans>Email verification</Trans>
</div>
<div className="text-muted-foreground text-sm">
<Trans>We'll send a 6-digit code to your email</Trans>
</div>
</div>
</Button>
{hasAuthenticatorEnabled && (
<Button
type="button"
variant="outline"
className="flex h-auto w-full justify-start gap-3 p-4"
onClick={async () => onMethodSelect('authenticator')}
disabled={isRequesting2FAEmail}
>
<KeyIcon className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">
<Trans>Authenticator app</Trans>
</div>
<div className="text-muted-foreground text-sm">
<Trans>Use your authenticator app to generate a code</Trans>
</div>
</div>
</Button>
)}
</div>
</div>
)}
{step === 'code-input' && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onGoBack}>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h3 className="text-lg font-semibold">
<Trans>Enter verification code</Trans>
</h3>
</div>
<div className="text-muted-foreground text-sm">
{selectedMethod === 'email' ? (
<Trans>
We've sent a 6-digit verification code to your email. Please enter it below to
complete the document.
</Trans>
) : (
<Trans>
Please open your authenticator app and enter the 6-digit code for this document.
</Trans>
)}
</div>
<Form {...form}>
<form
id="access-auth-2fa-form"
className="space-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem className="flex-1 items-center justify-center">
<PinInput
{...field}
maxLength={6}
autoFocus
inputMode="numeric"
pattern="^\d+$"
aria-label="2FA code"
containerClassName="h-12 justify-center"
>
<PinInputGroup>
<PinInputSlot className="h-12 w-12 text-lg" index={0} />
<PinInputSlot className="h-12 w-12 text-lg" index={1} />
<PinInputSlot className="h-12 w-12 text-lg" index={2} />
<PinInputSlot className="h-12 w-12 text-lg" index={3} />
<PinInputSlot className="h-12 w-12 text-lg" index={4} />
<PinInputSlot className="h-12 w-12 text-lg" index={5} />
</PinInputGroup>
</PinInput>
{expiresAt && millisecondsRemaining !== null && (
<div
className={cn('text-muted-foreground mt-2 text-center text-sm', {
'text-destructive': millisecondsRemaining <= 0,
})}
>
<Trans>
Expires in{' '}
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
'mm:ss',
)}
</Trans>
</div>
)}
</FormItem>
)}
/>
<div className="mt-4 space-y-2">
<Button
type="submit"
form="access-auth-2fa-form"
className="w-full"
disabled={!form.formState.isValid}
loading={isRequesting2FAEmail || form.formState.isSubmitting}
>
<Trans>Verify & Complete</Trans>
</Button>
{selectedMethod === 'email' && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={onResendEmail}
loading={isRequesting2FAEmail}
>
<Trans>Resend code</Trans>
</Button>
)}
</div>
</fieldset>
</form>
</Form>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,63 @@
import { Trans } from '@lingui/react/macro';
import { LinkIcon } from 'lucide-react';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
export type DocumentSigningAttachmentsDialogProps = {
document: DocumentAndSender;
};
export const DocumentSigningAttachmentsDialog = ({
document,
}: DocumentSigningAttachmentsDialogProps) => {
const attachments = document.attachments ?? [];
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Attachments</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Attachments</Trans>
</DialogTitle>
<DialogDescription>
<Trans>View all attachments for this document.</Trans>
</DialogDescription>
</DialogHeader>
<div className="mt-2 flex flex-col gap-2">
{attachments.length === 0 && (
<span className="text-muted-foreground text-sm">
<Trans>No attachments available.</Trans>
</span>
)}
{attachments.map((attachment, idx) => (
<a
key={attachment.id || idx}
href={attachment.url}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-muted/50 flex items-center gap-2 rounded px-2 py-1"
>
<LinkIcon className="h-4 w-4" />
<span className="truncate">{attachment.label}</span>
</a>
))}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -77,7 +77,7 @@ export const DocumentSigningAuthPasskey = ({
});
const { mutateAsync: createPasskeyAuthenticationOptions } =
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
trpc.auth.passkey.createAuthenticationOptions.useMutation();
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);

View File

@ -93,7 +93,7 @@ export const DocumentSigningAuthProvider = ({
[documentAuthOptions, recipient],
);
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
const passkeyQuery = trpc.auth.passkey.find.useQuery(
{
perPage: MAXIMUM_PASSKEYS,
},

View File

@ -2,12 +2,17 @@ import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
type TRecipientAccessAuth,
ZDocumentAccessAuthSchema,
} from '@documenso/lib/types/document-auth';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -27,15 +32,21 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningCompleteDialogProps = {
isSubmitting: boolean;
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
role: RecipientRole;
onSignatureComplete: (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => void | Promise<void>;
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
@ -47,6 +58,7 @@ export type DocumentSigningCompleteDialogProps = {
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
@ -57,7 +69,7 @@ export const DocumentSigningCompleteDialog = ({
fields,
fieldsValidated,
onSignatureComplete,
role,
recipient,
disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
@ -65,6 +77,11 @@ export const DocumentSigningCompleteDialog = ({
const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
@ -75,6 +92,11 @@ export const DocumentSigningCompleteDialog = ({
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const completionRequires2FA = useMemo(
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
[derivedRecipientAccessAuth],
);
const handleOpenChange = (open: boolean) => {
if (form.formState.isSubmitting || !isComplete) {
return;
@ -93,16 +115,43 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });
} else {
await onSignatureComplete();
// Check if 2FA is required
if (completionRequires2FA && !data.accessAuthOptions) {
setShowTwoFactorForm(true);
return;
}
const nextSigner =
allowDictateNextSigner && data.name && data.email
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions);
} catch (error) {
console.error('Error completing signature:', error);
const err = AppError.parseError(error);
if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) {
// This was a 2FA validation failure - show the 2FA dialog again with error
form.setValue('accessAuthOptions', undefined);
setTwoFactorValidationError('Invalid verification code. Please try again.');
setShowTwoFactorForm(true);
return;
}
}
};
const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => {
form.setValue('accessAuthOptions', validatedAuthOptions);
setShowTwoFactorForm(false);
setTwoFactorValidationError(null);
// Now trigger the form submission with auth options
void form.handleSubmit(onFormSubmit)();
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return (
@ -116,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
loading={isSubmitting}
disabled={disabled}
>
{match({ isComplete, role })
{match({ isComplete, role: recipient.role })
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
@ -128,184 +177,194 @@ export const DocumentSigningCompleteDialog = ({
</DialogTrigger>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
{!showTwoFactorForm && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</Button>
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
{showTwoFactorForm && (
<AccessAuth2FAForm
token={recipient.token}
error={twoFactorValidationError}
onSubmit={onTwoFactorFormSubmit}
/>
)}
</DialogContent>
</Dialog>
);

View File

@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
@ -34,29 +31,33 @@ export type DocumentSigningFormProps = {
document: DocumentAndSender;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
completeDocument: (options: {
accessAuthOptions?: TRecipientAccessAuth;
nextSigner?: { email: string; name: string };
}) => Promise<void>;
isSubmitting: boolean;
fieldsValidated: () => void;
nextRecipient?: RecipientWithFields;
};
export const DocumentSigningForm = ({
document,
recipient,
fields,
redirectUrl,
isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
completeDocument,
isSubmitting,
fieldsValidated,
nextRecipient,
}: DocumentSigningFormProps) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const analytics = useAnalytics();
const assistantSignersId = useId();
@ -66,21 +67,12 @@ export const DocumentSigningForm = ({
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: {
selectedSignerId: undefined,
},
});
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
@ -96,9 +88,9 @@ export const DocumentSigningForm = ({
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => {
const localFieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fieldsRequiringValidation);
fieldsValidated();
};
const onAssistantFormSubmit = () => {
@ -113,7 +105,7 @@ export const DocumentSigningForm = ({
setIsAssistantSubmitting(true);
try {
await completeDocument(undefined, nextSigner);
await completeDocument({ nextSigner });
} catch (err) {
toast({
title: 'Error',
@ -126,55 +118,6 @@ export const DocumentSigningForm = ({
}
};
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
return undefined;
}
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;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
return (
<div className="flex h-full flex-col">
{validateUninsertedFields && uninsertedFields[0] && (
@ -205,11 +148,11 @@ export const DocumentSigningForm = ({
isSubmitting={isSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
fieldsValidated={localFieldsValidated}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
@ -364,12 +307,15 @@ export const DocumentSigningForm = ({
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
fieldsValidated={localFieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({
accessAuthOptions,
nextSigner,
})
}
recipient={recipient}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}

View File

@ -1,15 +1,18 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { match } from 'ts-pattern';
import { useNavigate } from 'react-router';
import { P, match } from 'ts-pattern';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@ -18,14 +21,18 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentSigningAttachmentsDialog } from '~/components/general/document-signing/document-signing-attachments-dialog';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@ -40,6 +47,8 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type DocumentSigningPageViewProps = {
@ -63,9 +72,64 @@ export const DocumentSigningPageView = ({
}: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document;
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
const hasAuthenticator = authUser?.twoFactorEnabled
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const navigate = useNavigate();
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const fieldsValidated = () => {
validateFieldsInserted(fieldsRequiringValidation);
};
const completeDocument = async (options: {
accessAuthOptions?: TRecipientAccessAuth;
nextSigner?: { email: string; name: string };
}) => {
const { accessAuthOptions, nextSigner } = options;
const payload = {
token: recipient.token,
documentId: document.id,
accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
let senderName = document.user.name ?? '';
let senderEmail = `(${document.user.email})`;
@ -78,9 +142,42 @@ export const DocumentSigningPageView = ({
const targetSigner =
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
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;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const highestPageNumber = Math.max(...fields.map((field) => field.page));
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
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 && (
<img
src={`/api/branding/logo/team/${document.teamId}`}
alt={`${document.team.name}'s Logo`}
className="mb-4 h-12 w-12 md:mb-2"
/>
)}
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
title={document.title}
@ -135,7 +232,10 @@ export const DocumentSigningPageView = ({
</span>
</div>
<DocumentSigningRejectDialog document={document} token={recipient.token} />
<div className="flex gap-2">
<DocumentSigningAttachmentsDialog document={document} />
<DocumentSigningRejectDialog document={document} token={recipient.token} />
</div>
</div>
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
@ -163,19 +263,55 @@ export const DocumentSigningPageView = ({
.otherwise(() => null)}
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
{match({ hasPendingFields, isExpanded, role: recipient.role })
.with(
{
hasPendingFields: false,
role: P.not(RecipientRole.ASSISTANT),
isExpanded: false,
},
() => (
<div className="md:hidden">
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) =>
completeDocument({ nextSigner })
}
recipient={recipient}
allowDictateNextSigner={
nextRecipient && documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
),
)
.with({ isExpanded: true }, () => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
>
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
))
.otherwise(() => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
))}
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
@ -204,10 +340,13 @@ export const DocumentSigningPageView = ({
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
completeDocument={completeDocument}
isSubmitting={isSubmitting}
fieldsValidated={fieldsValidated}
nextRecipient={nextRecipient}
/>
</div>
</div>
@ -224,7 +363,9 @@ export const DocumentSigningPageView = ({
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
)}
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{fields
.filter(
(field) =>

View File

@ -0,0 +1,192 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
import { AttachmentType } from '@documenso/prisma/generated/types';
import { trpc } from '@documenso/trpc/react';
import type { TSetDocumentAttachmentsSchema } from '@documenso/trpc/server/document-router/set-document-attachments.types';
import { ZSetDocumentAttachmentsSchema } from '@documenso/trpc/server/document-router/set-document-attachments.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
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';
export type AttachmentFormProps = {
documentId: number;
};
export const AttachmentForm = ({ documentId }: AttachmentFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { data: attachmentsData, refetch: refetchAttachments } =
trpc.document.attachments.find.useQuery({
documentId,
});
const { mutateAsync: setDocumentAttachments } = trpc.document.attachments.set.useMutation();
const defaultAttachments = [
{
id: nanoid(12),
label: '',
url: '',
type: AttachmentType.LINK,
},
];
const form = useForm<TSetDocumentAttachmentsSchema>({
resolver: zodResolver(ZSetDocumentAttachmentsSchema),
defaultValues: {
documentId,
attachments: attachmentsData ?? defaultAttachments,
},
});
const {
fields: attachments,
append: appendAttachment,
remove: removeAttachment,
} = useFieldArray({
control: form.control,
name: 'attachments',
});
const onAddAttachment = () => {
appendAttachment({
id: nanoid(12),
label: '',
url: '',
type: AttachmentType.LINK,
});
};
const onRemoveAttachment = (index: number) => {
removeAttachment(index);
};
useEffect(() => {
if (attachmentsData && attachmentsData.length > 0) {
form.setValue('attachments', attachmentsData);
}
}, [attachmentsData]);
const onSubmit = async (data: TSetDocumentAttachmentsSchema) => {
try {
await setDocumentAttachments({
documentId,
attachments: data.attachments,
});
toast({
title: t`Attachment(s) updated`,
description: t`The attachment(s) have been updated successfully`,
});
await refetchAttachments();
} catch (error) {
console.error(error);
toast({
title: t`Something went wrong`,
description: t`We encountered an unknown error while attempting to create the attachments.`,
variant: 'destructive',
duration: 5000,
});
}
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Attachments</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Attachments</Trans>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
{attachments.map((attachment, index) => (
<div key={attachment.id} className="flex items-end gap-2">
<FormField
control={form.control}
name={`attachments.${index}.label`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={t`Attachment label`} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`attachments.${index}.url`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>URL</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="https://..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveAttachment(index)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
</fieldset>
<DialogFooter className="mt-4">
<Button type="button" variant="outline" onClick={onAddAttachment}>
<Trans>Add</Trans>
</Button>
<Button type="submit">
<Trans>Save</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -21,7 +21,7 @@ export const DocumentAuditLogDownloadButton = ({
const { _ } = useLingui();
const { mutateAsync: downloadAuditLogs, isPending } =
trpc.document.downloadAuditLogs.useMutation();
trpc.document.auditLog.download.useMutation();
const onDownloadAuditLogsClick = async () => {
try {

View File

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@ -49,7 +49,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
const { quota, remaining, refreshLimits } = useLimits();
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
@ -108,15 +108,51 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
}
};
const onFileDropRejected = () => {
const onFileDropRejected = (fileRejections: FileRejection[]) => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
title: _(msg`Upload failed`),
description,
duration: 5000,
variant: 'destructive',
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@ -129,8 +165,8 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
void onFileDrop(acceptedFile);
}
},
onDropRejected: () => {
void onFileDropRejected();
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,

View File

@ -59,23 +59,22 @@ export const DocumentEditForm = ({
const utils = trpc.useUtils();
const { data: document, refetch: refetchDocument } =
trpc.document.getDocumentWithDetailsById.useQuery(
{
documentId: initialDocument.id,
},
{
initialData: initialDocument,
...SKIP_QUERY_BATCH_META,
},
);
const { data: document, refetch: refetchDocument } = trpc.document.get.useQuery(
{
documentId: initialDocument.id,
},
{
initialData: initialDocument,
...SKIP_QUERY_BATCH_META,
},
);
const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
const { mutateAsync: updateDocument } = trpc.document.update.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
utils.document.get.setData(
{
documentId: initialDocument.id,
},
@ -84,23 +83,10 @@ export const DocumentEditForm = ({
},
});
const { mutateAsync: setSigningOrderForDocument } =
trpc.document.setSigningOrderForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
);
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ fields: newFields }) => {
utils.document.getDocumentWithDetailsById.setData(
utils.document.get.setData(
{
documentId: initialDocument.id,
},
@ -112,7 +98,7 @@ export const DocumentEditForm = ({
const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ recipients: newRecipients }) => {
utils.document.getDocumentWithDetailsById.setData(
utils.document.get.setData(
{
documentId: initialDocument.id,
},
@ -121,10 +107,10 @@ export const DocumentEditForm = ({
},
});
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({
const { mutateAsync: sendDocument } = trpc.document.distribute.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
utils.document.get.setData(
{
documentId: initialDocument.id,
},
@ -173,34 +159,37 @@ export const DocumentEditForm = ({
return initialStep;
});
const saveSettingsData = async (data: TAddSettingsFormSchema) => {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
return updateDocument({
documentId: document.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
};
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
await updateDocument({
documentId: document.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
await saveSettingsData(data);
setStep('signers');
} catch (err) {
console.error(err);
@ -213,18 +202,50 @@ export const DocumentEditForm = ({
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => {
try {
await Promise.all([
setSigningOrderForDocument({
documentId: document.id,
signingOrder: data.signingOrder,
}),
await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the document settings.`),
variant: 'destructive',
});
}
};
const saveSignersData = async (data: TAddSignersFormSchema) => {
return Promise.all([
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
},
}),
setRecipients({
documentId: document.id,
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth ?? [],
})),
}),
]);
};
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
try {
// For autosave, we need to return the recipients response for form state sync
const [, recipientsResponse] = await Promise.all([
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
},
}),
@ -238,6 +259,24 @@ export const DocumentEditForm = ({
}),
]);
return recipientsResponse;
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
throw err; // Re-throw so the autosave hook can handle the error
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
await saveSignersData(data);
setStep('fields');
} catch (err) {
console.error(err);
@ -250,12 +289,16 @@ export const DocumentEditForm = ({
}
};
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
return addFields({
documentId: document.id,
fields: data.fields,
});
};
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try {
await addFields({
documentId: document.id,
fields: data.fields,
});
await saveFieldsData(data);
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
@ -277,24 +320,60 @@ export const DocumentEditForm = ({
}
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const onAddFieldsFormAutoSave = async (data: TAddFieldsFormSchema) => {
try {
await saveFieldsData(data);
// Don't clear localStorage on auto-save, only on explicit submit
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the fields.`),
variant: 'destructive',
});
}
};
const saveSubjectData = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
try {
await sendDocument({
documentId: document.id,
meta: {
subject,
message,
distributionMethod,
emailId,
emailReplyTo: emailReplyTo || null,
emailSettings: emailSettings,
},
});
return updateDocument({
documentId: document.id,
meta: {
subject,
message,
distributionMethod,
emailId,
emailReplyTo,
emailSettings: emailSettings,
},
});
};
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
return sendDocument({
documentId: document.id,
meta: {
subject,
message,
distributionMethod,
emailId,
emailReplyTo: emailReplyTo || null,
emailSettings,
},
});
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
try {
await sendDocumentWithSubject(data);
if (data.meta.distributionMethod === DocumentDistributionMethod.EMAIL) {
toast({
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
@ -322,6 +401,21 @@ export const DocumentEditForm = ({
}
};
const onAddSubjectFormAutoSave = async (data: TAddSubjectFormSchema) => {
try {
// Save form data without sending the document
await saveSubjectData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the subject form.`),
variant: 'destructive',
});
}
};
const currentDocumentFlow = documentFlow[step];
/**
@ -367,25 +461,28 @@ export const DocumentEditForm = ({
fields={fields}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
/>
<AddSignersFormPartial
key={recipients.length}
key={document.id}
documentFlow={documentFlow.signers}
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields}
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddFieldsFormPartial
key={fields.length}
key={document.id}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
teamId={team.id}
/>
@ -397,6 +494,7 @@ export const DocumentEditForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSubjectFormSubmit}
onAutoSave={onAddSubjectFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
</Stepper>

View File

@ -42,7 +42,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
},

View File

@ -71,7 +71,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
},
@ -100,7 +100,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const onDownloadOriginalClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
},

View File

@ -32,7 +32,7 @@ export const DocumentPageViewRecentActivity = ({
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
} = trpc.document.auditLog.find.useInfiniteQuery(
{
documentId,
filterForRecentActivity: true,

View File

@ -52,7 +52,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
const disabledMessage = useMemo(() => {
if (organisation.subscription && remaining.documents === 0) {

View File

@ -54,7 +54,7 @@ export const FolderCard = ({
};
return (
<Link to={formatPath()} key={folder.id}>
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3">

View File

@ -28,7 +28,7 @@ export const LegacyFieldWarningPopover = ({
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
trpc.document.updateDocument.useMutation();
trpc.document.update.useMutation();
const onUpdateFieldsClick = async () => {
if (type === 'document') {

View File

@ -321,6 +321,19 @@ export const OrgMenuSwitcher = () => {
<Trans>Language</Trans>
</DropdownMenuItem>
{currentOrganisation && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to={{
pathname: `/o/${currentOrganisation.url}/support`,
search: currentTeam ? `?team=${currentTeam.id}` : '',
}}
>
<Trans>Support</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
onSelect={async () => authClient.signOut()}

View File

@ -0,0 +1,192 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
import { AttachmentType } from '@documenso/prisma/generated/types';
import { trpc } from '@documenso/trpc/react';
import type { TSetTemplateAttachmentsSchema } from '@documenso/trpc/server/template-router/set-template-attachments.types';
import { ZSetTemplateAttachmentsSchema } from '@documenso/trpc/server/template-router/set-template-attachments.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
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';
export type AttachmentFormProps = {
templateId: number;
};
export const AttachmentForm = ({ templateId }: AttachmentFormProps) => {
const { toast } = useToast();
const { t } = useLingui();
const { data: attachmentsData, refetch: refetchAttachments } =
trpc.template.attachments.find.useQuery({
templateId,
});
const { mutateAsync: setTemplateAttachments } = trpc.template.attachments.set.useMutation();
const defaultAttachments = [
{
id: nanoid(12),
label: '',
url: '',
type: AttachmentType.LINK,
},
];
const form = useForm<TSetTemplateAttachmentsSchema>({
resolver: zodResolver(ZSetTemplateAttachmentsSchema),
defaultValues: {
templateId,
attachments: attachmentsData ?? defaultAttachments,
},
});
const {
fields: attachments,
append: appendAttachment,
remove: removeAttachment,
} = useFieldArray({
control: form.control,
name: 'attachments',
});
const onAddAttachment = () => {
appendAttachment({
id: nanoid(12),
label: '',
url: '',
type: AttachmentType.LINK,
});
};
const onRemoveAttachment = (index: number) => {
removeAttachment(index);
};
useEffect(() => {
if (attachmentsData && attachmentsData.length > 0) {
form.setValue('attachments', attachmentsData);
}
}, [attachmentsData]);
const onSubmit = async (data: TSetTemplateAttachmentsSchema) => {
try {
await setTemplateAttachments({
templateId,
attachments: data.attachments,
});
toast({
title: t`Attachment(s) updated`,
description: t`The attachment(s) have been updated successfully`,
});
await refetchAttachments();
} catch (error) {
console.error(error);
toast({
title: t`Something went wrong`,
description: t`We encountered an unknown error while attempting to create the attachments.`,
variant: 'destructive',
duration: 5000,
});
}
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Attachments</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Attachments</Trans>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
{attachments.map((attachment, index) => (
<div key={attachment.id} className="flex items-end gap-2">
<FormField
control={form.control}
name={`attachments.${index}.label`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={t`Attachment label`} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`attachments.${index}.url`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>URL</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={t`https://...`} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveAttachment(index)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
</fieldset>
<DialogFooter className="mt-4">
<Button type="button" variant="outline" onClick={onAddAttachment}>
<Trans>Add</Trans>
</Button>
<Button type="submit">
<Trans>Save</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -4,8 +4,9 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
@ -67,10 +68,47 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
}
};
const onFileDropRejected = () => {
const onFileDropRejected = (fileRejections: FileRejection[]) => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: _(msg`Your template failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
title: _(msg`Upload failed`),
description,
duration: 5000,
variant: 'destructive',
});
@ -88,8 +126,8 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
void onFileDrop(acceptedFile);
}
},
onDropRejected: () => {
void onFileDropRejected();
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,

View File

@ -124,32 +124,36 @@ export const TemplateEditForm = ({
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
return updateTemplateSettings({
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
};
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
await saveSettingsData(data);
setStep('signers');
} catch (err) {
@ -163,24 +167,44 @@ export const TemplateEditForm = ({
}
};
const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
try {
await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template settings.`),
variant: 'destructive',
});
}
};
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
const [, recipients] = await Promise.all([
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers,
}),
]);
return recipients;
};
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await Promise.all([
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers,
}),
]);
await saveTemplatePlaceholderData(data);
setStep('fields');
} catch (err) {
@ -192,12 +216,48 @@ export const TemplateEditForm = ({
}
};
const onAddTemplatePlaceholderFormAutoSave = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
return await saveTemplatePlaceholderData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template placeholders.`),
variant: 'destructive',
});
throw err;
}
};
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
return addTemplateFields({
templateId: template.id,
fields: data.fields,
});
};
const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormSchema) => {
try {
await saveFieldsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template fields.`),
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
await saveFieldsData(data);
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
@ -270,11 +330,12 @@ export const TemplateEditForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
key={template.id}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
@ -282,15 +343,17 @@ export const TemplateEditForm = ({
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
key={template.id}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
teamId={team?.id}
/>
</Stepper>

View File

@ -67,7 +67,7 @@ export const TemplatePageViewDocumentsTable = ({
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
{
templateId,
page: parsedSearchParams.page,

View File

@ -18,7 +18,7 @@ export const TemplatePageViewRecentActivity = ({
templateId,
documentRootPath,
}: TemplatePageViewRecentActivityProps) => {
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
const { data, isLoading, isLoadingError, refetch } = trpc.document.find.useQuery({
templateId,
orderByColumn: 'createdAt',
orderByDirection: 'asc',

View File

@ -52,7 +52,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
},
});
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
const { mutateAsync: updateRecipient } = trpc.admin.recipient.update.useMutation();
const columns = useMemo(() => {
return [

View File

@ -34,7 +34,7 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.document.findDocumentAuditLogs.useQuery(
const { data, isLoading, isLoadingError } = trpc.document.auditLog.find.useQuery(
{
documentId,
page: parsedSearchParams.page,

View File

@ -45,7 +45,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const onDownloadClick = async () => {
try {
const document = !recipient
? await trpcClient.document.getDocumentById.query(
? await trpcClient.document.get.query(
{
documentId: row.id,
},

View File

@ -77,7 +77,7 @@ export const DocumentsTableActionDropdown = ({
const onDownloadClick = async () => {
try {
const document = !recipient
? await trpcClient.document.getDocumentById.query({
? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
@ -103,7 +103,7 @@ export const DocumentsTableActionDropdown = ({
const onDownloadOriginalClick = async () => {
try {
const document = !recipient
? await trpcClient.document.getDocumentById.query({
? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({

View File

@ -11,7 +11,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
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';

View File

@ -17,7 +17,6 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
@ -32,12 +31,12 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
data?: TFindInboxResponse;
isLoading?: boolean;
isLoadingError?: boolean;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
type DocumentsTableRow = TFindInboxResponse['data'][number];
export const InboxTable = () => {
const { _, i18n } = useLingui();

View File

@ -62,7 +62,7 @@ export const SettingsSecurityPasskeyTableActions = ({
});
const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } =
trpc.auth.updatePasskey.useMutation({
trpc.auth.passkey.update.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@ -84,7 +84,7 @@ export const SettingsSecurityPasskeyTableActions = ({
});
const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } =
trpc.auth.deletePasskey.useMutation({
trpc.auth.passkey.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -26,7 +26,7 @@ export const SettingsSecurityPasskeyTable = () => {
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
const { data, isLoading, isLoadingError } = trpc.auth.passkey.find.useQuery(
{
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,

View File

@ -46,7 +46,9 @@ export const TemplatesTableActionDropdown = ({
const isOwner = row.userId === user.id;
const isTeamTemplate = row.teamId === teamId;
const formatPath = `${templateRootPath}/${row.id}/edit`;
const formatPath = row.folderId
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
: `${templateRootPath}/${row.id}/edit`;
return (
<DropdownMenu>

View File

@ -48,7 +48,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
const { toast } = useToast();
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
trpc.admin.document.reseal.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -33,7 +33,7 @@ export default function AdminDocumentsPage() {
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
trpc.admin.findDocuments.useQuery(
trpc.admin.document.find.useQuery(
{
query: debouncedTerm,
page: page || 1,

View File

@ -71,6 +71,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
});
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
trpc.admin.organisationMember.promoteToOwner.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Member promoted to owner successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't promote the member to owner. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => {
return [
{
@ -101,6 +118,26 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
),
},
{
header: t`Actions`,
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button
variant="outline"
disabled={row.original.userId === organisation?.ownerUserId}
loading={isPromotingToOwner}
onClick={async () =>
promoteToOwner({
organisationId,
userId: row.original.userId,
})
}
>
<Trans>Promote to owner</Trans>
</Button>
</div>
),
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]);

View File

@ -2,14 +2,14 @@ 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 { User } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { Link } from 'react-router';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
@ -27,17 +27,18 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
const ZUserFormSchema = ZUpdateUserRequestSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
export default function UserPage({ params }: { params: { id: number } }) {
const { data: user, isLoading: isLoadingUser } = trpc.profile.getUser.useQuery(
const { data: user, isLoading: isLoadingUser } = trpc.admin.user.get.useQuery(
{
id: Number(params.id),
},
@ -77,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
return <AdminUserPage user={user} />;
}
const AdminUserPage = ({ user }: { user: User }) => {
const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const roles = user.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
const form = useForm<TUserFormSchema>({
resolver: zodResolver(ZUserFormSchema),
@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => {
/>
</div>
<div className="mt-16 flex flex-col items-center gap-4">
{user && <AdminUserDeleteDialog user={user} />}
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
</div>
</div>
);

View File

@ -6,6 +6,7 @@ import {
GroupIcon,
MailboxIcon,
Settings2Icon,
ShieldCheckIcon,
Users2Icon,
} from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
@ -77,6 +78,11 @@ export default function SettingsLayout() {
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/o/${organisation.url}/settings/sso`,
label: t`SSO`,
icon: ShieldCheckIcon,
},
{
path: `/o/${organisation.url}/settings/billing`,
label: t`Billing`,
@ -94,6 +100,13 @@ export default function SettingsLayout() {
return false;
}
if (
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
route.path.includes('/sso')
) {
return false;
}
return true;
});

View File

@ -0,0 +1,432 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import {
formatOrganisationCallbackUrl,
formatOrganisationLoginUrl,
} from '@documenso/lib/utils/organisation-authentication-portal';
import { trpc } from '@documenso/trpc/react';
import { domainRegex } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types';
import type { TGetOrganisationAuthenticationPortalResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-authentication-portal.types';
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-authentication-portal.types';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
.pick({
enabled: true,
wellKnownUrl: true,
clientId: true,
autoProvisionUsers: true,
defaultOrganisationRole: true,
})
.extend({
clientSecret: z.string().nullable(),
allowedDomains: z.string().refine(
(value) => {
const domains = value.split(' ').filter(Boolean);
return domains.every((domain) => domainRegex.test(domain));
},
{
message: msg`Invalid domains`.id,
},
),
});
type TProviderFormSchema = z.infer<typeof ZProviderFormSchema>;
export function meta() {
return appMetaTags('Organisation SSO Portal');
}
export default function OrganisationSettingSSOLoginPage() {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
organisationId: organisation.id,
});
if (isLoadingAuthenticationPortal || !authenticationPortal) {
return <SpinnerBox className="py-32" />;
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
<SSOProviderForm authenticationPortal={authenticationPortal} />
</div>
);
}
type SSOProviderFormProps = {
authenticationPortal: TGetOrganisationAuthenticationPortalResponse;
};
const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: updateOrganisationAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.update.useMutation();
const form = useForm<TProviderFormSchema>({
resolver: zodResolver(ZProviderFormSchema),
defaultValues: {
enabled: authenticationPortal.enabled,
clientId: authenticationPortal.clientId,
clientSecret: authenticationPortal.clientSecretProvided ? null : '',
wellKnownUrl: authenticationPortal.wellKnownUrl,
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
allowedDomains: authenticationPortal.allowedDomains.join(' '),
},
});
const onSubmit = async (values: TProviderFormSchema) => {
const { enabled, clientId, clientSecret, wellKnownUrl } = values;
if (enabled && !clientId) {
form.setError('clientId', {
message: t`Client ID is required`,
});
return;
}
if (enabled && clientSecret === '') {
form.setError('clientSecret', {
message: t`Client secret is required`,
});
return;
}
if (enabled && !wellKnownUrl) {
form.setError('wellKnownUrl', {
message: t`Well-known URL is required`,
});
return;
}
try {
await updateOrganisationAuthenticationPortal({
organisationId: organisation.id,
data: {
enabled,
clientId,
clientSecret: values.clientSecret ?? undefined,
wellKnownUrl,
autoProvisionUsers: values.autoProvisionUsers,
defaultOrganisationRole: values.defaultOrganisationRole,
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
},
});
toast({
title: t`Success`,
description: t`Provider has been updated successfully`,
duration: 5000,
});
} catch (err) {
console.error(err);
toast({
title: t`An error occurred`,
description: t`We couldn't update the provider. Please try again.`,
variant: 'destructive',
});
}
};
const isSsoEnabled = form.watch('enabled');
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-6">
<div className="space-y-2">
<Label>
<Trans>Organisation authentication portal URL</Trans>
</Label>
<div className="relative">
<Input
className="pr-12"
disabled
value={formatOrganisationLoginUrl(organisation.url)}
/>
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={formatOrganisationLoginUrl(organisation.url)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
<Trans>This is the URL which users will use to sign in to your organisation.</Trans>
</p>
</div>
<div className="space-y-2">
<Label>
<Trans>Redirect URI</Trans>
</Label>
<div className="relative">
<Input
className="pr-12"
disabled
value={formatOrganisationCallbackUrl(organisation.url)}
/>
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={formatOrganisationCallbackUrl(organisation.url)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
<Trans>Add this URL to your provider's allowed redirect URIs</Trans>
</p>
</div>
<div className="space-y-2">
<Label>
<Trans>Required scopes</Trans>
</Label>
<Input className="pr-12" disabled value={`openid profile email`} />
<p className="text-muted-foreground text-xs">
<Trans>This is the required scopes you must set in your provider's settings</Trans>
</p>
</div>
<FormField
control={form.control}
name="wellKnownUrl"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Issuer URL</Trans>
</FormLabel>
<FormControl>
<Input
placeholder={'https://your-provider.com/.well-known/openid-configuration'}
{...field}
/>
</FormControl>
{!form.formState.errors.wellKnownUrl && (
<p className="text-muted-foreground text-xs">
<Trans>The OpenID discovery endpoint URL for your provider</Trans>
</p>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Client ID</Trans>
</FormLabel>
<FormControl>
<Input id="client-id" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Client Secret</Trans>
</FormLabel>
<FormControl>
<Input
id="client-secret"
type="password"
{...field}
value={field.value === null ? '**********************' : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="defaultOrganisationRole"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Organisation Role for New Users</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select default role`} />
</SelectTrigger>
<SelectContent>
{ORGANISATION_MEMBER_ROLE_HIERARCHY[OrganisationMemberRole.MANAGER].map(
(role) => (
<SelectItem key={role} value={role}>
{t(ORGANISATION_MEMBER_ROLE_MAP[role])}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="allowedDomains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Allowed Email Domains</Trans>
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t`your-domain.com another-domain.com`}
className="min-h-[80px]"
/>
</FormControl>
{!form.formState.errors.allowedDomains && (
<p className="text-muted-foreground text-xs">
<Trans>
Space-separated list of domains. Leave empty to allow all domains.
</Trans>
</p>
)}
<FormMessage />
</FormItem>
)}
/>
{/* Todo: This is just dummy toggle, we need to decide what this does first. */}
{/* <FormField
control={form.control}
name="autoProvisionUsers"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Auto-provision Users</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<Trans>Automatically create accounts for new users on first login</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Enable SSO portal</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<Trans>Whether to enable the SSO portal for your organisation</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="warning">
<AlertDescription>
<Trans>
Please note that anyone who signs in through your portal will be added to your
organisation as a member.
</Trans>
</AlertDescription>
</Alert>
<div className="flex justify-end gap-2">
<Button loading={form.formState.isSubmitting} type="submit">
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,125 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { BookIcon, HelpCircleIcon, Link2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { SupportTicketForm } from '~/components/forms/support-ticket-form';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Support');
}
export default function SupportPage() {
const [showForm, setShowForm] = useState(false);
const { user } = useSession();
const organisation = useCurrentOrganisation();
const [searchParams] = useSearchParams();
const teamId = searchParams.get('team');
const subscriptionStatus = organisation.subscription?.status;
const handleSuccess = () => {
setShowForm(false);
};
const handleCloseForm = () => {
setShowForm(false);
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mb-8">
<h1 className="flex flex-row items-center gap-2 text-3xl font-bold">
<HelpCircleIcon className="text-muted-foreground h-8 w-8" />
<Trans>Support</Trans>
</h1>
<p className="text-muted-foreground mt-2">
<Trans>Your current plan includes the following support channels:</Trans>
</p>
<div className="mt-6 flex flex-col gap-4">
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<BookIcon className="text-muted-foreground h-5 w-5" />
<Link
to="https://docs.documenso.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Documentation</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>Read our documentation to get started with Documenso.</Trans>
</p>
</div>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Discord</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>
Join our community on{' '}
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
Discord
</Link>{' '}
for community support and discussion.
</Trans>
</p>
</div>
{organisation && IS_BILLING_ENABLED() && subscriptionStatus && (
<>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Trans>Contact us</Trans>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>We'll get back to you as soon as possible via email.</Trans>
</p>
<div className="mt-4">
{!showForm ? (
<Button variant="outline" size="sm" onClick={() => setShowForm(true)}>
<Trans>Create a support ticket</Trans>
</Button>
) : (
<SupportTicketForm
organisationId={organisation.id}
teamId={teamId}
onSuccess={handleSuccess}
onClose={handleCloseForm}
/>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -192,6 +192,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
</Alert>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>
<Trans>Linked Accounts</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>View and manage all login methods linked to your account.</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/linked-accounts">
<Trans>Manage linked accounts</Trans>
</Link>
</Button>
</Alert>
</div>
);
}

View File

@ -0,0 +1,179 @@
import { useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useQuery } from '@tanstack/react-query';
import { DateTime } from 'luxon';
import { authClient } from '@documenso/auth/client';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Linked Accounts');
}
export default function SettingsSecurityLinkedAccounts() {
const { t } = useLingui();
const { data, isLoading, isLoadingError, refetch } = useQuery({
queryKey: ['linked-accounts'],
queryFn: async () => await authClient.account.getMany(),
});
const results = data?.accounts ?? [];
const columns = useMemo(() => {
return [
{
header: t`Provider`,
accessorKey: 'provider',
cell: ({ row }) => row.original.provider,
},
{
header: t`Linked At`,
accessorKey: 'createdAt',
cell: ({ row }) =>
row.original.createdAt
? DateTime.fromJSDate(row.original.createdAt).toRelative()
: t`Unknown`,
},
{
id: 'actions',
cell: ({ row }) => (
<AccountUnlinkDialog
accountId={row.original.id}
provider={row.original.provider}
onSuccess={refetch}
/>
),
},
] satisfies DataTableColumnDef<(typeof results)[number]>[];
}, []);
return (
<div>
<SettingsHeader
title={t`Linked Accounts`}
subtitle={t`View and manage all login methods linked to your account.`}
/>
<div className="mt-4">
<DataTable
columns={columns}
data={results}
hasFilters={false}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-16 rounded" />
</TableCell>
</>
),
}}
/>
</div>
</div>
);
}
type AccountUnlinkDialogProps = {
accountId: string;
provider: string;
onSuccess: () => Promise<unknown>;
};
const AccountUnlinkDialog = ({ accountId, onSuccess, provider }: AccountUnlinkDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const handleRevoke = async () => {
setIsLoading(true);
try {
await authClient.account.delete(accountId);
await onSuccess();
toast({
title: t`Account unlinked`,
});
} catch (error) {
console.error(error);
toast({
title: t`Error`,
description: t`Failed to unlink account`,
variant: 'destructive',
});
}
setIsLoading(false);
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trans>Unlink</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the <span className="font-semibold">{provider}</span> login
method from your account.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button variant="destructive" loading={isLoading} onClick={handleRevoke}>
<Trans>Unlink</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -67,6 +67,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) {

View File

@ -12,6 +12,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { AttachmentForm } from '~/components/general/document/document-attachment-form';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
@ -106,7 +107,7 @@ export default function DocumentEditPage() {
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full items-end justify-between">
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@ -140,11 +141,12 @@ export default function DocumentEditPage() {
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<div className={document.useLegacyFieldInsertion ? 'flex items-center gap-2' : undefined}>
{document.useLegacyFieldInsertion && (
<LegacyFieldWarningPopover type="document" documentId={document.id} />
</div>
)}
)}
<AttachmentForm documentId={document.id} />
</div>
</div>
<DocumentEditForm

View File

@ -50,10 +50,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
if (document.folderId) {
throw redirect(documentRootPath);
}
const recipients = await getRecipientsForDocument({
documentId,
userId: user.id,
@ -68,13 +64,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return {
document,
documentRootPath,
recipients,
documentRootPath,
};
}
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
const { document, documentRootPath, recipients } = loaderData;
const { document, recipients, documentRootPath } = loaderData;
const { _, i18n } = useLingui();

View File

@ -12,10 +12,8 @@ import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';

View File

@ -21,7 +21,7 @@ export function meta() {
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const team = useOptionalCurrentTeam();

View File

@ -9,6 +9,7 @@ import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { AttachmentForm } from '~/components/general/template/template-attachment-form';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
@ -89,6 +90,7 @@ export default function TemplateEditPage() {
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
<AttachmentForm templateId={template.id} />
{template.useLegacyFieldInsertion && (
<div>

View File

@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
export function meta() {
return appMetaTags('Templates');

View File

@ -151,6 +151,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
authLevel = match(accessAuthMethod)
.with('ACCOUNT', () => _(msg`Account Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Authentication`))
.with(undefined, () => _(msg`Email`))
.exhaustive();
}

View File

@ -47,10 +47,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
});
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(undefined, () => true)
.exhaustive();
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!isAccessAuthValid) {
return superLoaderJson({

View File

@ -3,12 +3,12 @@ import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } fr
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
@ -19,6 +19,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -98,16 +99,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
recipientAuth: recipient.authOptions,
});
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
match(accesssAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
.exhaustive(),
);
let recipientHasAccount: boolean | null = null;
if (!isDocumentAccessValid) {
if (!isAccessAuthValid) {
recipientHasAccount = await getUserByEmail({ email: recipient.email })
.then((user) => !!user)
.catch(() => false);

View File

@ -0,0 +1,218 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { MailsIcon } from 'lucide-react';
import { Link, redirect, useSearchParams } from 'react-router';
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/o.$orgUrl.signin';
export function meta() {
return appMetaTags('Sign In');
}
export function ErrorBoundary() {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Authentication Portal Not Found`,
subHeading: msg`404 Not Found`,
message: msg`The organisation authentication portal does not exist, or is not configured`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
export async function loader({ request, params }: Route.LoaderArgs) {
const { isAuthenticated, user } = await getOptionalSession(request);
const orgUrl = params.orgUrl;
const organisation = await prisma.organisation.findFirst({
where: {
url: orgUrl,
},
select: {
name: true,
organisationClaim: true,
organisationAuthenticationPortal: {
select: {
enabled: true,
},
},
members: {
select: {
userId: true,
},
},
},
});
if (
!organisation ||
!organisation.organisationAuthenticationPortal.enabled ||
!organisation.organisationClaim.flags.authenticationPortal
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
// Redirect to organisation if already signed in and a member of the organisation.
if (isAuthenticated && user && organisation.members.find((member) => member.userId === user.id)) {
throw redirect(`/o/${orgUrl}`);
}
return {
organisationName: organisation.name,
orgUrl,
};
}
export default function OrganisationSignIn({ loaderData }: Route.ComponentProps) {
const [searchParams] = useSearchParams();
const { organisationName, orgUrl } = loaderData;
const { t } = useLingui();
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
const action = searchParams.get('action');
const onSignInWithOIDCClick = async () => {
setIsSubmitting(true);
try {
await authClient.oidc.org.signIn({
orgUrl,
});
} catch (err) {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to sign you In. Please try again later.`,
variant: 'destructive',
});
}
setIsSubmitting(false);
};
if (action === 'verification-required') {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex items-start">
<div className="mr-4 mt-1 hidden md:block">
<MailsIcon className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Confirmation email sent</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</Trans>
</p>
<div className="mt-4 flex items-center gap-x-2">
<Button asChild>
<Link to={`/o/${orgUrl}/signin`} replace>
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
<h1 className="text-2xl font-semibold">
<Trans>Welcome to {organisationName}</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Sign in to your account</Trans>
</p>
<hr className="-mx-6 my-4" />
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`flag-3rd-party-service`}
checked={isConfirmationChecked}
onCheckedChange={(checked) =>
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-3rd-party-service`}
>
<Trans>
I understand that I am providing my credentials to a 3rd party service configured by
this organisation
</Trans>
</label>
</div>
<Button
type="button"
size="lg"
variant="outline"
className="bg-background w-full"
loading={isSubmitting}
disabled={!isConfirmationChecked}
onClick={onSignInWithOIDCClick}
>
<Trans>Sign In</Trans>
</Button>
<div className="relative mt-2 flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>OR</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
<div className="text-muted-foreground mt-1 flex items-center justify-center text-xs">
<Link to="/signin">
<Trans>Return to Documenso sign in page here</Trans>
</Link>
</div>
</div>
</div>
);
}

View File

@ -45,6 +45,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
mode: 'insensitive',
},
},
select: {
id: true,
},
});
// Directly convert the team member invite to a team member if they already have an account.

View File

@ -0,0 +1,333 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
import { data, isRouteErrorResponse } from 'react-router';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatOrganisationLoginPath } from '@documenso/lib/utils/organisation-authentication-portal';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { GenericErrorLayout, defaultErrorCodeMap } from '~/components/general/generic-error-layout';
import type { Route } from './+types/organisation.sso.confirmation.$token';
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.data.type : 500;
const errorMap = match(errorCode)
.with('invalid-token', () => ({
subHeading: msg`400 Error`,
heading: msg`Invalid Token`,
message: msg`The token is invalid or has expired.`,
}))
.otherwise(() => defaultErrorCodeMap[500]);
return (
<GenericErrorLayout errorCode={500} errorCodeMap={{ 500: errorMap }} secondaryButton={null} />
);
}
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
throw data({
type: 'invalid-token',
});
}
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
include: {
user: {
select: {
name: true,
email: true,
avatarImageId: true,
},
},
},
});
if (!verificationToken || verificationToken.expires < new Date()) {
throw data({
type: 'invalid-token',
});
}
const metadata = ZOrganisationAccountLinkMetadataSchema.safeParse(verificationToken.metadata);
if (!metadata.success) {
throw data({
type: 'invalid-token',
});
}
const organisation = await prisma.organisation.findFirst({
where: {
id: metadata.data.organisationId,
},
select: {
name: true,
url: true,
avatarImageId: true,
},
});
if (!organisation) {
throw data({
type: 'invalid-token',
});
}
return {
token,
type: metadata.data.type,
user: {
name: verificationToken.user.name,
email: verificationToken.user.email,
avatar: verificationToken.user.avatarImageId,
},
organisation: {
name: organisation.name,
url: organisation.url,
avatar: organisation.avatarImageId,
},
} as const;
}
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
const { token, type, user, organisation } = loaderData;
const { toast } = useToast();
const navigate = useNavigate();
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
const { mutate: declineLinkOrganisationAccount, isPending: isDeclining } =
trpc.enterprise.organisation.authenticationPortal.declineLinkAccount.useMutation({
onSuccess: async () => {
await navigate('/');
toast({
title: 'Account link declined',
});
},
onError: (error) => {
toast({
title: 'Error declining account link',
description: error.message,
});
},
});
const { mutate: linkOrganisationAccount, isPending: isLinking } =
trpc.enterprise.organisation.authenticationPortal.linkAccount.useMutation({
onSuccess: async () => {
await navigate(formatOrganisationLoginPath(organisation.url));
toast({
title: 'Account linked successfully',
});
},
onError: (error) => {
toast({
title: 'Error linking account',
description: error.message,
});
},
});
return (
<div>
<Card className="w-full max-w-2xl border">
<CardHeader>
<CardTitle>
{type === 'link' ? (
<Trans>Account Linking Request</Trans>
) : (
<Trans>Account Creation Request</Trans>
)}
</CardTitle>
<CardDescription>
{type === 'link' ? (
<Trans>
An organisation wants to link your account. Please review the details below.
</Trans>
) : (
<Trans>
An organisation wants to create an account for you. Please review the details below.
</Trans>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Current User Section */}
<div className="space-y-3">
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
<UserCircle2 className="h-4 w-4" />
<Trans>Your Account</Trans>
</h3>
<div className="bg-muted/50 flex items-center justify-between gap-3 rounded-lg p-3">
<AvatarWithText
avatarSrc={formatAvatarUrl(user.avatar)}
avatarFallback={extractInitials(user.name || user.email)}
primaryText={user.name}
secondaryText={user.email}
/>
<Badge variant="secondary">
<Trans>Account</Trans>
</Badge>
</div>
</div>
<Separator />
{/* Organisation Section */}
<div className="space-y-3">
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
<Building2 className="h-4 w-4" />
<Trans>Requesting Organisation</Trans>
</h3>
<div className="bg-muted/50 flex items-center justify-between gap-3 rounded-lg p-3">
<AvatarWithText
avatarSrc={formatAvatarUrl(organisation.avatar)}
avatarFallback={extractInitials(organisation.name)}
primaryText={organisation.name}
secondaryText={`/o/${organisation.url}`}
/>
<Badge variant="secondary">
<Trans>Organisation</Trans>
</Badge>
</div>
</div>
<Separator />
{/* Warnings Section */}
<div className="space-y-3">
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4" />
<Trans>Important: What This Means</Trans>
</h3>
<div className="space-y-3 rounded-lg border p-4">
<p className="text-sm font-medium">
<Trans>
By accepting this request, you grant {organisation.name} the following
permissions:
</Trans>
</p>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<Eye className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="text-muted-foreground font-semibold">
Full account access:
</span>{' '}
View all your profile information, settings, and activity
</Trans>
</span>
</li>
<li className="flex items-start gap-2">
<Settings className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="text-muted-foreground font-semibold">
Account management:
</span>{' '}
Modify your account settings, permissions, and preferences
</Trans>
</span>
</li>
<li className="flex items-start gap-2">
<Database className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="text-muted-foreground font-semibold">Data access:</span>{' '}
Access all data associated with your account
</Trans>
</span>
</li>
</ul>
<Alert variant="warning" className="mt-3">
<AlertDescription>
<Trans>
This organisation will have administrative control over your account. You can
revoke this access later, but they will retain access to any data they've
already collected.
</Trans>
</AlertDescription>
</Alert>
</div>
</div>
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`accept-conditions`}
checked={isConfirmationChecked}
onCheckedChange={(checked) =>
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`accept-conditions`}
>
<Trans>I agree to link my account with this organization</Trans>
</label>
</div>
</CardContent>
<CardFooter className="flex justify-end gap-3">
<Button
variant="outline"
disabled={isDeclining || isLinking}
onClick={() => declineLinkOrganisationAccount({ token })}
>
<Trans>Decline</Trans>
</Button>
<Button
disabled={!isConfirmationChecked || isDeclining || isLinking}
loading={isLinking}
onClick={() => linkOrganisationAccount({ token })}
>
<Trans>Accept & Link Account</Trans>
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@ -0,0 +1,20 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
export const loader = () => {
try {
const certStatus = getCertificateStatus();
return Response.json({
isAvailable: certStatus.isAvailable,
timestamp: new Date().toISOString(),
});
} catch {
return Response.json(
{
isAvailable: false,
timestamp: new Date().toISOString(),
},
{ status: 500 },
);
}
};

View File

@ -1,22 +1,49 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
import { prisma } from '@documenso/prisma';
export async function loader() {
type CheckStatus = 'ok' | 'warning' | 'error';
export const loader = async () => {
const checks: {
database: { status: CheckStatus };
certificate: { status: CheckStatus };
} = {
database: { status: 'ok' },
certificate: { status: 'ok' },
};
let overallStatus: CheckStatus = 'ok';
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({
status: 'ok',
message: 'All systems operational',
});
} catch (err) {
console.error(err);
return Response.json(
{
status: 'error',
message: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 },
);
} catch {
checks.database = { status: 'error' };
overallStatus = 'error';
}
}
try {
const certStatus = getCertificateStatus();
if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' };
} else {
checks.certificate = { status: 'warning' };
if (overallStatus === 'ok') {
overallStatus = 'warning';
}
}
} catch {
checks.certificate = { status: 'error' };
overallStatus = 'error';
}
return Response.json(
{
status: overallStatus,
timestamp: new Date().toISOString(),
checks,
},
{ status: overallStatus === 'error' ? 500 : 200 },
);
};

View File

@ -58,10 +58,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: template.authOptions,
});
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
.with(undefined, () => true)
.exhaustive();
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
.exhaustive(),
);
if (!isAccessAuthValid) {
throw data(

View File

@ -1,10 +1,12 @@
import { RecipientRole } from '@prisma/client';
import { data } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
@ -23,6 +25,8 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/sign.$url';
export async function loader({ params, request }: Route.LoaderArgs) {
const { requestMetadata } = getOptionalLoaderContext();
if (!params.url) {
throw new Response('Not found', { status: 404 });
}
@ -71,10 +75,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: document.authOptions,
});
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(undefined, () => true)
.exhaustive();
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
match(accesssAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
.exhaustive(),
);
if (!isAccessAuthValid) {
throw data(
@ -102,6 +108,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
);
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
});
const allRecipients =
recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({

View File

@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.12.2-rc.4"
"version": "1.12.10"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

View File

@ -109,7 +109,7 @@ COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./pa
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
# Get the start script from docker/start.sh
# Get the start script from docker/
COPY --chown=nodejs:nodejs ./docker/start.sh /app/apps/remix/start.sh
WORKDIR /app/apps/remix

View File

@ -18,27 +18,66 @@ This setup includes a PostgreSQL database and the Documenso application. You wil
3. Create a `.env` file in the same directory and add your SMTP details as well as a few extra environment variables, following the example below:
```
# Generate random secrets (you can use: openssl rand -hex 32)
NEXTAUTH_SECRET="<your-secret>"
NEXT_PRIVATE_ENCRYPTION_KEY="<your-key>"
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-secondary-key>"
# Your application URL
NEXT_PUBLIC_WEBAPP_URL="<your-url>"
# SMTP Configuration
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
NEXT_PRIVATE_SMTP_HOST="<your-host>"
NEXT_PRIVATE_SMTP_PORT=<your-port>
NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
NEXT_PRIVATE_SMTP_FROM_NAME="<your-from-name>"
NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-from-email>"
# Certificate passphrase (required)
NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
```
4. Update the volume binding for the cert file in the `compose.yml` file to point to your own key file:
4. Set up your signing certificate. You have three options:
Since the `cert.p12` file is required for signing and encrypting documents, you will need to provide your own key file. Update the volume binding in the `compose.yml` file to point to your key file:
**Option A: Generate Certificate Inside Container (Recommended)**
Start your containers first, then generate a self-signed certificate:
```bash
# Start containers
docker-compose up -d
# Set certificate password securely (won't appear in command history)
read -s -p "Enter certificate password: " CERT_PASS
echo
# Generate certificate inside container using environment variable
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /tmp/private.key \
-out /tmp/certificate.crt \
-subj '/C=US/ST=State/L=City/O=Organization/CN=localhost' && \
openssl pkcs12 -export -out /app/certs/cert.p12 \
-inkey /tmp/private.key -in /tmp/certificate.crt \
-passout env:CERT_PASS && \
rm /tmp/private.key /tmp/certificate.crt
"
# Restart container
docker-compose restart documenso
```
**Option B: Use Existing Certificate**
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
```yaml
volumes:
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
```
```yaml
volumes:
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
```
1. Run the following command to start the containers:
5. Run the following command to start the containers:
```
docker-compose --env-file ./.env up -d
@ -46,7 +85,7 @@ docker-compose --env-file ./.env up -d
This will start the PostgreSQL database and the Documenso application containers.
5. Access the Documenso application by visiting `http://localhost:3000` in your web browser.
6. Access the Documenso application by visiting `http://localhost:3000` in your web browser.
## Option 2: Standalone Docker Container
@ -69,27 +108,93 @@ docker pull ghcr.io/documenso/documenso
```
docker run -d \
-p 3000:3000 \
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>"
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>"
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>"
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>"
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>"
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>"
-v /path/to/your/keyfile.p12:/opt/documenso/cert.p12
-e NEXTAUTH_SECRET="<your-nextauth-secret>" \
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>" \
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>" \
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>" \
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>" \
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>" \
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>" \
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>" \
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>" \
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>" \
-v /path/to/your/cert.p12:/opt/documenso/cert.p12:ro \
documenso/documenso
```
Replace the placeholders with your actual database and SMTP details.
1. Access the Documenso application by visiting the URL you provided in the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
3. Access the Documenso application by visiting the URL you provided in the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
## Success
You have now successfully set up Documenso using Docker. You can start organizing and managing your documents efficiently. If you encounter any issues or have further questions, please refer to the official Documenso documentation or seek assistance from the community.
You have now successfully set up Documenso using Docker. You can start organizing and managing your documents efficiently.
## Troubleshooting
### Certificate Permission Issues
If you encounter errors related to certificate access, here are common solutions:
#### Error: "Failed to read signing certificate"
1. **Check file exists:**
```bash
ls -la /path/to/your/cert.p12
```
2. **Fix permissions:**
```bash
chmod 644 /path/to/your/cert.p12
chown 1001:1001 /path/to/your/cert.p12
```
3. **Verify Docker mount:**
```bash
docker exec -it <container_name> ls -la /opt/documenso/cert.p12
```
### Container Logs
Check application logs for detailed error information:
```bash
# For Docker Compose
docker-compose logs -f documenso
# For standalone container
docker logs -f <container_name>
```
### Health Checks
Check the status of your Documenso instance:
```bash
# Basic health check (database + certificate)
curl http://localhost:3000/api/health
# Detailed certificate status
curl http://localhost:3000/api/certificate-status
```
The health endpoint will show:
- `status: "ok"` - Everything working properly
- `status: "warning"` - App running but certificate issues
- `status: "error"` - Critical issues (database down, etc.)
### Common Issues
1. **Port already in use:** Change the port mapping in compose.yml or your docker run command
2. **Database connection issues:** Ensure your database is running and accessible
3. **SMTP errors:** Verify your email server settings in the .env file
If you encounter any issues or have further questions, please refer to the official Documenso documentation or seek assistance from the community.
## Advanced Configuration

View File

@ -46,6 +46,7 @@ services:
- NEXT_PRIVATE_SMTP_APIKEY_USER=${NEXT_PRIVATE_SMTP_APIKEY_USER}
- NEXT_PRIVATE_SMTP_APIKEY=${NEXT_PRIVATE_SMTP_APIKEY}
- NEXT_PRIVATE_SMTP_SECURE=${NEXT_PRIVATE_SMTP_SECURE}
- NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=${NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS}
- NEXT_PRIVATE_SMTP_FROM_NAME=${NEXT_PRIVATE_SMTP_FROM_NAME:?err}
- NEXT_PRIVATE_SMTP_FROM_ADDRESS=${NEXT_PRIVATE_SMTP_FROM_ADDRESS:?err}
- NEXT_PRIVATE_SMTP_SERVICE=${NEXT_PRIVATE_SMTP_SERVICE}
@ -63,7 +64,7 @@ services:
ports:
- ${PORT:-3000}:${PORT:-3000}
volumes:
- /opt/documenso/cert.p12:/opt/documenso/cert.p12
- /opt/documenso/cert.p12:/opt/documenso/cert.p12:ro
volumes:
database:

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