From 6a8bb4be04b6f0417dbfd0a385d0099fab2686e2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 8 Sep 2026 21:03:02 +1000 Subject: [PATCH 01/13] fix: improve invalid bulk template upload error handling (#3326) --- .../dialogs/template-bulk-send-dialog.tsx | 123 ++++++++++- .../internal/bulk-send-template.handler.ts | 52 +---- .../template/validate-bulk-send-csv.test.ts | 208 ++++++++++++++++++ .../template/validate-bulk-send-csv.ts | 117 ++++++++++ .../trpc/server/template-router/router.ts | 12 +- 5 files changed, 461 insertions(+), 51 deletions(-) create mode 100644 packages/lib/server-only/template/validate-bulk-send-csv.test.ts create mode 100644 packages/lib/server-only/template/validate-bulk-send-csv.ts 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..7e610f469 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 { @@ -17,7 +20,9 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { 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/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts index cb57b42a2..3db217eda 100644 --- a/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts +++ b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts @@ -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(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: { diff --git a/packages/lib/server-only/template/validate-bulk-send-csv.test.ts b/packages/lib/server-only/template/validate-bulk-send-csv.test.ts new file mode 100644 index 000000000..799b0c541 --- /dev/null +++ b/packages/lib/server-only/template/validate-bulk-send-csv.test.ts @@ -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' }, + ], + }); + }); + }); +}); diff --git a/packages/lib/server-only/template/validate-bulk-send-csv.ts b/packages/lib/server-only/template/validate-bulk-send-csv.ts new file mode 100644 index 000000000..3afc1d7c5 --- /dev/null +++ b/packages/lib/server-only/template/validate-bulk-send-csv.ts @@ -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; + +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 }; +}; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index e11e1f25d..00c4816f8 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -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 }; }), }); From 5e8a4341410cd114f7089c42648f52ea5de77f3f Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 9 Sep 2026 10:51:26 +1000 Subject: [PATCH 02/13] fix: use react router middleware (#3351) --- apps/remix/app/entry.server.tsx | 7 +++-- apps/remix/app/middleware/admin.ts | 13 +++++++++ apps/remix/app/middleware/nonce.ts | 8 +++++ apps/remix/app/root.tsx | 7 +++-- .../routes/_authenticated+/admin+/_layout.tsx | 8 +++-- apps/remix/app/utils/nonce.ts | 8 ++++- apps/remix/react-router.config.ts | 3 ++ apps/remix/server/context.ts | 5 ++-- apps/remix/server/load-context.ts | 29 ++++--------------- 9 files changed, 53 insertions(+), 35 deletions(-) create mode 100644 apps/remix/app/middleware/admin.ts create mode 100644 apps/remix/app/middleware/nonce.ts diff --git a/apps/remix/app/entry.server.tsx b/apps/remix/app/entry.server.tsx index 7f28001f4..895ccba93 100644 --- a/apps/remix/app/entry.server.tsx +++ b/apps/remix/app/entry.server.tsx @@ -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; 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 baffaa6ec..e317068df 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