diff --git a/.gitignore b/.gitignore index f31f951a7..9e622a76f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ logs.json # claude .claude -CLAUDE.md \ No newline at end of file +CLAUDE.md + +# agents +.specs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..b6fba867d --- /dev/null +++ b/AGENTS.md @@ -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 `
` `` 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 `string` 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 diff --git a/README.md b/README.md index aa423aa2b..185d9839e 100644 --- a/README.md +++ b/README.md @@ -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 ` from the `apps/web` folder. +> If you want to run with another port than 3000, you can start the application with `next -p ` from the `apps/remix` folder. ### Run as a service diff --git a/SIGNING.md b/SIGNING.md index d8f664cee..cb719ffb8 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -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 diff --git a/apps/documentation/pages/developers/contributing/contributing-translations.mdx b/apps/documentation/pages/developers/contributing/contributing-translations.mdx index e313a4dc1..b8ffbb362 100644 --- a/apps/documentation/pages/developers/contributing/contributing-translations.mdx +++ b/apps/documentation/pages/developers/contributing/contributing-translations.mdx @@ -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." msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso." ``` diff --git a/apps/documentation/pages/developers/self-hosting/how-to.mdx b/apps/documentation/pages/developers/self-hosting/how-to.mdx index 4025ce6d0..cdc5a2c22 100644 --- a/apps/documentation/pages/developers/self-hosting/how-to.mdx +++ b/apps/documentation/pages/developers/self-hosting/how-to.mdx @@ -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. - If you want to run with another port than `3000`, you can start the application with `next -p ` from the `apps/web` folder. + If you want to run with another port than `3000`, you can start the application with `next -p ` from the `apps/remix` folder. @@ -119,16 +119,89 @@ NEXT_PRIVATE_SMTP_USERNAME="" NEXT_PRIVATE_SMTP_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: + + This is the most common source of issues for self-hosters. Please follow these steps carefully. + -```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 + ``` + + + Your certificate MUST have a password. Certificates without passwords will cause "Failed to get + private key bags" errors. + + +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 diff --git a/apps/documentation/pages/users/compliance/standards-and-regulations.mdx b/apps/documentation/pages/users/compliance/standards-and-regulations.mdx index c1c7845df..4d5193944 100644 --- a/apps/documentation/pages/users/compliance/standards-and-regulations.mdx +++ b/apps/documentation/pages/users/compliance/standards-and-regulations.mdx @@ -19,13 +19,13 @@ device, and other FDA-regulated industries. - [x] User Access Management - [x] Quality Assurance Documentation -## SOC/ SOC II +## SOC 2 - - Status: [Planned](https://github.com/documenso/backlog/issues/24) + + Status: [Compliant](https://documen.so/trust) -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). Status: [Planned](https://github.com/documenso/backlog/issues/26) -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 diff --git a/apps/documentation/pages/users/documents/sending-documents.mdx b/apps/documentation/pages/users/documents/sending-documents.mdx index 19d012bc3..bf4d7c394 100644 --- a/apps/documentation/pages/users/documents/sending-documents.mdx +++ b/apps/documentation/pages/users/documents/sending-documents.mdx @@ -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. + + The maximum file size for uploaded documents is 150MB in production. In staging, the limit is + 50MB. + + ![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. diff --git a/apps/documentation/pages/users/organisations/_meta.json b/apps/documentation/pages/users/organisations/_meta.json index b77f9137c..dfc75fc95 100644 --- a/apps/documentation/pages/users/organisations/_meta.json +++ b/apps/documentation/pages/users/organisations/_meta.json @@ -3,5 +3,6 @@ "members": "Members", "groups": "Groups", "teams": "Teams", + "sso": "SSO", "billing": "Billing" -} \ No newline at end of file +} diff --git a/apps/documentation/pages/users/organisations/sso/_meta.json b/apps/documentation/pages/users/organisations/sso/_meta.json new file mode 100644 index 000000000..4ba07c6f6 --- /dev/null +++ b/apps/documentation/pages/users/organisations/sso/_meta.json @@ -0,0 +1,4 @@ +{ + "index": "Configuration", + "microsoft-entra-id": "Microsoft Entra ID" +} diff --git a/apps/documentation/pages/users/organisations/sso/index.mdx b/apps/documentation/pages/users/organisations/sso/index.mdx new file mode 100644 index 000000000..c909b3336 --- /dev/null +++ b/apps/documentation/pages/users/organisations/sso/index.mdx @@ -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 + + + Anyone who signs in through your portal will be added to your organisation as a member. + + +## Getting Started + +To set up the SSO Portal, you need to be an organisation owner, admin, or manager. + + + **Enterprise Only**: This feature is only available to Enterprise customers. + + + + +### 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. + + + +## 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 + + + For additional support for SSO Portal configuration, contact our support team at + support@documenso.com. + + +## 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 diff --git a/apps/documentation/pages/users/organisations/sso/microsoft-entra-id.mdx b/apps/documentation/pages/users/organisations/sso/microsoft-entra-id.mdx new file mode 100644 index 000000000..6b3ed6d65 --- /dev/null +++ b/apps/documentation/pages/users/organisations/sso/microsoft-entra-id.mdx @@ -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 + +Each user in your Azure AD will need an email associated with it. + +## Creating an App Registration + + + +### 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. + + + +## 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 diff --git a/apps/documentation/public/organisations/organisations-sso-settings.webp b/apps/documentation/public/organisations/organisations-sso-settings.webp new file mode 100644 index 000000000..b6d46f77e Binary files /dev/null and b/apps/documentation/public/organisations/organisations-sso-settings.webp differ diff --git a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx index 26692fedb..9f82d8551 100644 --- a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx @@ -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 { diff --git a/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx index 58ffa9c85..157cc5284 100644 --- a/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx @@ -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 { diff --git a/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx index ee42931a9..347532a19 100644 --- a/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx @@ -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 { diff --git a/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx index 1718c9e97..64f9aa72d 100644 --- a/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -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 { diff --git a/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx index f95657d9f..59372ecc9 100644 --- a/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx @@ -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 { 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 { @@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminUserResetTwoFactorDialogProps = { className?: string; - user: User; + user: TGetUserResponse; }; export const AdminUserResetTwoFactorDialog = ({ diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index 746ef1570..a802387ef 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -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(); diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index bb87f99dc..57146ed9f 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -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`), diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index b3dc69503..d93f29e84 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -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({ 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 @@ -182,7 +187,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia - diff --git a/apps/remix/app/components/dialogs/passkey-create-dialog.tsx b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx index 6a21895d3..8bfcbe3e5 100644 --- a/apps/remix/app/components/dialogs/passkey-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx @@ -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); diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index 3d4e7ba61..f5822b2f9 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -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(); - - 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; @@ -278,14 +249,7 @@ export function TemplateUseDialog({ )} - + @@ -306,6 +270,7 @@ export function TemplateUseDialog({ diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index aa557132b..bd20c2377 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe type TDeleteTokenByIdMutationSchema = z.infer; - const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({ + const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({ onSuccess() { onDelete?.(); }, diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 60b732a76..21faee750 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -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 = ({
- + {localFields.map((field, index) => { const recipientIndex = recipients.findIndex( (r) => r.id === field.recipientId, diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 09f6a91d2..db429f7e4 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -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 = ({ Sign document - + {isExpanded ? ( + + ) : pendingFields.length > 0 ? ( + + ) : ( + + )}
@@ -442,7 +461,9 @@ export const EmbedDirectTemplateClientPage = ({ - + {showPendingFieldTooltip && pendingFields.length > 0 && ( Click to insert field diff --git a/apps/remix/app/components/embed/embed-document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx index 561fdf4cb..ea14b3f1f 100644 --- a/apps/remix/app/components/embed/embed-document-fields.tsx +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -50,8 +50,10 @@ export const EmbedDocumentFields = ({ onSignField, onUnsignField, }: EmbedDocumentFieldsProps) => { + const highestPageNumber = Math.max(...fields.map((field) => field.page)); + return ( - + {fields.map((field) => match(field.type) .with(FieldType.SIGNATURE, () => ( diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index ef2eedc1c..2f24dceeb 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -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 = ({ )} - + {isExpanded ? ( + + ) : pendingFields.length > 0 ? ( + + ) : ( + + )} @@ -465,7 +486,9 @@ export const EmbedSignDocumentClientPage = ({ - + {showPendingFieldTooltip && pendingFields.length > 0 && ( Click to insert field diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index 37abdcce3..5867a1a2a 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -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 = ({ {hasDocumentLoaded && ( - + {showPendingFieldTooltip && pendingFields.length > 0 && ( { 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 && ( ({ values: { diff --git a/apps/remix/app/components/forms/token.tsx b/apps/remix/app/components/forms/token.tsx index 13b7da0b1..1b8e4f3e3 100644 --- a/apps/remix/app/components/forms/token.tsx +++ b/apps/remix/app/components/forms/token.tsx @@ -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(); const [noExpirationDate, setNoExpirationDate] = useState(false); - const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({ + const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({ onSuccess(data) { setNewlyCreatedToken(data); }, diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index 9e9724d2e..2305bc6af 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -64,7 +64,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { const [pages, setPages] = useState([]); const { data: searchDocumentsData, isPending: isSearchingDocuments } = - trpcReact.document.searchDocuments.useQuery( + trpcReact.document.search.useQuery( { query: search, }, diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index da38f51c4..fedea33c0 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -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 = ({ - + {validateUninsertedFields && uninsertedFields[0] && ( Click to insert field @@ -413,11 +417,11 @@ export const DirectTemplateSigningForm = ({ handleSubmit()} documentTitle={template.title} fields={localFields} fieldsValidated={fieldsValidated} - role={directRecipient.role} + recipient={directRecipient} /> diff --git a/apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx b/apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx new file mode 100644 index 000000000..57891e877 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx @@ -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; + +export type AccessAuth2FAFormProps = { + onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void; + token: string; + error?: string | null; +}; + +export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => { + const [step, setStep] = useState('method-selection'); + const [selectedMethod, setSelectedMethod] = useState(null); + + const [expiresAt, setExpiresAt] = useState(null); + const [millisecondsRemaining, setMillisecondsRemaining] = useState(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 ( +
+ {step === 'method-selection' && ( +
+
+

+ Choose verification method +

+

+ Please select how you'd like to receive your verification code. +

+
+ + {error && ( + + {error} + + )} + +
+ + + {hasAuthenticatorEnabled && ( + + )} +
+
+ )} + + {step === 'code-input' && ( +
+
+ + +

+ Enter verification code +

+
+ +
+ {selectedMethod === 'email' ? ( + + We've sent a 6-digit verification code to your email. Please enter it below to + complete the document. + + ) : ( + + Please open your authenticator app and enter the 6-digit code for this document. + + )} +
+ + + +
+ ( + + + + + + + + + + + + + {expiresAt && millisecondsRemaining !== null && ( +
+ + Expires in{' '} + {DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( + 'mm:ss', + )} + +
+ )} +
+ )} + /> + +
+ + + {selectedMethod === 'email' && ( + + )} +
+
+ + +
+ )} +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx index 22e641713..930738c74 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx @@ -77,7 +77,7 @@ export const DocumentSigningAuthPasskey = ({ }); const { mutateAsync: createPasskeyAuthenticationOptions } = - trpc.auth.createPasskeyAuthenticationOptions.useMutation(); + trpc.auth.passkey.createAuthenticationOptions.useMutation(); const [formErrorCode, setFormErrorCode] = useState(null); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx index 42e5ffd5b..c3b1be53e 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx @@ -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, }, diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index 65503b965..7f0f06e5a 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -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; - onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise; - role: RecipientRole; + onSignatureComplete: ( + nextSigner?: { name: string; email: string }, + accessAuthOptions?: TRecipientAccessAuth, + ) => void | Promise; + recipient: Pick; 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; @@ -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(null); + + const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext(); + const form = useForm({ 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 }, () => Next field) .with({ isComplete: true, role: RecipientRole.APPROVER }, () => Approve) .with({ isComplete: true, role: RecipientRole.VIEWER }, () => ( @@ -128,184 +177,194 @@ export const DocumentSigningCompleteDialog = ({ -
- -
- -
- {match(role) - .with(RecipientRole.VIEWER, () => Complete Viewing) - .with(RecipientRole.SIGNER, () => Complete Signing) - .with(RecipientRole.APPROVER, () => Complete Approval) - .with(RecipientRole.CC, () => Complete Viewing) - .with(RecipientRole.ASSISTANT, () => Complete Assisting) - .exhaustive()} -
-
- -
- {match(role) - .with(RecipientRole.VIEWER, () => ( - - - - You are about to complete viewing " - - {documentTitle} - - ". - -
Are you sure? -
-
- )) - .with(RecipientRole.SIGNER, () => ( - - - - You are about to complete signing " - - {documentTitle} - - ". - -
Are you sure? -
-
- )) - .with(RecipientRole.APPROVER, () => ( - - - - You are about to complete approving{' '} - - "{documentTitle}" - - . - -
Are you sure? -
-
- )) - .otherwise(() => ( - - - - You are about to complete viewing " - - {documentTitle} - - ". - -
Are you sure? -
-
- ))} -
- - {allowDictateNextSigner && ( -
- {!isEditingNextSigner && ( -
-

- The next recipient to sign this document will be{' '} - {form.watch('name')} ( - {form.watch('email')}). -

- - -
- )} - - {isEditingNextSigner && ( -
- ( - - - Name - - - - - - - - )} - /> - - ( - - - Email - - - - - - - )} - /> -
- )} -
- )} - - - - -
- - - +
+ + +
+ {match(recipient.role) + .with(RecipientRole.VIEWER, () => ( + + + + You are about to complete viewing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ )) + .with(RecipientRole.SIGNER, () => ( + + + + You are about to complete signing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ )) + .with(RecipientRole.APPROVER, () => ( + + + + You are about to complete approving{' '} + + "{documentTitle}" + + . + +
Are you sure? +
+
+ )) + .otherwise(() => ( + + + + You are about to complete viewing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ ))}
-
-
-
- + + {allowDictateNextSigner && ( +
+ {!isEditingNextSigner && ( +
+

+ The next recipient to sign this document will be{' '} + {form.watch('name')} ( + {form.watch('email')}). +

+ + +
+ )} + + {isEditingNextSigner && ( +
+ ( + + + Name + + + + + + + + )} + /> + + ( + + + Email + + + + + + + )} + /> +
+ )} +
+ )} + + + + +
+ + + +
+
+ + + + )} + + {showTwoFactorForm && ( + + )}
); diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index b3356bee8..dcc947645 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -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; + 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 (
{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 } diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index a1bf3d24e..6c06cdf44 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -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,8 +21,11 @@ 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'; @@ -40,6 +46,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 +71,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(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 +141,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 (
+ {document.team.teamGlobalSettings.brandingEnabled && + document.team.teamGlobalSettings.brandingLogo && ( + {`${document.team.name}'s + )}

null)}

- + )) + .otherwise(() => ( + + > + + + ))}
@@ -204,10 +336,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} />
@@ -224,7 +359,9 @@ export const DocumentSigningPageView = ({ )} - + {fields .filter( (field) => diff --git a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx index fb531eb37..77e90eff8 100644 --- a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx +++ b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx @@ -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 { diff --git a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx index 16a52194e..84b1dfc4a 100644 --- a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx @@ -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) => ( + + {match(error.code) + .with(ErrorCode.FileTooLarge, () => ( + File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB + )) + .with(ErrorCode.FileInvalidType, () => Only PDF files are allowed) + .with(ErrorCode.FileTooSmall, () => File is too small) + .with(ErrorCode.TooManyFiles, () => ( + Only one file can be uploaded at a time + )) + .otherwise(() => ( + Unknown error + ))} + + )); + + const description = ( + <> + + {file.name} couldn't be uploaded: + + {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, diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index e03744164..023a20a8b 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -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} /> @@ -397,6 +494,7 @@ export const DocumentEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSubjectFormSubmit} + onAutoSave={onAddSubjectFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx index 55f6d85c2..e5fea4d2b 100644 --- a/apps/remix/app/components/general/document/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -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, }, diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx index c4043de3a..326c7553c 100644 --- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -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, }, diff --git a/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx index 10beae93b..abeeacbc4 100644 --- a/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx +++ b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx @@ -32,7 +32,7 @@ export const DocumentPageViewRecentActivity = ({ hasNextPage, fetchNextPage, isFetchingNextPage, - } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( + } = trpc.document.auditLog.find.useInfiniteQuery( { documentId, filterForRecentActivity: true, diff --git a/apps/remix/app/components/general/document/document-upload.tsx b/apps/remix/app/components/general/document/document-upload.tsx index c21fcc5f0..b86a12ecc 100644 --- a/apps/remix/app/components/general/document/document-upload.tsx +++ b/apps/remix/app/components/general/document/document-upload.tsx @@ -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) { diff --git a/apps/remix/app/components/general/folder/folder-card.tsx b/apps/remix/app/components/general/folder/folder-card.tsx index a8d2d3bac..db88ebb7f 100644 --- a/apps/remix/app/components/general/folder/folder-card.tsx +++ b/apps/remix/app/components/general/folder/folder-card.tsx @@ -54,7 +54,7 @@ export const FolderCard = ({ }; return ( - +
diff --git a/apps/remix/app/components/general/legacy-field-warning-popover.tsx b/apps/remix/app/components/general/legacy-field-warning-popover.tsx index 6bd489c27..3165b1be7 100644 --- a/apps/remix/app/components/general/legacy-field-warning-popover.tsx +++ b/apps/remix/app/components/general/legacy-field-warning-popover.tsx @@ -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') { diff --git a/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx b/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx index 0d9c3fc9e..dbca5a8ea 100644 --- a/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx @@ -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) => ( + + {match(error.code) + .with(ErrorCode.FileTooLarge, () => ( + File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB + )) + .with(ErrorCode.FileInvalidType, () => Only PDF files are allowed) + .with(ErrorCode.FileTooSmall, () => File is too small) + .with(ErrorCode.TooManyFiles, () => ( + Only one file can be uploaded at a time + )) + .otherwise(() => ( + Unknown error + ))} + + )); + + const description = ( + <> + + {file.name} couldn't be uploaded: + + {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, diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 3cea126c8..41002b54e 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -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} /> diff --git a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx index bef9189d5..6072a8846 100644 --- a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx +++ b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx @@ -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, diff --git a/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx index e0f3f67c9..9b39a27a8 100644 --- a/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx +++ b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx @@ -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', diff --git a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx index 58e25b179..89a9366b1 100644 --- a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx +++ b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx @@ -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 [ diff --git a/apps/remix/app/components/tables/document-logs-table.tsx b/apps/remix/app/components/tables/document-logs-table.tsx index 8cdae26d5..a042c6a44 100644 --- a/apps/remix/app/components/tables/document-logs-table.tsx +++ b/apps/remix/app/components/tables/document-logs-table.tsx @@ -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, diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx index 1333ca912..c1d68c133 100644 --- a/apps/remix/app/components/tables/documents-table-action-button.tsx +++ b/apps/remix/app/components/tables/documents-table-action-button.tsx @@ -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, }, diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx index 1186afb18..8114c6cc1 100644 --- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx @@ -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({ diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index fa5be7d2d..a003f4d0d 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -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'; diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx index 45f837c17..f2d138e0d 100644 --- a/apps/remix/app/components/tables/inbox-table.tsx +++ b/apps/remix/app/components/tables/inbox-table.tsx @@ -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(); diff --git a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx index e86800149..835bebf55 100644 --- a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx +++ b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx @@ -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`), diff --git a/apps/remix/app/components/tables/settings-security-passkey-table.tsx b/apps/remix/app/components/tables/settings-security-passkey-table.tsx index 3d202900a..b2fe09621 100644 --- a/apps/remix/app/components/tables/settings-security-passkey-table.tsx +++ b/apps/remix/app/components/tables/settings-security-passkey-table.tsx @@ -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, diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx index db1b4d0e8..623ac0938 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -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`), diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx index 35640b28e..27b7509f2 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx @@ -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, diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 0a93d2d66..5aa188895 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -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 {row.original.user.email} ), }, + { + header: t`Actions`, + cell: ({ row }) => ( +
+ +
+ ), + }, ] satisfies DataTableColumnDef[]; }, [organisation]); diff --git a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx index 458553dae..3cd0f5853 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx @@ -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, @@ -33,12 +33,12 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox'; -const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true }); +const ZUserFormSchema = ZUpdateUserRequestSchema.omit({ id: true }); type TUserFormSchema = z.infer; 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), }, @@ -78,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) { return ; } -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({ resolver: zodResolver(ZUserFormSchema), diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx index 87326a5fa..1ef247147 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -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; }); diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx new file mode 100644 index 000000000..db6b7c38d --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx @@ -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; + +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 ; + } + + return ( +
+ + + +
+ ); +} + +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({ + 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 ( +
+ +
+
+ + +
+ +
+ toast({ title: t`Copied to clipboard` })} + /> +
+
+ +

+ This is the URL which users will use to sign in to your organisation. +

+
+ +
+ + +
+ +
+ toast({ title: t`Copied to clipboard` })} + /> +
+
+ +

+ Add this URL to your provider's allowed redirect URIs +

+
+ +
+ + + + +

+ This is the required scopes you must set in your provider's settings +

+
+ + ( + + + Issuer URL + + + + + + {!form.formState.errors.wellKnownUrl && ( +

+ The OpenID discovery endpoint URL for your provider +

+ )} + +
+ )} + /> + +
+ ( + + + Client ID + + + + + + + )} + /> + + ( + + + Client Secret + + + + + + + )} + /> +
+ + ( + + + Default Organisation Role for New Users + + + + + + + )} + /> + + ( + + + Allowed Email Domains + + +