diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index b01a28740..fb208e916 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -2,7 +2,7 @@ name: 'Setup node' inputs: node_version: required: false - default: v22.x + default: v24.x runs: using: 'composite' diff --git a/README.md b/README.md index a1ed84f30..ad4d7c5b9 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations To run Documenso locally, you will need -- Node.js (v22 or above) +- Node.js (v24 or above) - Postgres SQL Database - Docker (optional) diff --git a/apps/docs/content/docs/developers/api/teams.mdx b/apps/docs/content/docs/developers/api/teams.mdx index 99d708b41..0d869c56c 100644 --- a/apps/docs/content/docs/developers/api/teams.mdx +++ b/apps/docs/content/docs/developers/api/teams.mdx @@ -95,7 +95,7 @@ Documents created with a team token belong to that team: ```bash curl -X POST "https://app.documenso.com/api/v2/envelope/create" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: multipart/form-data" \ -F 'payload={ "type": "DOCUMENT", @@ -157,11 +157,11 @@ Retrieve all documents belonging to the team: ```bash # List all team documents curl -X GET "https://app.documenso.com/api/v2/envelope" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" # Filter by status curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" ```` @@ -191,7 +191,7 @@ Templates created with a team token are shared across the team. ```bash curl -X POST "https://app.documenso.com/api/v2/template/create" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: multipart/form-data" \ -F 'payload={ "title": "NDA Template", @@ -269,7 +269,7 @@ console.log('Created team template:', template.id); ```bash curl -X GET "https://app.documenso.com/api/v2/template" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" ```` diff --git a/apps/docs/content/docs/developers/examples/common-workflows.mdx b/apps/docs/content/docs/developers/examples/common-workflows.mdx index 5bdf32cdf..5cac47f79 100644 --- a/apps/docs/content/docs/developers/examples/common-workflows.mdx +++ b/apps/docs/content/docs/developers/examples/common-workflows.mdx @@ -51,6 +51,7 @@ async function createAndSendDocument( pdfBuffer: Buffer, filename: string, title: string, + externalId: string, recipients: Recipient[], ): Promise { const recipientPayload = recipients.map((recipient, index) => ({ @@ -89,6 +90,7 @@ async function createAndSendDocument( JSON.stringify({ type: 'DOCUMENT', title, + externalId, recipients: recipientPayload, meta: { subject: `Please sign: ${title}`, @@ -145,6 +147,7 @@ const result = await createAndSendDocument( pdfBuffer, 'contract.pdf', 'Service Agreement', + 'nda-contract-ndac214', [ { email: 'client@example.com', name: 'John Smith', role: 'SIGNER' }, { email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' }, @@ -172,6 +175,7 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \ -F 'payload={ "type": "DOCUMENT", "title": "Service Agreement", + "externalId": "nda-contract-ndac214", "recipients": [ { "email": "client@example.com", @@ -241,6 +245,8 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}' +`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents). + --- ## Workflow 2: Create Document from Template with Custom Data diff --git a/apps/docs/content/docs/self-hosting/deployment/docker.mdx b/apps/docs/content/docs/self-hosting/deployment/docker.mdx index 68508e767..d8ba9c70a 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker.mdx @@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran | Variable | Description | Default | | ------------------------------------------- | -------------------------------------------------------------- | ------------------------- | | `PORT` | Port the application listens on | `3000` | -| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — set to the volume-mount path (e.g. `/opt/documenso/cert.p12`). Only Docker Compose defaults this; plain `docker run` must set it explicitly | - | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` | @@ -136,6 +136,7 @@ docker run -d \ -e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \ -e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \ -e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \ + -e NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH="/opt/documenso/cert.p12" \ -e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \ -e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \ -e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \ @@ -154,6 +155,12 @@ A signing certificate is required for document signing. You have two options for - **Volume mount** — mount a `.p12` file from the host into the container at `/opt/documenso/cert.p12` (shown above). This is the simplest approach for small to moderate deployments. - **Base64-encoded contents** — set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` with the base64-encoded certificate string. Use this when file mounting is not available (e.g., Railway, Vercel). + + Plain `docker run` deployments must set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` explicitly. This + prevents production deployments from accidentally using the insecure example certificate. + Docker Compose sets the file path for you. + + For production deployments that require Adobe Approved Trust List recognition, consider using a [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) or another external HSM. @@ -178,6 +185,7 @@ NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000 NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/documenso +NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12 NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth NEXT_PRIVATE_SMTP_HOST=smtp.example.com @@ -203,6 +211,12 @@ docker run -d \ Documenso provides health check endpoints for monitoring: + + If a certificate is mounted but signing fails, ensure `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` + explicitly points to its path inside the container. Production does not use the development + example certificate as a fallback. + + | Endpoint | Purpose | | ------------------------- | -------------------------------------------------------------- | | `/api/health` | Checks database connectivity and certificate status | diff --git a/apps/docs/content/docs/self-hosting/deployment/manual.mdx b/apps/docs/content/docs/self-hosting/deployment/manual.mdx index d6dc4fda5..70f7da640 100644 --- a/apps/docs/content/docs/self-hosting/deployment/manual.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/manual.mdx @@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; ## Prerequisites -- Node.js 22 or later -- npm 11 or later +- Node.js 24 or later +- npm 11.17 or later - PostgreSQL 14 or later - A Linux server (for systemd service setup) diff --git a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx index c64bd081e..b75069906 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx @@ -141,8 +141,8 @@ If building from source (not using Docker images): | Requirement | Version | | ----------- | ------- | -| Node.js | 22+ | -| npm | 11+ | +| Node.js | 24+ | +| npm | 11.17+ | --- @@ -169,7 +169,7 @@ Documenso runs on: | MySQL/MariaDB | PostgreSQL-specific features required | | SQLite | Not suitable for production workloads | | MongoDB | Relational database required | -| Node.js < 22 | Modern JavaScript features required | +| Node.js < 24 | Modern JavaScript features required | --- diff --git a/apps/docs/content/docs/users/organisations/preferences/document.mdx b/apps/docs/content/docs/users/organisations/preferences/document.mdx index 1f4e82c08..d81252325 100644 --- a/apps/docs/content/docs/users/organisations/preferences/document.mdx +++ b/apps/docs/content/docs/users/organisations/preferences/document.mdx @@ -34,7 +34,7 @@ To access the preferences, navigate to either the organisation or teams settings | **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. | | **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). | | **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). | -| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. | +| **Delegate Document Ownership** | By default, documents created with a team API token are owned by the user who created the token. Enable this setting to let supported API requests assign ownership to another team member. | | **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. | Document visibility, language, and signature settings can be overridden per document. diff --git a/apps/docs/package.json b/apps/docs/package.json index 9539148d0..7834fb7ef 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -15,7 +15,7 @@ "fumadocs-ui": "16.14.3", "lucide-react": "^0.563.0", "mermaid": "^11.12.2", - "next": "16.3.0", + "next": "^16.3.3", "next-plausible": "^3.12.5", "next-themes": "^0.4.6", "react": "^19.2.4", diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index 1b7c14350..8d1d1d11b 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -12,7 +12,7 @@ "dependencies": { "@documenso/prisma": "*", "luxon": "^3.7.2", - "next": "16.3.0" + "next": "^16.3.3" }, "devDependencies": { "@types/node": "^20", diff --git a/apps/remix/Dockerfile.bun b/apps/remix/Dockerfile.bun deleted file mode 100644 index 973038e8a..000000000 --- a/apps/remix/Dockerfile.bun +++ /dev/null @@ -1,25 +0,0 @@ -FROM oven/bun:1 AS dependencies-env -COPY . /app - -FROM dependencies-env AS development-dependencies-env -COPY ./package.json bun.lockb /app/ -WORKDIR /app -RUN bun i --frozen-lockfile - -FROM dependencies-env AS production-dependencies-env -COPY ./package.json bun.lockb /app/ -WORKDIR /app -RUN bun i --production - -FROM dependencies-env AS build-env -COPY ./package.json bun.lockb /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN bun run build - -FROM dependencies-env -COPY ./package.json bun.lockb /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/apps/remix/Dockerfile.pnpm b/apps/remix/Dockerfile.pnpm deleted file mode 100644 index 57916afc2..000000000 --- a/apps/remix/Dockerfile.pnpm +++ /dev/null @@ -1,26 +0,0 @@ -FROM node:20-alpine AS dependencies-env -RUN npm i -g pnpm -COPY . /app - -FROM dependencies-env AS development-dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -WORKDIR /app -RUN pnpm i --frozen-lockfile - -FROM dependencies-env AS production-dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -WORKDIR /app -RUN pnpm i --prod --frozen-lockfile - -FROM dependencies-env AS build-env -COPY ./package.json pnpm-lock.yaml /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN pnpm build - -FROM dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["pnpm", "start"] \ No newline at end of file diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx index 7e381c82f..af03b216d 100644 --- a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -1,4 +1,7 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv'; import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { @@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; +import { Plural, Trans } from '@lingui/react/macro'; import { File as FileIcon, Upload, X } from 'lucide-react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; import { z } from 'zod'; import { useCurrentTeam } from '~/providers/team'; @@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({ type TBulkSendFormSchema = z.infer; +type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string }; + export type TemplateBulkSendDialogProps = { templateId: number; recipients: Array<{ email: string; name?: string | null }>; @@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc const team = useCurrentTeam(); + const [open, setOpen] = useState(false); + const [validationError, setValidationError] = useState(null); + const form = useForm({ resolver: zodResolver(ZBulkSendFormSchema), defaultValues: { @@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation(); + const onOpenChange = (value: boolean) => { + if (form.formState.isSubmitting) { + return; + } + + setOpen(value); + + if (!value) { + setValidationError(null); + + form.reset(); + } + }; + const onDownloadTemplate = () => { const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]); @@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc }; const onSubmit = async (values: TBulkSendFormSchema) => { + setValidationError(null); + try { const csv = await values.file.text(); - await uploadBulkSend({ + const result = await uploadBulkSend({ templateId, teamId: team?.id, csv: csv, sendImmediately: values.sendImmediately, }); + if (!result.success) { + setValidationError(result.error); + + return; + } + toast({ title: _(msg`Success`), description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`), }); + setOpen(false); form.reset(); + onSuccess?.(); } catch (err) { console.error(err); - toast({ - title: _(msg`Error`), - description: _(msg`Failed to upload CSV. Please check the file format and try again.`), - variant: 'destructive', - }); + const error = AppError.parseError(err); + + setValidationError({ type: 'UPLOAD_ERROR', code: error.code }); } }; return ( - + {trigger ?? ( diff --git a/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx b/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx index 12180ab2a..3818cfcf3 100644 --- a/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx +++ b/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx @@ -18,6 +18,8 @@ import { useCallback, useRef } from 'react'; import type { Control } from 'react-hook-form'; import { useFieldArray, useFormContext, useFormState } from 'react-hook-form'; +import { useCspNonce } from '~/utils/nonce'; + import { useConfigureDocument } from './configure-document-context'; import type { TConfigureEmbedFormSchema } from './configure-document-view.types'; @@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps { export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => { const { _ } = useLingui(); const { isTemplate } = useConfigureDocument(); + const cspNonce = useCspNonce(); const $sensorApi = useRef(null); @@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure /> { diff --git a/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx new file mode 100644 index 000000000..79767f58f --- /dev/null +++ b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx @@ -0,0 +1,158 @@ +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { Trans } from '@lingui/react/macro'; +import type React from 'react'; +import { useState } from 'react'; +import { type FieldValues, type Path, useFormContext } from 'react-hook-form'; +import { z } from 'zod'; + +/** + * Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`. + */ +export const ZTwoFactorCodeFieldSchema = z.object({ + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), +}); + +export type TTwoFactorCodeFieldSchema = z.infer; + +export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode; + +type TwoFactorMethod = 'totp' | 'backup'; + +export type TwoFactorCodeDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + isSubmitting?: boolean; + submitLabel: React.ReactNode; + + /** + * Called when the user submits the code. Typically the parent form's submit handler. + */ + onSubmit: () => void; +}; + +/** + * Collects a TOTP or backup code on top of an existing form, mirroring the + * sign in and disable 2FA dialogs. + * + * Must be rendered inside a `
` whose values include `totpCode` and `backupCode`. + */ +export const TwoFactorCodeDialog = ({ + open, + onOpenChange, + isSubmitting, + submitLabel, + onSubmit, +}: TwoFactorCodeDialogProps) => { + const form = useFormContext(); + + const [method, setMethod] = useState('totp'); + + const totpCodeName = 'totpCode' as Path; + const backupCodeName = 'backupCode' as Path; + + const onToggleMethod = () => { + form.resetField(totpCodeName); + form.resetField(backupCodeName); + + setMethod((current) => (current === 'totp' ? 'backup' : 'totp')); + }; + + const handleOpenChange = (value: boolean) => { + if (isSubmitting) { + return; + } + + if (!value) { + form.resetField(totpCodeName); + form.resetField(backupCodeName); + setMethod('totp'); + } + + onOpenChange(value); + }; + + return ( + + + + + Two-Factor Authentication + + + + {method === 'totp' ? ( + Enter the code from your authenticator app to continue. + ) : ( + Enter one of your backup codes to continue. + )} + + + +
+ {method === 'totp' && ( + ( + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + )} + + {method === 'backup' && ( + ( + + + Backup Code + + + + + + + )} + /> + )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/forms/password-setup-request-button.tsx b/apps/remix/app/components/forms/password-setup-request-button.tsx new file mode 100644 index 000000000..bac09f11d --- /dev/null +++ b/apps/remix/app/components/forms/password-setup-request-button.tsx @@ -0,0 +1,53 @@ +import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { CheckIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +/** + * Compact "send me a setup link" button that reports via toast, for settings + * cards where the surrounding layout provides the explanation. + */ +export const PasswordSetupRequestButton = () => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { user } = useSession(); + + const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({ + onSuccess: () => { + toast({ + title: _(msg`Check your email`), + description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`), + duration: 5000, + }); + }, + onError: (errorCode) => { + toast({ + title: _(msg`An error occurred`), + description: match(errorCode) + .with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`)) + .otherwise(() => _(msg`We were unable to send the email. Please try again later.`)), + variant: 'destructive', + }); + }, + }); + + if (isSuccess) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/apps/remix/app/components/forms/password-setup-request.tsx b/apps/remix/app/components/forms/password-setup-request.tsx new file mode 100644 index 000000000..35cfcaf80 --- /dev/null +++ b/apps/remix/app/components/forms/password-setup-request.tsx @@ -0,0 +1,61 @@ +import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { match } from 'ts-pattern'; + +export type PasswordSetupRequestProps = { + className?: string; +}; + +/** + * Inline "send me a setup link" control with its own sent/error states, for + * contexts like dialogs where a toast would be missed. + */ +export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => { + const { _ } = useLingui(); + const { user } = useSession(); + + const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest(); + + if (isSuccess) { + return ( + + + Check your email + + + + We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue. + + + + ); + } + + return ( +
+ {errorCode && ( + + + An error occurred + + + {match(errorCode) + .with('SIGNIN_DISABLED', () => + _(msg`Password sign in is disabled for this instance. Please contact support.`), + ) + .otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))} + + + )} + + +
+ ); +}; diff --git a/apps/remix/app/components/forms/password.tsx b/apps/remix/app/components/forms/password.tsx index d04f84131..65ba8c8c0 100644 --- a/apps/remix/app/components/forms/password.tsx +++ b/apps/remix/app/components/forms/password.tsx @@ -1,6 +1,6 @@ import { authClient } from '@documenso/auth/client'; import type { SessionUser } from '@documenso/auth/server/lib/session/session'; -import { AppError } from '@documenso/lib/errors/app-error'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -11,20 +11,21 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; -import { z } from 'zod'; +import type { z } from 'zod'; -export const ZPasswordFormSchema = z - .object({ - currentPassword: ZCurrentPasswordSchema, - password: ZPasswordSchema, - repeatedPassword: ZPasswordSchema, - }) - .refine((data) => data.password === data.repeatedPassword, { - message: 'Passwords do not match', - path: ['repeatedPassword'], - }); +import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog'; + +export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({ + currentPassword: ZCurrentPasswordSchema, + password: ZPasswordSchema, + repeatedPassword: ZPasswordSchema, +}).refine((data) => data.password === data.repeatedPassword, { + message: 'Passwords do not match', + path: ['repeatedPassword'], +}); export type TPasswordFormSchema = z.infer; @@ -33,29 +34,51 @@ export type PasswordFormProps = { user: SessionUser; }; -export const PasswordForm = ({ className }: PasswordFormProps) => { +export const PasswordForm = ({ className, user }: PasswordFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false); + const form = useForm({ values: { currentPassword: '', password: '', repeatedPassword: '', + totpCode: '', + backupCode: '', }, resolver: zodResolver(ZPasswordFormSchema), }); const isSubmitting = form.formState.isSubmitting; - const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { + const onFormSubmit = async (values: TPasswordFormSchema) => { + const { currentPassword, password, totpCode, backupCode } = values; + + // Collect the 2FA code in a dialog once the password fields are valid. + if (user.twoFactorEnabled && !hasTwoFactorCode(values)) { + if (isTwoFactorDialogOpen) { + const message = _(msg`A code is required`); + + form.setError('totpCode', { message }); + form.setError('backupCode', { message }); + } + + setIsTwoFactorDialogOpen(true); + return; + } + try { await authClient.emailPassword.updatePassword({ currentPassword, password, + totpCode: totpCode || undefined, + backupCode: backupCode || undefined, }); form.reset(); + setIsTwoFactorDialogOpen(false); toast({ title: _(msg`Password updated`), @@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { const error = AppError.parseError(err); const errorMessage = match(error.code) - .with('NO_PASSWORD', () => msg`User has no password.`) - .with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`) - .with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`) + .with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`) + .with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`) + .with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`) + .with( + AppErrorCode.INCORRECT_TWO_FACTOR_CODE, + AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS, + () => msg`The two factor code you provided is invalid. Please try again.`, + ) .otherwise( () => msg`We encountered an unknown error while attempting to update your password. Please try again later.`, ); @@ -83,7 +111,12 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { return ( - + {/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */} +
{ + + + open={isTwoFactorDialogOpen} + onOpenChange={setIsTwoFactorDialogOpen} + isSubmitting={isSubmitting} + submitLabel={Update password} + onSubmit={form.handleSubmit(onFormSubmit)} + /> ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx index 2d1b51747..1d2806fda 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx @@ -1,5 +1,7 @@ import { AppError } from '@documenso/lib/errors/app-error'; import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { UserAuthMethod } from '@documenso/lib/types/user-auth-method'; +import { trpc } from '@documenso/trpc/react'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; @@ -7,11 +9,13 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from ' import { Input } from '@documenso/ui/primitives/input'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; +import { Loader2Icon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password'; export type DocumentSigningAuthPasswordProps = { open: boolean; @@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({ }: DocumentSigningAuthPasswordProps) => { const { t } = useLingui(); - const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = - useRequiredDocumentSigningAuthContext(); + const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); + + // Fetched on demand since this is only needed once the user opts for password auth. + const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, { + enabled: !!user, + }); const form = useForm({ resolver: zodResolver(ZPasswordAuthFormSchema), @@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({ const [formErrorCode, setFormErrorCode] = useState(null); + // If the query fails we fall through to the regular password form rather than blocking. + const isPasswordSetupRequired = + !!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD); + const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => { try { setIsCurrentlyAuthenticating(true); @@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({ const error = AppError.parseError(err); setFormErrorCode(error.code); - - // Todo: Alert. } }; @@ -79,9 +89,22 @@ export const DocumentSigningAuthPassword = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); + if (user && isAuthMethodsPending) { + return ( +
+ +
+ ); + } + + if (isPasswordSetupRequired) { + return ; + } + return (
- + {/* method="post" so a pre-hydration native submit can't leak the password into the URL. */} +
{formErrorCode && ( diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx new file mode 100644 index 000000000..b827c1cbf --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx @@ -0,0 +1,63 @@ +import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { Trans } from '@lingui/react/macro'; + +import { PasswordSetupRequest } from '~/components/forms/password-setup-request'; + +export type DocumentSigningAuthSetPasswordProps = { + onOpenChange: (value: boolean) => void; +}; + +/** + * Shown in place of the password reauth form when the signed in user has no + * password (e.g. they signed up via OAuth or a passkey). + * + * Password based action auth is meant to prove more than possession of a session, + * so rather than letting the session set a password inline we send the user the + * verified reset link and ask them to come back. + */ +export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => { + const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email'); + + return ( +
+ {isEmailPasswordSigninEnabled ? ( + <> + + + No password set + + + + Signing this field requires a password, but your account does not have one. We can email you a link to + set one. Once done, sign in again and return to this document to continue. + + + + + + + ) : ( + + + Password authentication unavailable + + + + Your account does not have a password and password sign in is disabled for this instance. Please contact + the document sender to use a different authentication method. + + + + )} + + + + +
+ ); +}; 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 051ee562c..06bedf4b6 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -28,6 +28,7 @@ import { useNavigate, useSearchParams } from 'react-router'; import { z } from 'zod'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { useCurrentTeam } from '~/providers/team'; +import { useCspNonce } from '~/utils/nonce'; import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; export type DocumentEditFormProps = { @@ -42,6 +43,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => { const { toast } = useToast(); const { _ } = useLingui(); + const cspNonce = useCspNonce(); const navigate = useNavigate(); @@ -473,6 +475,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath onSubmit={onAddSignersFormSubmit} onAutoSave={onAddSignersFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} + nonce={cspNonce} /> { const { t, i18n } = useLingui(); const analytics = useAnalytics(); @@ -359,6 +362,9 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR shouldOverdrawWholeArea: true, ignoreStroke: true, flipEnabled: false, + anchorStyleFunc: (anchor) => { + anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / scale); + }, boundBoxFunc: (oldBox, newBox) => { // Enforce minimum size if (newBox.width < 30 || newBox.height < 20) { diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index cff9bab64..b3173b547 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -45,6 +45,7 @@ import { isDeepEqual } from 'remeda'; import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog'; import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog'; import { useCurrentTeam } from '~/providers/team'; +import { useCspNonce } from '~/utils/nonce'; export const EnvelopeEditorRecipientForm = () => { const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } = @@ -52,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => { const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); + const cspNonce = useCspNonce(); const { t } = useLingui(); const { toast } = useToast(); @@ -795,6 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
{ diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index 73493c385..60d970caf 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -26,6 +26,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog'; +import { useCspNonce } from '~/utils/nonce'; import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form'; @@ -42,6 +43,7 @@ type LocalFile = { export const EnvelopeEditorUploadPage = () => { const organisation = useCurrentOrganisation(); + const cspNonce = useCspNonce(); const { t, i18n } = useLingui(); const { maximumEnvelopeItemCount, remaining } = useLimits(); @@ -494,7 +496,7 @@ export const EnvelopeEditorUploadPage = () => { {/* Uploaded Files List */}
- + {(provided) => (
{ const { _ } = useLingui(); const { toast } = useToast(); + const cspNonce = useCspNonce(); const navigate = useNavigate(); const team = useCurrentTeam(); @@ -339,6 +341,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath onSubmit={onAddTemplatePlaceholderFormSubmit} onAutoSave={onAddTemplatePlaceholderFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} + nonce={cspNonce} /> { let shellRendered = false; diff --git a/apps/remix/app/middleware/admin.ts b/apps/remix/app/middleware/admin.ts new file mode 100644 index 000000000..0eef2c90d --- /dev/null +++ b/apps/remix/app/middleware/admin.ts @@ -0,0 +1,13 @@ +import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { isAdmin } from '@documenso/lib/utils/is-admin'; +import { type MiddlewareFunction, redirect } from 'react-router'; + +export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => { + const { user } = await getOptionalSession(request); + + if (!user || !isAdmin(user)) { + throw redirect('/'); + } + + return next(); +}; diff --git a/apps/remix/app/middleware/nonce.ts b/apps/remix/app/middleware/nonce.ts new file mode 100644 index 000000000..916d5232d --- /dev/null +++ b/apps/remix/app/middleware/nonce.ts @@ -0,0 +1,8 @@ +import type { MiddlewareFunction } from 'react-router'; + +import { getRequestNonce } from '../../server/load-context'; +import { nonceContext } from '../utils/nonce'; + +export const nonceMiddleware: MiddlewareFunction = ({ context }) => { + context.set(nonceContext, getRequestNonce()); +}; diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index d32745c54..9441ac2d1 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -25,13 +25,16 @@ import { useMatches, } from 'react-router'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; +import { nonceMiddleware } from '~/middleware/nonce'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; import { GenericErrorLayout } from './components/general/generic-error-layout'; import { langCookie } from './storage/lang-cookie.server'; import { themeSessionResolver } from './storage/theme-session.server'; import { appMetaTags } from './utils/meta'; -import { nonce } from './utils/nonce'; +import { nonce, nonceContext } from './utils/nonce'; + +export const middleware = [nonceMiddleware]; export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }]; @@ -86,7 +89,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { // Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all // SSR-rendered