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/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/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/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 ac56a9c3a..6bde92ae9 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 @@ -33,6 +33,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'; import { RecipientStepList } from './recipient-step-list'; @@ -42,6 +43,7 @@ export const EnvelopeEditorRecipientForm = () => { const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); + const cspNonce = useCspNonce(); const { toast } = useToast(); const { remaining } = useLimits(); 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 9ad04f6a2..6796228d9 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 @@ -43,6 +43,7 @@ type LocalFile = { export const EnvelopeEditorUploadPage = () => { const organisation = useCurrentOrganisation(); + const cspNonce = useCspNonce(); const { t, i18n } = useLingui(); const { maximumEnvelopeItemCount, remaining } = useLimits(); 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 ede3dfb6c..0b2dbc896 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -25,6 +25,7 @@ import { z } from 'zod'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { useCurrentTeam } from '~/providers/team'; +import { useCspNonce } from '~/utils/nonce'; export type TemplateEditFormProps = { className?: string; @@ -38,6 +39,7 @@ const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields']; export const TemplateEditForm = ({ initialTemplate, className, templateRootPath }: TemplateEditFormProps) => { 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 a798b8619..b7f1cafd4 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -23,13 +23,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 }]; @@ -74,7 +77,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { // Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all // SSR-rendered