diff --git a/.env.example b/.env.example index 87ad09a63..7b8872b69 100644 --- a/.env.example +++ b/.env.example @@ -136,3 +136,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" # OPTIONAL: The file to save the logger output to. Will disable stdout if provided. NEXT_PRIVATE_LOGGER_FILE_PATH= +# [[PLAIN SUPPORT]] +NEXT_PRIVATE_PLAIN_API_KEY= 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 f44b88c2a..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 @@ -308,7 +306,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on ### Support IPv6 -If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command +If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command For local docker run 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/openpage-api/lib/growth/get-monthly-completed-document.ts b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts index f429b0a54..808d7259d 100644 --- a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts +++ b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts @@ -1,4 +1,4 @@ -import { DocumentStatus } from '@prisma/client'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { DateTime } from 'luxon'; import { kyselyPrisma, sql } from '@documenso/prisma'; @@ -7,18 +7,19 @@ import { addZeroMonth } from '../add-zero-month'; export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { const qb = kyselyPrisma.$kysely - .selectFrom('Document') + .selectFrom('Envelope') .select(({ fn }) => [ - fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'), + fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'), fn.count('id').as('count'), fn .sum(fn.count('id')) // Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any - .over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any)) + .over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any)) .as('cume_count'), ]) - .where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`) + .where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`) + .where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`) .groupBy('month') .orderBy('month', 'desc') .limit(12); 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..aee9167cc 100644 --- a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Document } from '@prisma/client'; import { useNavigate } from 'react-router'; import { trpc } from '@documenso/trpc/react'; @@ -22,10 +21,10 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminDocumentDeleteDialogProps = { - document: Document; + envelopeId: string; }; -export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => { +export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -34,7 +33,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 { @@ -42,7 +41,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo return; } - await deleteDocument({ id: document.id, reason }); + await deleteDocument({ id: envelopeId, reason }); toast({ title: _(msg`Document deleted`), 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 new file mode 100644 index 000000000..59372ecc9 --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useRevalidator } from 'react-router'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminUserResetTwoFactorDialogProps = { + className?: string; + user: TGetUserResponse; +}; + +export const AdminUserResetTwoFactorDialog = ({ + className, + user, +}: AdminUserResetTwoFactorDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { revalidate } = useRevalidator(); + const [email, setEmail] = useState(''); + const [open, setOpen] = useState(false); + + const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } = + trpc.admin.user.resetTwoFactor.useMutation(); + + const onResetTwoFactor = async () => { + try { + await resetTwoFactor({ + userId: user.id, + }); + + toast({ + title: _(msg`2FA Reset`), + description: _(msg`The user's two factor authentication has been reset successfully.`), + duration: 5000, + }); + + await revalidate(); + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) + .with( + AppErrorCode.UNAUTHORIZED, + () => msg`You are not authorized to reset two factor authentcation for this user.`, + ) + .otherwise( + () => msg`An error occurred while resetting two factor authentication for the user.`, + ); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + + if (!newOpen) { + setEmail(''); + } + }; + + return ( +
+ +
+ Reset Two Factor Authentication + + + Reset the users two factor authentication. This action is irreversible and will + disable two factor authentication for the user. + + +
+ +
+ + + + + + + + + Reset Two Factor Authentication + + + + + + + This action is irreversible. Please ensure you have informed the user before + proceeding. + + + + +
+ + + To confirm, please enter the accounts email address
({user.email}). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; 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..81ec5bdf4 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,15 +56,15 @@ export const DocumentDuplicateDialog = ({ const documentsPath = formatDocumentsPath(team.url); const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = - trpcReact.document.duplicateDocument.useMutation({ - onSuccess: async ({ documentId }) => { + trpcReact.document.duplicate.useMutation({ + onSuccess: async ({ id }) => { toast({ title: _(msg`Document Duplicated`), description: _(msg`Your document has been successfully duplicated.`), duration: 5000, }); - await navigate(`${documentsPath}/${documentId}/edit`); + await navigate(`${documentsPath}/${id}/edit`); onOpenChange(false); }, }); diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx index 401fb3529..c4c85c051 100644 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx @@ -81,7 +81,7 @@ export const DocumentMoveToFolderDialog = ({ }, ); - const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation(); + const { mutateAsync: updateDocument } = trpc.document.update.useMutation(); useEffect(() => { if (!open) { @@ -94,9 +94,11 @@ export const DocumentMoveToFolderDialog = ({ const onSubmit = async (data: TMoveDocumentFormSchema) => { try { - await moveDocumentToFolder({ + await updateDocument({ documentId, - folderId: data.folderId ?? null, + data: { + folderId: data.folderId ?? null, + }, }); const documentsPath = formatDocumentsPath(team.url); diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index b3dc69503..d8c0a73ee 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -4,15 +4,15 @@ 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 Recipient, SigningStatus } from '@prisma/client'; +import { type Recipient, SigningStatus, type Team, type User } 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'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import type { Document } from '@documenso/prisma/types/document-legacy-schema'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -43,7 +43,11 @@ import { StackAvatar } from '../general/stack-avatar'; const FORM_ID = 'resend-email'; export type DocumentResendDialogProps = { - document: TDocumentRow; + document: Pick & { + user: Pick; + recipients: Recipient[]; + team: Pick | null; + }; recipients: Recipient[]; }; @@ -71,7 +75,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 +89,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 +160,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia @@ -182,7 +191,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia - diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx new file mode 100644 index 000000000..a3aac4436 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -0,0 +1,449 @@ +import { useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { + DocumentDistributionMethod, + DocumentStatus, + EnvelopeType, + type Field, + FieldType, + type Recipient, + RecipientRole, +} from '@prisma/client'; +import { AnimatePresence, motion } from 'framer-motion'; +import { InfoIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import type { TEnvelope } from '@documenso/lib/types/envelope'; +import { formatSigningLink } from '@documenso/lib/utils/recipients'; +import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopeDistributeDialogProps = { + envelope: Pick & { + recipients: Recipient[]; + fields: Field[]; + }; + trigger?: React.ReactNode; +}; + +export const ZEnvelopeDistributeFormSchema = z.object({ + meta: z.object({ + emailId: z.string().nullable(), + emailReplyTo: z.preprocess( + (val) => (val === '' ? undefined : val), + z.string().email().optional(), + ), + subject: z.string(), + message: z.string(), + distributionMethod: z + .nativeEnum(DocumentDistributionMethod) + .optional() + .default(DocumentDistributionMethod.EMAIL), + }), +}); + +export type TEnvelopeDistributeFormSchema = z.infer; + +export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => { + const organisation = useCurrentOrganisation(); + + const recipients = envelope.recipients; + + const { toast } = useToast(); + const { t } = useLingui(); + + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation(); + + const form = useForm({ + defaultValues: { + meta: { + emailId: envelope.documentMeta?.emailId ?? null, + emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined, + subject: envelope.documentMeta?.subject ?? '', + message: envelope.documentMeta?.message ?? '', + distributionMethod: + envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL, + }, + }, + resolver: zodResolver(ZEnvelopeDistributeFormSchema), + }); + + const { + handleSubmit, + setValue, + watch, + formState: { isSubmitting }, + } = form; + + const { data: emailData, isLoading: isLoadingEmails } = + trpc.enterprise.organisation.email.find.useQuery({ + organisationId: organisation.id, + perPage: 100, + }); + + const emails = emailData?.data || []; + + const distributionMethod = watch('meta.distributionMethod'); + + const everySignerHasSignature = useMemo( + () => + envelope.recipients + .filter((recipient) => recipient.role === RecipientRole.SIGNER) + .every((recipient) => + envelope.fields.some( + (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, + ), + ), + [envelope.recipients, envelope.fields], + ); + + const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => { + try { + await distributeEnvelope({ envelopeId: envelope.id, meta }); + + toast({ + title: t`Envelope distributed`, + description: t`Your envelope has been distributed successfully.`, + duration: 5000, + }); + + setIsOpen(false); + } catch (err) { + toast({ + title: t`Something went wrong`, + description: t`This envelope could not be distributed at this time. Please try again.`, + variant: 'destructive', + duration: 7500, + }); + } + }; + + if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) { + return null; + } + + return ( + + {trigger} + + + + + Send Document + + + + Recipients will be able to sign the document once sent + + + {everySignerHasSignature ? ( + + +
+ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + setValue('meta.distributionMethod', value as DocumentDistributionMethod) + } + value={distributionMethod} + className="mb-2" + > + + + Email + + + None + + + + +
+ + {distributionMethod === DocumentDistributionMethod.EMAIL && ( + + +
+ {organisation.organisationClaim.flags.emailDomains && ( + ( + + + Email Sender + + + + + + + + )} + /> + )} + + ( + + + Reply To Email{' '} + (Optional) + + + + + + + + + )} + /> + + ( + + + Subject{' '} + (Optional) + + + + + + + + )} + /> + + ( + + + Message{' '} + (Optional) + + + + + + + + + + + +