Merge branch 'main' into feat/add-recipient-groups

This commit is contained in:
David Nguyen
2026-09-10 21:22:46 +10:00
60 changed files with 1189 additions and 705 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Setup node'
inputs:
node_version:
required: false
default: v22.x
default: v24.x
runs:
using: 'composite'
+1 -1
View File
@@ -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)
@@ -95,7 +95,7 @@ Documents created with a team token belong to that team:
<Tab value="curl">
```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"
````
</Tab>
@@ -191,7 +191,7 @@ Templates created with a team token are shared across the team.
<Tab value="curl">
```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);
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -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).
<Callout type="warn">
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.
</Callout>
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.
<Callout type="warn">
@@ -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:
<Callout type="info">
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.
</Callout>
| Endpoint | Purpose |
| ------------------------- | -------------------------------------------------------------- |
| `/api/health` | Checks database connectivity and certificate status |
@@ -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)
@@ -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 |
---
@@ -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.
-25
View File
@@ -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"]
-26
View File
@@ -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"]
@@ -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<typeof ZBulkSendFormSchema>;
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<TBulkSendValidationError | null>(null);
const form = useForm<TBulkSendFormSchema>({
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 (
<Dialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => onChange(null)}
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -218,6 +257,72 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
)}
/>
{validationError !== null && (
<Alert variant="destructive">
<AlertDescription className="max-h-32 overflow-y-auto">
{match(validationError)
.with({ type: 'PARSE_ERROR' }, () => (
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
))
.with({ type: 'EMPTY' }, () => (
<Trans>
The CSV does not contain any rows. Please add at least one row of recipient details.
</Trans>
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
<Plural value={rowCount} one="The CSV contains # row." other="The CSV contains # rows." />{' '}
<Plural
value={maxRows}
one="A maximum of # row is allowed per upload."
other="A maximum of # rows is allowed per upload."
/>
</Trans>
))
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
<>
<Trans>
The CSV is missing the following required columns. Please download the template CSV for the
correct format.
</Trans>
<ul className="mt-1 list-inside list-disc">
{missingColumns.map((column) => (
<li key={column} className="font-mono">
{column}
</li>
))}
</ul>
</>
))
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
<>
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
<ul className="mt-1 list-inside list-disc">
{rowErrors.map((rowError, index) => (
<li key={index}>
<Trans>
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
email or empty
</Trans>
</li>
))}
</ul>
</>
))
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
code === AppErrorCode.LIMIT_EXCEEDED ? (
<Trans>The CSV exceeds the maximum file size.</Trans>
) : (
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
),
)
.exhaustive()}
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="sendImmediately"
@@ -240,7 +345,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Trans>Cancel</Trans>
</Button>
@@ -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<SensorAPI | null>(null);
@@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
/>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -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}
/>
<AddFieldsFormPartial
@@ -38,6 +38,9 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
/** How far past a resize handle you can still grab it, in screen pixels. */
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
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) {
@@ -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();
@@ -43,6 +43,7 @@ type LocalFile = {
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const cspNonce = useCspNonce();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
@@ -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}
/>
<AddTemplateFieldsFormPartial
+4 -3
View File
@@ -7,10 +7,11 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import type { EntryContext, RouterContextProvider } from 'react-router';
import { ServerRouter } from 'react-router';
import { langCookie } from './storage/lang-cookie.server';
import { nonceContext } from './utils/nonce';
export const streamTimeout = 5_000;
@@ -19,7 +20,7 @@ export default async function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
loadContext: RouterContextProvider,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -33,7 +34,7 @@ export default async function handleRequest(
// scripts it injects (route manifest, hydration data, module preloads).
// The same nonce is also exposed to the React tree via the root loader so
// our own inline scripts/styles can carry it.
const nonce = loadContext.nonce || undefined;
const nonce = loadContext.get(nonceContext) || undefined;
return new Promise((resolve, reject) => {
let shellRendered = false;
+13
View File
@@ -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();
};
+8
View File
@@ -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());
};
+5 -2
View File
@@ -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 <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
nonce: context.nonce,
nonce: context.get(nonceContext),
session: session.isAuthenticated
? {
user: session.user,
@@ -1,4 +1,4 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,16 +20,18 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import { adminMiddleware } from '~/middleware/admin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Admin`);
}
export const middleware = [adminMiddleware];
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const { user } = await getOptionalSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
@@ -1,8 +1,8 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
export const loader = () => {
export const loader = async () => {
try {
const certStatus = getCertificateStatus();
const certStatus = await getCertificateStatus();
return Response.json({
isAvailable: certStatus.isAvailable,
+1 -1
View File
@@ -22,7 +22,7 @@ export const loader = async () => {
}
try {
const certStatus = getCertificateStatus();
const certStatus = await getCertificateStatus();
if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' };
+7 -1
View File
@@ -1,4 +1,10 @@
import { useRouteLoaderData } from 'react-router';
import { createContext, useRouteLoaderData } from 'react-router';
/**
* Per-request CSP nonce. Set by the root route middleware, read with
* `context.get(nonceContext)` in loaders/actions and `entry.server`.
*/
export const nonceContext = createContext<string>('');
/**
* Returns the supplied CSP nonce only when rendering on the server.
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.17.0"
"version": "2.18.0"
}
+3
View File
@@ -8,4 +8,7 @@ export default {
// kept without a trailing slash so they match exactly, and so the bare
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
future: {
v8_middleware: true,
},
} satisfies Config;
+2 -3
View File
@@ -24,12 +24,11 @@ export const appContext = async (c: Context, next: Next) => {
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return next();
return await next();
}
// Add context to any pages you want here.
return next();
return await next();
};
const setAppContext = (c: Context, context: AppContext) => {
+6 -23
View File
@@ -1,33 +1,16 @@
import { getContext } from 'hono/context-storage';
import type { AppLoadContext } from 'react-router';
import { RouterContextProvider } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
* `hono/context-storage` (enabled in `server/router.ts`).
*/
declare module 'react-router' {
interface AppLoadContext {
/**
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
* so it can be threaded into `<ServerRouter nonce>` and root loader
* data, which then feeds `<Scripts>`, `<Links>`, etc.
*/
nonce: string;
}
}
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
/**
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
* production (`hono-react-router-adapter/node`).
*
* The Hono context isn't passed directly by the adapter, so we read it via
* `hono/context-storage`, which is enabled in `server/router.ts`.
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
*/
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
+2 -1
View File
@@ -5,6 +5,7 @@ import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import type { AppLoadContext } from 'react-router';
import tailwindcss from 'tailwindcss';
import { defineConfig, normalizePath } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
@@ -53,7 +54,7 @@ export default defineConfig({
entry: 'server/router.ts',
getLoadContext: async () => {
const { getLoadContext } = await import('./server/load-context');
return getLoadContext();
return getLoadContext() as unknown as AppLoadContext;
},
exclude: [
// Spread the defaults but replace the /.css$/ rule so that Bull
+28 -12
View File
@@ -1,7 +1,7 @@
###########################
# BASE CONTAINER #
###########################
FROM node:22-alpine3.22 AS base
FROM node:24-alpine3.23 AS base
RUN apk add --no-cache openssl
RUN apk add --no-cache font-freefont
@@ -19,7 +19,10 @@ WORKDIR /app
COPY . .
RUN npm install -g "turbo@^2.10.0"
# Install the exact turbo version resolved in the lockfile, without installing
# the rest of the dependency tree (prune must run before any npm ci).
RUN TURBO_VERSION="$(jq -r '.packages["node_modules/turbo"].version' package-lock.json)" \
&& npm install -g "turbo@${TURBO_VERSION}"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@@ -39,9 +42,9 @@ RUN apk add --no-cache make cmake g++ openssl bash
WORKDIR /app
# Disable husky from installing hooks
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
ENV NEXT_TELEMETRY_DISABLED=1
# Encryption keys
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
@@ -76,6 +79,7 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/package-lock.json ./package-lock.json
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
COPY --from=builder /app/patches ./patches
RUN npm ci
@@ -84,17 +88,17 @@ COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
RUN npm install -g "turbo@^2.10.0"
ENV NODE_OPTIONS="--max-old-space-size=8192"
RUN turbo run build --filter=@documenso/remix...
RUN npx turbo run build --filter=@documenso/remix...
###########################
# RUNNER CONTAINER #
###########################
FROM base AS runner
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
# Telemetry credentials (baked into image at build time, can be disabled at runtime)
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
@@ -114,8 +118,19 @@ WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
# Copy the tailwind config files across
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --only=production
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
# Strip build-time residue that ships as production dependencies but is never
# executed at runtime.
RUN rm -rf \
node_modules/react-email/dist/cli \
node_modules/esbuild \
node_modules/@esbuild \
node_modules/.bin/esbuild \
node_modules/.bin/email
# Automatically leverage output traces to reduce image size
# https://nodejs.org/docs/advanced-features/output-file-tracing
@@ -126,8 +141,9 @@ COPY --from=installer --chown=nodejs:nodejs /app/apps/remix/public ./apps/remix/
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/schema.prisma ./packages/prisma/schema.prisma
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./packages/prisma/migrations
# Generate the prisma client again
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
# Generate the prisma client again, this time only targeting the client generator
RUN npx prisma generate --schema ./packages/prisma/schema.prisma --generator client \
&& npm cache clean --force
# Get the start script from docker/
+35 -14
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.17.0",
"version": "2.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.17.0",
"version": "2.18.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -102,8 +102,8 @@
"zod-prisma-types": "3.3.5"
},
"engines": {
"node": ">=22.0.0",
"npm": ">=11.11.0"
"node": ">=24.0.0",
"npm": ">=11.17.0"
}
},
"apps/docs": {
@@ -416,7 +416,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.17.0",
"version": "2.18.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -3153,6 +3153,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
"integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "10.5.0",
@@ -3164,6 +3165,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz",
"integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "10.5.0",
@@ -3174,12 +3176,14 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz",
"integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz",
"integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@commitlint/cli": {
@@ -4802,9 +4806,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.1.tgz",
"integrity": "sha512-DWGxWw1na8oFPixz+b6kOgu4tMD8xJLDgyPGVUNqIswO5POHAxsqmTvUvDW0IrwHP7kFRHBV3I3T/KhTABf9rA==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
@@ -5723,6 +5727,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz",
"integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chevrotain": "^10.5.0",
@@ -10295,12 +10300,14 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.19.3.tgz",
"integrity": "sha512-+D6v7RIF21bJrZAXiiIdW0qR73TleYVCqTDozokdEHdGeqN997O7jJW5z+43s5CVwWTZJIwFGpoeXxDDyXDVxA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-6.19.3.tgz",
"integrity": "sha512-UUxn6VLfKKVqm5n9vexOQgFJ9TCBIxupb7F5FzLQ6iM2VV0WZxhgzbQVqBDsGY+VmQLmqaSoKRusEfy3O5KZpA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3"
@@ -10349,12 +10356,14 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.19.3.tgz",
"integrity": "sha512-rzHJaIZEnEDUWNjjFAimsyMCS9osPxwbwiAbymwFprSHJSAglMxMDVU+xbQUCLeDsjUF09W5ag/aBPL2t3YqgA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/generator-helper": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.19.3.tgz",
"integrity": "sha512-13S8ngSWVKcyuRFqaK/JGMyovjQC5sOKJ9A+ufqePQWeIUbvKO2QY2CmhcV4JAa2vuN22//4Sip5mORVKwS0sw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3",
@@ -10375,6 +10384,7 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-6.19.3.tgz",
"integrity": "sha512-1V5ba+BNtGFFlgoA8ecyAbjmoMWzIrEuebmLbJpeS7qbhnY6vbc1OZ6qc55lI/VAkfdALSL5vePG5KF9P8feLw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.19.3",
@@ -10405,18 +10415,21 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/prisma-schema-wasm/-/prisma-schema-wasm-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-/5JrsJQAOIWSl+9WMM/0ugd737kT9zdMNr7EFaP7ogeMxAhxQ78uaOV6JYGEocD8LC0gZx7MXmVE/CTfYdJ2iA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/schema-engine-wasm": {
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/schema-engine-wasm/-/schema-engine-wasm-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-mXtzeePkSdqpr/HAIclqzQEf+tBzLawy7NQ9AaRCfg8N/cruavpsocgbSwOOfV7JTk9JubyzKuR3tp2bNNuWbQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/schema-files-loader": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/schema-files-loader/-/schema-files-loader-6.19.3.tgz",
"integrity": "sha512-rnoL2PgopghakRZLCZwj+d7LPvjbY9mFholFWuibEKzwO7aqS16s60Hz4wxAnKwqOcS/M6pV7QLrsHBYnNR0KA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/prisma-schema-wasm": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
@@ -16274,6 +16287,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
"integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "10.5.0",
@@ -16625,6 +16639,7 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"dev": true,
"license": "MIT"
},
"node_modules/collapse-white-space": {
@@ -19871,6 +19886,7 @@
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
@@ -22537,6 +22553,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -26383,6 +26400,7 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.6.2.tgz",
"integrity": "sha512-WX/oENQ0S74r/Wgd2uuHT5i3KbnwLFCP2Fq5ISzrXkus/htOC4uCaQPYuGP2m/wSeKZZCw1RxptTlD+ib7Ht/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@prisma/generator-helper": "^6.16.1",
@@ -26408,6 +26426,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-2.3.0.tgz",
"integrity": "sha512-/+VF2t2DlY+t/27hhyH5ULWxBAAsMBPgOo8Ltq7uXpBz49lUf4XuO1ff9HV0l7J3aDojG+YyczEvY0og9uRqsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mrleebo/prisma-ast": "^0.13.1",
@@ -27608,6 +27627,7 @@
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
"integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==",
"dev": true,
"license": "MIT"
},
"node_modules/rehype-raw": {
@@ -31654,6 +31674,7 @@
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/zod-prisma-types/-/zod-prisma-types-3.3.5.tgz",
"integrity": "sha512-PDuRRCdX1d6ch6UclNvrqM1SLb2qvuaQPvZT/OHRrcxOQqQUnCz009aBc1uxuwMho1+d42TTDCQr+HPXhTSJtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@prisma/dmmf": "^6.16.3",
@@ -32410,17 +32431,17 @@
"nanoid": "^5.1.6",
"prisma": "^6.19.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^2.3.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76",
"zod-prisma-types": "3.3.5"
"zod": "^3.25.76"
},
"devDependencies": {
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^2.3.0",
"tsx": "^4.23.1",
"typescript": "5.6.2"
"typescript": "5.6.2",
"zod-prisma-types": "3.3.5"
}
},
"packages/prisma/node_modules/@esbuild/aix-ppc64": {
+5 -5
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.17.0",
"version": "2.18.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -41,10 +41,10 @@
"translate:extract": "lingui extract --clean",
"translate:compile": "lingui compile"
},
"packageManager": "npm@11.11.0",
"packageManager": "npm@11.19.1",
"engines": {
"npm": ">=11.11.0",
"node": ">=22.0.0"
"npm": ">=11.17.0",
"node": ">=24.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
@@ -91,7 +91,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -10,7 +10,7 @@ test.describe.configure({ mode: 'parallel' });
const nanoid = customAlphabet('1234567890abcdef', 10);
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organizations…';
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
@@ -411,7 +411,7 @@ test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', asyn
});
// They should NOT see the organisation settings heading for this org.
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
await expect(page.getByText('Organization Settings')).not.toBeVisible();
});
// ─── Access control: UI ──────────────────────────────────────────────────────
@@ -50,9 +50,9 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
});
// Verify we're on the admin organisation page
await expect(page.getByText(`Manage organisation`)).toBeVisible();
await expect(page.getByText(`Manage organization`)).toBeVisible();
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
await expect(page.getByLabel('Organization Name')).toHaveValue(organisation.name);
// Check that the organisation members table shows the correct roles
const ownerRow = page.getByRole('row', { name: ownerUser.email });
@@ -356,7 +356,7 @@ test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
});
// Should show 404 error
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Organization not found' })).toBeVisible({
timeout: 10_000,
});
});
@@ -525,8 +525,8 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
// Should be able to access organisation settings
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeEnabled();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
@@ -408,7 +408,7 @@ test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) =
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
await expectToastTextToBeVisible(page, 'Documents cancelled');
await expectToastTextToBeVisible(page, 'Documents canceled');
// Selection clears after a successful cancel.
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
@@ -455,7 +455,7 @@ test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// Only one of the three was pending, so this is a partial result.
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
await expectToastTextToBeVisible(page, 'Documents partially canceled');
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pending.id },
@@ -505,7 +505,7 @@ test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', as
// The server rejects the cancellation for a document the MEMBER does not own,
// so it reports zero cancelled (a partial result with the document in failedIds).
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
await expectToastTextToBeVisible(page, 'Documents partially canceled');
// The document remains pending.
const envelope = await prisma.envelope.findFirstOrThrow({
@@ -41,7 +41,7 @@ const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: s
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
if (reason) {
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
await page.getByPlaceholder('Add an optional reason for canceling this document').fill(reason);
}
await page.getByRole('button', { name: 'Cancel document' }).click();
@@ -58,13 +58,13 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
// The cancelled document is still listed.
await selectDocumentStatusFilter(page, 'Cancelled');
await selectDocumentStatusFilter(page, 'Canceled');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
@@ -95,7 +95,7 @@ test('[DOCUMENTS]: cancelling a pending document retains it for recipients', asy
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
await apiSignout({ page });
@@ -125,10 +125,10 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await selectDocumentStatusFilter(page, 'Cancelled');
await selectDocumentStatusFilter(page, 'Canceled');
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
@@ -328,7 +328,7 @@ test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
@@ -29,11 +29,11 @@ test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
await page.waitForURL(`/settings/organisations`);
await expectTextToBeVisible(page, 'No results found');
await page.getByRole('button', { name: 'Create organisation' }).click();
await page.getByRole('button', { name: 'Create organization' }).click();
await page.getByLabel('Organisation Name*').fill('test');
await page.getByLabel('Organization Name*').fill('test');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Your organisation has been created').first()).toBeVisible();
await expect(page.getByText('Your organization has been created').first()).toBeVisible();
await page.reload();
await page.getByRole('row').filter({ hasText: 'test' }).getByRole('link').nth(1).click();
@@ -53,12 +53,12 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
const updatedOrganisationId = `organisation-${Date.now()}`;
// Update team.
await page.getByLabel('Organisation Name*').click();
await page.getByLabel('Organisation Name*').clear();
await page.getByLabel('Organisation Name*').fill(updatedOrganisationId);
await page.getByLabel('Organisation URL*').click();
await page.getByLabel('Organisation URL*').clear();
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
await page.getByLabel('Organization Name*').click();
await page.getByLabel('Organization Name*').clear();
await page.getByLabel('Organization Name*').fill(updatedOrganisationId);
await page.getByLabel('Organization URL*').click();
await page.getByLabel('Organization URL*').clear();
await page.getByLabel('Organization URL*').fill(updatedOrganisationId);
await page.getByRole('button', { name: 'Save changes' }).click();
@@ -277,8 +277,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Create a custom group A with 3 members "ORGANISATION ADMIN" to check that they get the correct roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
await page.getByRole('option', { name: 'Organization Admin' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member1' }).click();
await page.getByRole('option', { name: 'Member2' }).click();
@@ -291,16 +291,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm org roles have been applied to these members.
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail3)).toBeVisible();
// Test updating the group.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('link', { name: 'Manage' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
await page.getByRole('option', { name: 'Organisation Member' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Admin' }).click();
await page.getByRole('option', { name: 'Organization Member' }).click();
// Remove Member3 by clicking the X on its chip in the multiselect.
await page
.getByTestId('group-members-picker')
@@ -327,16 +327,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm admins still get admin roles.
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail3)).toBeVisible();
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
await page.getByRole('option', { name: 'Organization Admin' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member4' }).click();
await page.getByRole('option', { name: 'Member5' }).click();
@@ -537,6 +537,6 @@ test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
await expect(page.getByText('You have successfully left this organization').first()).toBeVisible();
await expect(page.getByText('No results found').first()).toBeVisible();
});
@@ -7,8 +7,8 @@ import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
const BANNER_EXCEEDED_TEXT = 'Your organization has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organization is approaching a fair use limit';
type SeedQuotaStateOptions = {
organisationId: string;
@@ -162,7 +162,7 @@ test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page })
});
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
@@ -176,7 +176,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('option', { name: 'Inherit from organization' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
@@ -254,9 +254,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page
.getByTestId('inheritable-email-document-settings')
.getByRole('combobox')
.filter({ hasText: 'Inherit from organisation' })
.filter({ hasText: 'Inherit from organization' })
.click();
await page.getByRole('option', { name: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Override organization settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
@@ -308,8 +308,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('combobox').filter({ hasText: 'Override organization settings' }).click();
await page.getByRole('option', { name: 'Inherit from organization' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
@@ -248,7 +248,7 @@ test.describe('Unified Settings', () => {
// Wait for the organisation page to actually render — asserting straight after
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organization Settings');
await expect(trigger).toContainText(selected.name);
@@ -283,7 +283,7 @@ test.describe('Unified Settings', () => {
// Selecting the inherit option stages the field back to inherited.
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: /inherit from organisation/i }).click();
await page.getByRole('option', { name: /inherit from organization/i }).click();
await expect(langStatus).toHaveText(/inherited/i);
});
@@ -450,7 +450,7 @@ test.describe('Unified Settings', () => {
// An empty group would just look broken, so it explains itself directly under the switcher.
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText(/permission to manage this organisation/i);
await expect(emptyState).toContainText(/permission to manage this organization/i);
// Team and account pages remain navigable.
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
@@ -0,0 +1,80 @@
import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
test('[TEMPLATES]: bulk send via CSV from the table action dropdown', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedTemplate({
title: 'Bulk send template',
userId: owner.id,
teamId: team.id,
});
const uniqueRecipientEmail = `bulk-send-${Date.now()}@documenso.com`;
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates`,
});
const actionBtn = page
.getByRole('row', { name: 'Bulk send template' })
.getByRole('cell', { name: 'Use Template' })
.getByRole('button')
.nth(1);
await openDropdownMenu(page, actionBtn);
await page.getByText('Bulk Send via CSV').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Bulk Send Template via CSV' });
await expect(dialog).toBeVisible();
// Opening the native file picker blurs the window. Radix dropdown menus close themselves on
// window blur, which used to unmount this dialog when it was nested inside the menu content,
// silently discarding the user's file selection.
await page.evaluate(() => window.dispatchEvent(new Event('blur')));
await expect(dialog).toBeVisible();
const csv = ['recipient_1_email,recipient_1_name', `${uniqueRecipientEmail},Bulk Recipient`].join('\n');
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bulk-send.csv',
mimeType: 'text/csv',
buffer: Buffer.from(csv),
});
await expect(dialog.getByText('bulk-send.csv')).toBeVisible();
await dialog.getByRole('button', { name: 'Upload and Process' }).click();
await expect(page.getByText('Your bulk send has been initiated').first()).toBeVisible();
// The bulk send runs as a background job, so poll for the created document.
await expect
.poll(
async () =>
await prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
teamId: team.id,
recipients: {
some: {
email: uniqueRecipientEmail,
},
},
},
}),
{ timeout: 30_000 },
)
.toBe(1);
});
+2
View File
@@ -93,6 +93,8 @@ export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYW
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
export const NEXT_PRIVATE_SIGNING_TRANSPORT = () => env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
/**
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
*
@@ -2,12 +2,10 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { zEmail } from '@documenso/lib/utils/zod';
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/macro';
import { parse } from 'csv-parse/sync';
import { createElement } from 'react';
import { z } from 'zod';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -17,14 +15,6 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefinition; io: JobRunIO }) => {
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
@@ -41,25 +31,21 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
throw new Error('Template not found');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
if (rows.length > 100) {
throw new Error('Maximum 100 rows allowed per upload');
}
const { recipients } = template;
// Validate CSV structure
const csvHeaders = Object.keys(rows[0]);
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
// The CSV is validated upfront when the bulk send is uploaded, this acts as
// a final safeguard prior to processing.
const csvValidationResult = validateBulkSendCsv({
csvContent,
recipientCount: recipients.length,
});
for (const header of requiredHeaders) {
if (!csvHeaders.includes(header)) {
throw new Error(`Missing required column: ${header}`);
}
if (!csvValidationResult.success) {
throw new Error(`Bulk send CSV failed validation: ${JSON.stringify(csvValidationResult.error)}`);
}
const rows = csvValidationResult.data;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -79,22 +65,6 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
// Process each row
for (const [rowIndex, row] of rows.entries()) {
try {
for (const [recipientIndex] of recipients.entries()) {
const nameKey = `recipient_${recipientIndex + 1}_name`;
const emailKey = `recipient_${recipientIndex + 1}_email`;
const parsed = ZRecipientRowSchema.safeParse({
name: row[nameKey],
email: row[emailKey],
});
if (!parsed.success) {
throw new Error(
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
);
}
}
const envelope = await io.runTask(`create-document-${rowIndex}`, async () => {
return await createDocumentFromTemplate({
id: {
+23 -13
View File
@@ -1,26 +1,36 @@
import * as fs from 'node:fs';
import { X509Certificate } from 'node:crypto';
import { env } from '@documenso/lib/utils/env';
import { createLocalSigner } from '@documenso/signing/transports/local';
export const getCertificateStatus = () => {
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
import { NEXT_PRIVATE_SIGNING_TRANSPORT } from '../../constants/app';
/**
* Whether the local P12 opens with the configured passphrase and is in date.
* Skips AIA so this stays offline. gcloud-hsm and csc always report available.
*/
export const getCertificateStatus = async () => {
const transport = NEXT_PRIVATE_SIGNING_TRANSPORT();
// Cannot inspect a remote HSM or CSC provider from this process.
if (transport === 'gcloud-hsm' || transport === 'csc') {
return { isAvailable: true };
}
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
return { isAvailable: true };
// Anything else (typo, leftover `http`) would throw at seal time.
if (transport !== 'local') {
return { isAvailable: false };
}
const defaultPath = env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
try {
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
const signer = await createLocalSigner({ buildChain: false });
const stats = fs.statSync(filePath);
const certificate = new X509Certificate(Buffer.from(signer.certificate));
return { isAvailable: stats.size > 0 };
const now = new Date();
const isWithinValidityPeriod = new Date(certificate.validFrom) <= now && now <= new Date(certificate.validTo);
return { isAvailable: isWithinValidityPeriod };
} catch {
return { isAvailable: false };
}
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest';
import type { TBulkSendCsvError, TValidateBulkSendCsvResult } from './validate-bulk-send-csv';
import { validateBulkSendCsv } from './validate-bulk-send-csv';
const buildCsv = (headers: string[], rows: string[][]) =>
[headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
const expectFailure = (result: TValidateBulkSendCsvResult): TBulkSendCsvError => {
if (result.success) {
throw new Error('Expected validation to fail, but it passed');
}
return result.error;
};
describe('validateBulkSendCsv', () => {
describe('valid CSVs', () => {
it('returns the parsed rows for a valid CSV', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_1_name'],
[
['alice@example.com', 'Alice'],
['bob@example.com', 'Bob'],
],
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result).toEqual({
success: true,
data: [
{ recipient_1_email: 'alice@example.com', recipient_1_name: 'Alice' },
{ recipient_1_email: 'bob@example.com', recipient_1_name: 'Bob' },
],
});
});
it('allows an empty string email so template defaults can be used', () => {
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], [['', 'Alice']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('allows the optional name column to be omitted entirely', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('allows unknown extra columns', () => {
const csvContent = buildCsv(['recipient_1_email', 'unrelated_column'], [['alice@example.com', 'anything']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('validates columns for every configured recipient', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_2_email'],
[['alice@example.com', 'bob@example.com']],
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 2 });
expect(result.success).toBe(true);
});
it('allows exactly the maximum number of rows', () => {
const csvContent = buildCsv(
['recipient_1_email'],
Array.from({ length: 100 }, (_, index) => [`user${index}@example.com`]),
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
});
describe('PARSE_ERROR', () => {
it('rejects a CSV that cannot be parsed', () => {
const csvContent = 'recipient_1_email\n"unclosed quote';
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'PARSE_ERROR' });
});
it('rejects a CSV with inconsistent column counts', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_1_name'],
[['alice@example.com', 'Alice', 'unexpected-extra-value']],
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'PARSE_ERROR' });
});
});
describe('EMPTY', () => {
it('rejects an empty file', () => {
const error = expectFailure(validateBulkSendCsv({ csvContent: '', recipientCount: 1 }));
expect(error).toEqual({ type: 'EMPTY' });
});
it('rejects a CSV containing only a header row', () => {
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], []);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'EMPTY' });
});
});
describe('ROW_LIMIT_EXCEEDED', () => {
it('rejects a CSV exceeding the default limit of 100 rows', () => {
const csvContent = buildCsv(
['recipient_1_email'],
Array.from({ length: 101 }, (_, index) => [`user${index}@example.com`]),
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 101, maxRows: 100 });
});
it('respects a custom maxRows option', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['bob@example.com']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1, maxRows: 1 }));
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 2, maxRows: 1 });
});
});
describe('MISSING_COLUMNS', () => {
it('rejects a CSV missing a required email column', () => {
const csvContent = buildCsv(['recipient_1_name'], [['Alice']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'MISSING_COLUMNS', missingColumns: ['recipient_1_email'] });
});
it('reports every missing column', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 3 }));
expect(error).toEqual({
type: 'MISSING_COLUMNS',
missingColumns: ['recipient_2_email', 'recipient_3_email'],
});
});
});
describe('INVALID_RECIPIENTS', () => {
it('rejects a CSV containing an invalid email', () => {
const csvContent = buildCsv(['recipient_1_email'], [['not-an-email']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [{ row: 1, column: 'recipient_1_email' }],
});
});
it('references the offending row and column', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['not-an-email']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [{ row: 2, column: 'recipient_1_email' }],
});
});
it('aggregates errors across multiple rows and recipients', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_2_email'],
[
['not-an-email', 'bob@example.com'],
['alice@example.com', 'also-not-an-email'],
],
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 2 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [
{ row: 1, column: 'recipient_1_email' },
{ row: 2, column: 'recipient_2_email' },
],
});
});
});
});
@@ -0,0 +1,117 @@
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
import { zEmail } from '../../utils/zod';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export type TBulkSendCsvRow = Record<string, string | undefined>;
export type TBulkSendCsvRowError = {
/**
* The 1-indexed row number the error occurred on, excluding the header row.
*/
row: number;
/**
* The column the error occurred in, such as `recipient_1_email`.
*/
column: string;
message: string;
};
export type TBulkSendCsvError =
| { type: 'PARSE_ERROR' }
| { type: 'EMPTY' }
| { type: 'ROW_LIMIT_EXCEEDED'; rowCount: number; maxRows: number }
| { type: 'MISSING_COLUMNS'; missingColumns: string[] }
| { type: 'INVALID_RECIPIENTS'; rowErrors: TBulkSendCsvRowError[] };
export type TValidateBulkSendCsvResult =
| { success: true; data: TBulkSendCsvRow[] }
| { success: false; error: TBulkSendCsvError };
export type ValidateBulkSendCsvOptions = {
csvContent: string;
/**
* The number of recipients configured on the template, used to derive the
* required `recipient_N_email` columns.
*/
recipientCount: number;
maxRows?: number;
};
/**
* Validate the CSV provided for a template bulk send.
*
* Returns a discriminated union so callers can surface structured error
* details, such as which rows contain invalid recipients.
*/
export const validateBulkSendCsv = ({
csvContent,
recipientCount,
maxRows = 100,
}: ValidateBulkSendCsvOptions): TValidateBulkSendCsvResult => {
let rows: TBulkSendCsvRow[];
try {
rows = parse(csvContent, { columns: true, skip_empty_lines: true });
} catch {
return { success: false, error: { type: 'PARSE_ERROR' } };
}
if (rows.length === 0) {
return { success: false, error: { type: 'EMPTY' } };
}
if (rows.length > maxRows) {
return { success: false, error: { type: 'ROW_LIMIT_EXCEEDED', rowCount: rows.length, maxRows } };
}
const csvHeaders = Object.keys(rows[0]);
const requiredHeaders = Array.from({ length: recipientCount }, (_, index) => `recipient_${index + 1}_email`);
const missingColumns = requiredHeaders.filter((header) => !csvHeaders.includes(header));
if (missingColumns.length > 0) {
return { success: false, error: { type: 'MISSING_COLUMNS', missingColumns } };
}
const rowErrors: TBulkSendCsvRowError[] = [];
for (const [rowIndex, row] of rows.entries()) {
for (let recipientIndex = 0; recipientIndex < recipientCount; recipientIndex += 1) {
const nameKey = `recipient_${recipientIndex + 1}_name`;
const emailKey = `recipient_${recipientIndex + 1}_email`;
const parsed = ZRecipientRowSchema.safeParse({
name: row[nameKey],
email: row[emailKey],
});
if (!parsed.success) {
rowErrors.push({
row: rowIndex + 1,
column: emailKey,
message: parsed.error.issues?.[0]?.message ?? 'Invalid value',
});
}
}
}
if (rowErrors.length > 0) {
return { success: false, error: { type: 'INVALID_RECIPIENTS', rowErrors } };
}
return { success: true, data: rows };
};
@@ -35,33 +35,6 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
},
});
// Todo: (RR7) Migrate to use this after RR7.
// Note: If we actually ever proceed with this, there are multiple
// locations where we will need to update this.
// const user = await prisma.$transaction(async (tx) => {
// const user = await tx.user.create({
// data: {
// name,
// email: email.toLowerCase(),
// password: hashedPassword, // Todo: (RR7) Drop password.
// signature,
// },
// });
// await tx.account.create({
// data: {
// userId: user.id,
// type: 'emailPassword', // Todo: (RR7)
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
// providerAccountId: user.id.toString(),
// password: hashedPassword,
// },
// });
// return user;
// });
// Not used at the moment, uncomment if required.
await onCreateUserHook(user).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
@@ -81,13 +81,20 @@ describe('isPrivateUrl', () => {
expect(isPrivateUrl('http://[fd12::1]')).toBe(true);
});
it('should not catch IPv4-mapped IPv6 in URL form (URL parser normalizes to hex)', () => {
// new URL() normalizes "::ffff:127.0.0.1" to "::ffff:7f00:1" which none
// of the checks handle. This is fine because dns.lookup never returns
// IPv4-mapped addresses — it returns plain IPv4 (family: 4) instead.
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(false);
it('should detect private IPv4-mapped IPv6 addresses (URL parser normalizes to hex)', () => {
// new URL() normalizes "::ffff:127.0.0.1" to the hex form "::ffff:7f00:1",
// so the embedded IPv4 must be decoded and re-checked. Otherwise a literal
// host such as http://[::ffff:127.0.0.1] bypasses every dotted-decimal
// check above (SSRF, see #2901).
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:192.168.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:169.254.169.254]')).toBe(true);
});
it('should still allow public IPv4-mapped IPv6 addresses', () => {
expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:1.1.1.1]')).toBe(false);
});
});
@@ -69,11 +69,25 @@ export const isPrivateUrl = (url: string): boolean => {
}
}
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
// IPv4-mapped IPv6, dotted form (e.g. ::ffff:127.0.0.1)
const v4MappedDotted = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (v4Mapped) {
return isPrivateUrl(`http://${v4Mapped[1]}`);
if (v4MappedDotted) {
return isPrivateUrl(`http://${v4MappedDotted[1]}`);
}
// IPv4-mapped IPv6, hex form (e.g. ::ffff:7f00:1). `new URL()` normalizes the
// dotted form above to this, so it must be decoded to the embedded IPv4 as
// well - otherwise a literal host such as `http://[::ffff:127.0.0.1]` slips
// through every dotted-decimal check above (SSRF, see #2901).
const v4MappedHex = normalizedHost.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (v4MappedHex) {
const high = parseInt(v4MappedHex[1], 16);
const low = parseInt(v4MappedHex[2], 16);
const ipv4 = [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.');
return isPrivateUrl(`http://${ipv4}`);
}
return false;
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -26,16 +26,16 @@
"nanoid": "^5.1.6",
"prisma": "^6.19.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^2.3.0",
"prisma-json-types-generator": "^3.6.2",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76",
"zod-prisma-types": "3.3.5"
"zod": "^3.25.76"
},
"devDependencies": {
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"prisma-kysely": "^2.3.0",
"prisma-json-types-generator": "^3.6.2",
"tsx": "^4.23.1",
"typescript": "5.6.2"
"typescript": "5.6.2",
"zod-prisma-types": "3.3.5"
}
}
+7 -2
View File
@@ -1,9 +1,9 @@
import {
NEXT_PRIVATE_SIGNING_TRANSPORT,
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER,
NEXT_PUBLIC_SIGNING_CONTACT_INFO,
NEXT_PUBLIC_WEBAPP_URL,
} from '@documenso/lib/constants/app';
import { env } from '@documenso/lib/utils/env';
import type { PDF, Signer } from '@libpdf/core';
import { match } from 'ts-pattern';
@@ -22,7 +22,7 @@ const getSigner = async () => {
return signer;
}
const transport = env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
const transport = NEXT_PRIVATE_SIGNING_TRANSPORT();
// eslint-disable-next-line require-atomic-updates
signer = await match(transport)
@@ -49,6 +49,11 @@ export const signPdf = async ({ pdf }: SignOptions) => {
timestampAuthority: tsa ?? undefined,
longTermValidation: !!tsa,
archivalTimestamp: !!tsa,
// A B-LTA signature (signer chain + RFC 3161 timestamp token + LTV
// revocation data) can exceed the 12288-byte default placeholder,
// depending on the signing certificate chain and the TSA responder.
// The unused portion is zero-padding, so over-reserving is cheap.
estimatedSize: tsa ? 32768 : undefined,
});
return bytes;
+12 -2
View File
@@ -22,10 +22,20 @@ const loadP12 = (): Uint8Array => {
throw new Error('No certificate found for local signing');
};
export const createLocalSigner = async () => {
export type CreateLocalSignerOptions = {
/**
* Fetch missing intermediates via AIA. Leave on for sealing.
* Turn off for health checks so they do not hit the network.
*
* @default true
*/
buildChain?: boolean;
};
export const createLocalSigner = async ({ buildChain = true }: CreateLocalSignerOptions = {}) => {
const p12 = loadP12();
return await P12Signer.create(p12, env('NEXT_PRIVATE_SIGNING_PASSPHRASE') || '', {
buildChain: true,
buildChain,
});
};
+11 -1
View File
@@ -22,6 +22,7 @@ import { findTemplates } from '@documenso/lib/server-only/template/find-template
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
@@ -879,6 +880,15 @@ export const templateRouter = router({
});
}
const csvValidationResult = validateBulkSendCsv({
csvContent: csv,
recipientCount: template.recipients.length,
});
if (!csvValidationResult.success) {
return { success: false as const, error: csvValidationResult.error };
}
await jobs.triggerJob({
name: 'internal.bulk-send-template',
payload: {
@@ -891,6 +901,6 @@ export const templateRouter = router({
},
});
return { success: true };
return { success: true as const };
}),
});
-143
View File
@@ -1,143 +0,0 @@
import { useLayoutEffect, useRef } from 'react';
import { cn } from '../lib/utils';
export type Dimensions = {
height: number;
width: number;
};
export type AutoSizedTextProps = {
children: React.ReactNode;
className?: string;
maxHeight?: number;
useRem?: boolean;
};
const ITERATION_LIMIT = 20;
const MAXIMUM_DIFFERENCE = 1; // px
function getElementDimensions(element: HTMLElement): Dimensions {
const bbox = element.getBoundingClientRect();
return {
width: bbox.width,
height: bbox.height,
};
}
function getBaseFontSize(): number {
try {
const fontSize = getComputedStyle(document.documentElement).fontSize;
const parsed = parseFloat(fontSize);
// Check if we got a valid number
if (!Number.isFinite(parsed)) {
return 16;
}
return parsed;
} catch (error) {
// Fallback to browser default if anything goes wrong
return 16;
}
}
function pxToRem(px: number): number {
return px / getBaseFontSize();
}
export function AutoSizedText({ children, className, maxHeight, useRem = false }: AutoSizedTextProps) {
const childRef = useRef<HTMLDivElement>(null);
const fontSize = useRef<number>(0);
const fontSizeLowerBound = useRef<number>(0);
const fontSizeUpperBound = useRef<number>(0);
const adjustFontSize = (childDimensions: Dimensions, parentDimensions: Dimensions) => {
const childElement = childRef.current;
if (!childElement) {
return;
}
let newFontSize: number;
const targetHeight = maxHeight && maxHeight < parentDimensions.height ? maxHeight : parentDimensions.height;
const isElementTooBig = childDimensions.width > parentDimensions.width || childDimensions.height > targetHeight;
if (isElementTooBig) {
// Scale down if element is bigger than target
newFontSize = (fontSizeLowerBound.current + fontSize.current) / 2;
fontSizeUpperBound.current = fontSize.current;
} else if (childDimensions.width < parentDimensions.width || childDimensions.height < parentDimensions.height) {
// Scale up if element is smaller than target
newFontSize = (fontSizeUpperBound.current + fontSize.current) / 2;
fontSizeLowerBound.current = fontSize.current;
}
fontSize.current = newFontSize;
// Convert to rem if useRem is true
const displayFontSize = useRem ? `${pxToRem(newFontSize)}rem` : `${newFontSize}px`;
childElement.style.fontSize = displayFontSize;
};
useLayoutEffect(() => {
const childElement = childRef.current;
const parentElement = childRef.current?.parentElement;
if (!childElement || !parentElement) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const parentDimensions = entry.contentRect;
// Reset iteration parameters
fontSizeLowerBound.current = 0;
fontSizeUpperBound.current = parentDimensions.height;
let iterationCount = 0;
while (iterationCount <= ITERATION_LIMIT) {
const childDimensions = getElementDimensions(childElement);
const targetHeight = maxHeight && maxHeight < parentDimensions.height ? maxHeight : parentDimensions.height;
const widthDifference = parentDimensions.width - childDimensions.width;
const heightDifference = targetHeight - childDimensions.height;
const childFitsIntoParent = heightDifference >= 0 && widthDifference >= 0;
const isWithinTolerance =
Math.abs(widthDifference) <= MAXIMUM_DIFFERENCE || Math.abs(heightDifference) <= MAXIMUM_DIFFERENCE;
if (childFitsIntoParent && isWithinTolerance) {
break;
}
adjustFontSize(childDimensions, parentDimensions);
iterationCount += 1;
}
});
observer.observe(parentElement);
return () => {
observer.disconnect();
};
}, [maxHeight, useRem]);
return (
<div ref={childRef} className={cn('inline-block leading-none', className)}>
{children}
</div>
);
}
@@ -68,6 +68,12 @@ export type AddSignersFormProps = {
onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean;
/**
* CSP nonce forwarded to `@hello-pangea/dnd`, which injects its drag styles
* as runtime `<style>` elements that would otherwise be blocked by the
* strict `style-src-elem` policy.
*/
nonce?: string;
};
export const AddSignersFormPartial = ({
@@ -79,6 +85,7 @@ export const AddSignersFormPartial = ({
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
nonce,
}: AddSignersFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -647,6 +654,7 @@ export const AddSignersFormPartial = ({
/>
<DragDropContext
nonce={nonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
+13
View File
@@ -4,6 +4,19 @@ import * as React from 'react';
import { cn } from '../lib/utils';
/**
* Note: The underlying @radix-ui/react-menu package is patched, see
* patches/@radix-ui+react-menu+2.1.24.patch.
*
* Since @radix-ui/react-menu 2.1.18, Radix closes any open menu when the window fires a
* "blur" event. Opening a native file picker blurs the window, which would close the
* menu and unmount anything rendered within its content, such as dialogs containing
* file inputs, silently discarding the user's file selection. The patch removes that
* behaviour since there is currently no native prop to opt out of it, see
* https://github.com/radix-ui/primitives/issues/3618.
*
* Regression test: packages/app-tests/e2e/templates/template-bulk-send.spec.ts
*/
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -58,6 +58,12 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean;
/**
* CSP nonce forwarded to `@hello-pangea/dnd`, which injects its drag styles
* as runtime `<style>` elements that would otherwise be blocked by the
* strict `style-src-elem` policy.
*/
nonce?: string;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
@@ -70,6 +76,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
isDocumentPdfLoaded,
onSubmit,
onAutoSave,
nonce,
}: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);
@@ -525,6 +532,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{/* Drag and drop context */}
<DragDropContext
nonce={nonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
+44
View File
@@ -0,0 +1,44 @@
diff --git a/node_modules/@radix-ui/react-menu/dist/index.js b/node_modules/@radix-ui/react-menu/dist/index.js
index b3f6113..d8ec2e3 100644
--- a/node_modules/@radix-ui/react-menu/dist/index.js
+++ b/node_modules/@radix-ui/react-menu/dist/index.js
@@ -139,9 +139,14 @@ var Menu = /* @__PURE__ */ __name((props) => {
if (!open) {
return;
}
- const handleBlur = /* @__PURE__ */ __name(() => handleOpenChange(false), "handleBlur");
- window.addEventListener("blur", handleBlur);
- return () => window.removeEventListener("blur", handleBlur);
+ // DOCUMENSO PATCH: @radix-ui/react-menu 2.1.18+ closes open menus when the window
+ // fires a "blur" event. Opening a native file picker blurs the window, which closes
+ // the menu and unmounts anything rendered within its content, such as dialogs that
+ // contain file inputs, silently discarding the user's file selection. This restores
+ // the pre-2.1.18 behaviour. Regression test: e2e/templates/template-bulk-send.spec.ts
+ // const handleBlur = () => handleOpenChange(false);
+ // window.addEventListener("blur", handleBlur);
+ // return () => window.removeEventListener("blur", handleBlur);
}, [open, handleOpenChange]);
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PopperPrimitive.Root, { ...popperScope, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
MenuProvider,
diff --git a/node_modules/@radix-ui/react-menu/dist/index.mjs b/node_modules/@radix-ui/react-menu/dist/index.mjs
index cd4303a..68865e3 100644
--- a/node_modules/@radix-ui/react-menu/dist/index.mjs
+++ b/node_modules/@radix-ui/react-menu/dist/index.mjs
@@ -73,9 +73,14 @@ var Menu = /* @__PURE__ */ __name((props) => {
if (!open) {
return;
}
- const handleBlur = /* @__PURE__ */ __name(() => handleOpenChange(false), "handleBlur");
- window.addEventListener("blur", handleBlur);
- return () => window.removeEventListener("blur", handleBlur);
+ // DOCUMENSO PATCH: @radix-ui/react-menu 2.1.18+ closes open menus when the window
+ // fires a "blur" event. Opening a native file picker blurs the window, which closes
+ // the menu and unmounts anything rendered within its content, such as dialogs that
+ // contain file inputs, silently discarding the user's file selection. This restores
+ // the pre-2.1.18 behaviour. Regression test: e2e/templates/template-bulk-send.spec.ts
+ // const handleBlur = () => handleOpenChange(false);
+ // window.addEventListener("blur", handleBlur);
+ // return () => window.removeEventListener("blur", handleBlur);
}, [open, handleOpenChange]);
return /* @__PURE__ */ jsx(PopperPrimitive.Root, { ...popperScope, children: /* @__PURE__ */ jsx(
MenuProvider,