diff --git a/cms-i18n/nextjs/README.md b/cms-i18n/nextjs/README.md index c7f1fba7..d1137035 100644 --- a/cms-i18n/nextjs/README.md +++ b/cms-i18n/nextjs/README.md @@ -118,6 +118,9 @@ To get started, you need to configure environment variables. Follow these steps: - **`NEXT_PUBLIC_SITE_URL`**: The public URL of your site. This is used for SEO metadata and blog post routing. - **`NEXT_PUBLIC_ENABLE_VISUAL_EDITING`**: Visual editing is enabled by default. Set to `false` to disable. +The form API limits multipart request size and field count. Add rate limiting or bot protection at your hosting edge before +launching a public form. + ## **Running the Application** ### Local Development diff --git a/cms-i18n/nextjs/package.json b/cms-i18n/nextjs/package.json index a7fed575..eb5b62fe 100644 --- a/cms-i18n/nextjs/package.json +++ b/cms-i18n/nextjs/package.json @@ -7,6 +7,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", + "test": "tsx --test src/lib/directus/*.test.ts", "generate:types": "tsx ./src/lib/directus/generateDirectusTypes.ts", "lint": "next lint", "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"", diff --git a/cms-i18n/nextjs/src/app/api/forms/submit/route.ts b/cms-i18n/nextjs/src/app/api/forms/submit/route.ts index a8f12465..715614c9 100644 --- a/cms-i18n/nextjs/src/app/api/forms/submit/route.ts +++ b/cms-i18n/nextjs/src/app/api/forms/submit/route.ts @@ -1,11 +1,16 @@ import { NextResponse } from 'next/server'; import { submitForm } from '@/lib/directus/forms'; -import { validateFormSubmission } from '@/lib/directus/validateFormSubmission'; +import { parseFormRequest, validateFormSubmission } from '@/lib/directus/validateFormSubmission'; import { useDirectus } from '@/lib/directus/directus'; import type { FormField } from '@/types/directus-schema'; export async function POST(request: Request) { - const formData = await request.formData(); + const parsedRequest = await parseFormRequest(request); + if (!parsedRequest.success) { + return NextResponse.json({ error: parsedRequest.error }, { status: parsedRequest.status }); + } + + const formData = parsedRequest.data; const formId = formData.get('formId'); if (typeof formId !== 'string' || !formId.trim()) { @@ -20,16 +25,14 @@ export async function POST(request: Request) { // Fetch the authoritative form field definitions from Directus server-side. // This ensures validation rules (required, validation patterns) come from the // source of truth rather than client-provided data. - const { directus, readItem, withToken } = useDirectus(); + const { directus, readItem } = useDirectus(); let fields: FormField[]; try { + // Public policy restricts this read to active forms; the server token only needs submission permissions. const form = await directus.request( - withToken( - TOKEN, - readItem('forms', formId.trim(), { - fields: ['is_active', { fields: ['id', 'name', 'type', 'label', 'required', 'validation'] }], - } as any), - ), + readItem('forms', formId.trim(), { + fields: ['is_active', { fields: ['id', 'name', 'type', 'label', 'required', 'validation', 'choices'] }], + } as any), ); if (!(form as any).is_active || !Array.isArray((form as any).fields)) { diff --git a/cms-i18n/nextjs/src/lib/directus/validateFormSubmission.test.ts b/cms-i18n/nextjs/src/lib/directus/validateFormSubmission.test.ts new file mode 100644 index 00000000..64f71262 --- /dev/null +++ b/cms-i18n/nextjs/src/lib/directus/validateFormSubmission.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { FormField } from '@/types/directus-schema'; +import { + MAX_FORM_FILE_BYTES, + MAX_FORM_REQUEST_BYTES, + MAX_FORM_REQUEST_ENTRIES, + parseFormRequest, + validateFormSubmission, +} from './validateFormSubmission'; + +const field = (overrides: Partial): FormField => ({ + id: 'field-id', + name: 'field', + type: 'text', + ...overrides, +}); + +test('rejects oversized multipart requests before parsing', async () => { + const request = new Request('https://site.example.test/api/forms/submit', { + method: 'POST', + headers: { 'content-length': String(MAX_FORM_REQUEST_BYTES + 1) }, + body: new FormData(), + }); + + const result = await parseFormRequest(request); + assert.deepEqual(result, { success: false, error: 'Form submission is too large', status: 413 }); +}); + +test('rejects multipart requests with excessive entries', async () => { + const formData = new FormData(); + for (let index = 0; index <= MAX_FORM_REQUEST_ENTRIES; index++) { + formData.set(`field-${index}`, 'value'); + } + const request = new Request('https://site.example.test/api/forms/submit', { method: 'POST', body: formData }); + + const result = await parseFormRequest(request); + assert.equal(result.success, false); + if (!result.success) assert.equal(result.status, 413); +}); + +test('returns parsed data for bounded multipart requests', async () => { + const formData = new FormData(); + formData.set('formId', 'form-id'); + const request = new Request('https://site.example.test/api/forms/submit', { method: 'POST', body: formData }); + + const result = await parseFormRequest(request); + assert.equal(result.success, true); + if (result.success) assert.equal(result.data.get('formId'), 'form-id'); +}); + +test('rejects empty required checkbox controls', () => { + const checkbox = field({ name: 'consent', type: 'checkbox', required: true }); + const group = field({ name: 'topics', type: 'checkbox_group', required: true }); + const formData = new FormData(); + formData.set('consent', 'false'); + formData.set('topics', '[]'); + + assert.equal(validateFormSubmission([checkbox], formData).success, false); + assert.equal(validateFormSubmission([group], formData).success, false); +}); + +test('rejects values outside configured choices', () => { + const choices = [ + { text: 'Basic', value: 'basic' }, + { text: 'Pro', value: 'pro' }, + ]; + const select = field({ name: 'plan', type: 'select', required: true, choices }); + const group = field({ name: 'plans', type: 'checkbox_group', choices }); + const formData = new FormData(); + formData.set('plan', 'enterprise'); + formData.set('plans', JSON.stringify(['basic', 'enterprise'])); + + assert.equal(validateFormSubmission([select], formData).success, false); + assert.equal(validateFormSubmission([group], formData).success, false); +}); + +test('accepts configured choices and checked required controls', () => { + const choices = [{ text: 'Basic', value: 'basic' }]; + const fields = [ + field({ name: 'consent', type: 'checkbox', required: true }), + field({ name: 'plan', type: 'select', required: true, choices }), + field({ name: 'plans', type: 'checkbox_group', required: true, choices }), + ]; + const formData = new FormData(); + formData.set('consent', 'true'); + formData.set('plan', 'basic'); + formData.set('plans', JSON.stringify(['basic'])); + + assert.equal(validateFormSubmission(fields, formData).success, true); +}); + +test('matches the licensed Directus five-megabyte upload limit', () => { + const upload = field({ name: 'upload', type: 'file' }); + const accepted = new FormData(); + accepted.set('upload', new File([new Uint8Array(MAX_FORM_FILE_BYTES)], 'accepted.bin')); + const rejected = new FormData(); + rejected.set('upload', new File([new Uint8Array(MAX_FORM_FILE_BYTES + 1)], 'rejected.bin')); + + assert.equal(MAX_FORM_FILE_BYTES, 5_000_000); + assert.equal(validateFormSubmission([upload], accepted).success, true); + assert.equal(validateFormSubmission([upload], rejected).success, false); +}); diff --git a/cms-i18n/nextjs/src/lib/directus/validateFormSubmission.ts b/cms-i18n/nextjs/src/lib/directus/validateFormSubmission.ts index 05abe7a3..64341557 100644 --- a/cms-i18n/nextjs/src/lib/directus/validateFormSubmission.ts +++ b/cms-i18n/nextjs/src/lib/directus/validateFormSubmission.ts @@ -2,7 +2,42 @@ import { buildZodSchema } from '@/lib/zodSchemaBuilder'; import type { FormField } from '@/types/directus-schema'; /** Matches licensed template Forms policy file upload limit (5 MB). */ -export const MAX_FORM_FILE_BYTES = 5 * 1024 * 1024; +export const MAX_FORM_FILE_BYTES = 5_000_000; + +/** Allows up to five maximum-sized files plus multipart field overhead. */ +export const MAX_FORM_REQUEST_BYTES = MAX_FORM_FILE_BYTES * 5 + 1_000_000; +export const MAX_FORM_REQUEST_ENTRIES = 100; + +type ParseFormRequestResult = + | { success: true; data: FormData } + | { success: false; error: string; status: 400 | 413 }; + +export async function parseFormRequest(request: Request): Promise { + const contentLength = Number(request.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_FORM_REQUEST_BYTES) { + return { success: false, error: 'Form submission is too large', status: 413 }; + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return { success: false, error: 'Invalid form submission', status: 400 }; + } + + let entryCount = 0; + let payloadBytes = 0; + for (const [, value] of formData) { + entryCount++; + payloadBytes += typeof value === 'string' ? new TextEncoder().encode(value).byteLength : value.size; + + if (entryCount > MAX_FORM_REQUEST_ENTRIES || payloadBytes > MAX_FORM_REQUEST_BYTES) { + return { success: false, error: 'Form submission is too large', status: 413 }; + } + } + + return { success: true, data: formData }; +} function parseFieldValue(field: FormField, raw: FormDataEntryValue): unknown { if (field.type === 'file') { @@ -42,7 +77,7 @@ export function validateFormSubmission( if (raw.size > MAX_FORM_FILE_BYTES) { return { success: false, - error: `${field.label || field.name} must be ${MAX_FORM_FILE_BYTES / (1024 * 1024)} MB or smaller`, + error: `${field.label || field.name} must be 5 MB or smaller`, }; } if (raw.size > 0) { diff --git a/cms-i18n/nextjs/src/lib/zodSchemaBuilder.ts b/cms-i18n/nextjs/src/lib/zodSchemaBuilder.ts index 1bece613..f76a6a5b 100644 --- a/cms-i18n/nextjs/src/lib/zodSchemaBuilder.ts +++ b/cms-i18n/nextjs/src/lib/zodSchemaBuilder.ts @@ -6,6 +6,8 @@ export const buildZodSchema = (fields: FormField[]) => { fields.forEach((field) => { let fieldSchema: z.ZodTypeAny; + const fieldLabel = field.label || field.name; + const choiceValues = field.choices?.map((choice) => choice.value) || []; switch (field.type) { case 'checkbox': @@ -83,14 +85,32 @@ export const buildZodSchema = (fields: FormField[]) => { } if (field.required) { - if (fieldSchema instanceof z.ZodString) { - fieldSchema = fieldSchema.nonempty(`${field.label || field.name} is required`); + if (field.type === 'checkbox') { + fieldSchema = fieldSchema.refine((value) => value === true, `${fieldLabel} is required`); + } else if (field.type === 'checkbox_group') { + fieldSchema = fieldSchema.refine((value) => value.length > 0, `${fieldLabel} is required`); + } else if (fieldSchema instanceof z.ZodString) { + fieldSchema = fieldSchema.nonempty(`${fieldLabel} is required`); } } else { // Allow empty strings or undefined for optional fields fieldSchema = fieldSchema.or(z.literal('')).or(z.undefined()); } + if (choiceValues.length > 0) { + if (field.type === 'checkbox_group') { + fieldSchema = fieldSchema.refine( + (value) => value === '' || value === undefined || value.every((choice: string) => choiceValues.includes(choice)), + `${fieldLabel} contains an invalid option`, + ); + } else if (field.type === 'radio' || field.type === 'select') { + fieldSchema = fieldSchema.refine( + (value) => value === '' || value === undefined || choiceValues.includes(value), + `${fieldLabel} must be a configured option`, + ); + } + } + if (field.name) { schema[field.name] = fieldSchema; } diff --git a/cms/nextjs/.env.example b/cms/nextjs/.env.example index 84149c23..5022848c 100644 --- a/cms/nextjs/.env.example +++ b/cms/nextjs/.env.example @@ -1,6 +1,10 @@ -NEXT_PUBLIC_DIRECTUS_URL=http://localhost:8055 # Or your cloud instance URL -DIRECTUS_SERVER_TOKEN=token_from_Webmaster_account -DIRECTUS_ADMIN_TOKEN=token_from_Admin_account # Only for local type generation, never used at runtime -NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Application URL +# Directus instance URL +NEXT_PUBLIC_DIRECTUS_URL=http://localhost:8055 +# Static token from your admin user (Directus → Users Directory → Token → Save) +# Must match the instance you applied the template to — regenerate after a database reset +DIRECTUS_SERVER_TOKEN= +# Admin token for local type generation only (pnpm run generate:types) +DIRECTUS_ADMIN_TOKEN= +NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Set to false to disable (enabled by default) # NEXT_PUBLIC_ENABLE_VISUAL_EDITING=false diff --git a/cms/nextjs/README.md b/cms/nextjs/README.md index a8f053c3..81efbd90 100644 --- a/cms/nextjs/README.md +++ b/cms/nextjs/README.md @@ -30,6 +30,14 @@ CMS-powered web applications. Directus allows you to work on unpublished content using **Draft Mode**. This Next.js template is configured to support Directus Draft Mode out of the box, enabling live previews of unpublished or draft content as you make changes. +### **Content Versioning in Directus 12** + +In Directus 12, the published content version is called `published` (formerly `main`), and every versioned item +automatically gets a `draft` version. Published items are locked in the Studio — edits happen on the draft version and +are promoted to publish. This template handles both keys: preview URLs with `version=published` (or the legacy +`version=main`) load the live content, while `version=draft` (or any custom version key) fetches that version from the +API. + ### **Live Preview Setup** [Directus Live Preview](https://docs.directus.io/guides/headless-cms/live-preview/nextjs.html) @@ -46,7 +54,7 @@ Directus Draft Mode out of the box, enabling live previews of unpublished or dra To set up this template, ensure you have the following: -- **Node.js** (16.x or newer) +- **Node.js** (22.x or newer) - **npm** or **pnpm** - Access to a **Directus** instance ([cloud or self-hosted](../../README.md)) @@ -78,11 +86,16 @@ To get started, you need to configure environment variables. Follow these steps: 2. **Update the following variables in your `.env` file:** - **`NEXT_PUBLIC_DIRECTUS_URL`**: URL of your Directus instance. - - **`DIRECTUS_SERVER_TOKEN`**: Token from the **Webmaster** account in Directus. Used server-side for preview, draft content, and form submissions. + - **`DIRECTUS_SERVER_TOKEN`**: Static token from your Directus **admin account** (created during first-launch onboarding). Used server-side for preview, draft + content, and form submissions. With a licensed instance, you can instead use a token from a user assigned only the + **Content - Live Preview** and **Forms - Submission** policies. - **`DIRECTUS_ADMIN_TOKEN`**: Admin token for local type generation only. Never used at runtime. - **`NEXT_PUBLIC_SITE_URL`**: The public URL of your site. This is used for SEO metadata and blog post routing. - **`NEXT_PUBLIC_ENABLE_VISUAL_EDITING`**: Visual editing is enabled by default. Set to `false` to disable. +The form API limits multipart request size and field count. Add rate limiting or bot protection at your hosting edge before +launching a public form. + ## **Running the Application** ### Local Development diff --git a/cms/nextjs/package.json b/cms/nextjs/package.json index 2a3bc0ff..fffd22b2 100644 --- a/cms/nextjs/package.json +++ b/cms/nextjs/package.json @@ -7,14 +7,15 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", + "test": "tsx --test src/lib/directus/*.test.ts", "generate:types": "tsx ./src/lib/directus/generateDirectusTypes.ts", "lint": "next lint", "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"" }, "dependencies": { - "@directus/sdk": "20.0.3", - "@directus/visual-editing": "2.0.0", + "@directus/sdk": "22.0.0", + "@directus/visual-editing": "2.1.0", "@hookform/resolvers": "5.0.1", "@radix-ui/react-checkbox": "1.1.4", "@radix-ui/react-collapsible": "1.1.3", @@ -52,7 +53,7 @@ "@trivago/prettier-plugin-sort-imports": "5.2.2", "@types/node": "22.14.0", "@types/readline-sync": "1.4.8", - "directus-sdk-typegen": "0.2.0", + "directus-sdk-typegen": "0.2.1", "eslint": "9.24.0", "eslint-config-next": "15.2.8", "eslint-config-prettier": "10.1.1", @@ -71,6 +72,9 @@ "typescript": "5.8.3", "typescript-eslint": "8.29.1" }, + "engines": { + "node": ">=22" + }, "pnpm": { "overrides": { "tar": "^7.5.4" diff --git a/cms/nextjs/pnpm-lock.yaml b/cms/nextjs/pnpm-lock.yaml index 00ef8e40..c54caed0 100644 --- a/cms/nextjs/pnpm-lock.yaml +++ b/cms/nextjs/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: .: dependencies: '@directus/sdk': - specifier: 20.0.3 - version: 20.0.3 + specifier: 22.0.0 + version: 22.0.0 '@directus/visual-editing': - specifier: 2.0.0 - version: 2.0.0 + specifier: 2.1.0 + version: 2.1.0 '@hookform/resolvers': specifier: 5.0.1 version: 5.0.1(react-hook-form@7.55.0(react@19.2.3)) @@ -124,8 +124,8 @@ importers: specifier: 1.4.8 version: 1.4.8 directus-sdk-typegen: - specifier: 0.2.0 - version: 0.2.0 + specifier: 0.2.1 + version: 0.2.1 eslint: specifier: 9.24.0 version: 9.24.0(jiti@1.21.7) @@ -308,13 +308,12 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} - '@directus/sdk@20.0.3': - resolution: {integrity: sha512-PFTYx8QxYi36L9zjOVldwZ88Iln9pBGtEamEjLGFuYqy1jk5jKJ7qA7827KfeZJkz3EtNNZM6J605I7s/ayD2g==} + '@directus/sdk@22.0.0': + resolution: {integrity: sha512-1D3cgjg2jnA7S1LXwKoYSw7yyJCs/DyKUOUDMLbvLtljafWJ5KOef7O8bVDPuQkVw6Bvoo6GUCbAMYI6Ff8Igg==} engines: {node: '>=22'} - '@directus/visual-editing@2.0.0': - resolution: {integrity: sha512-cPcMEPiFVdVO6KeC3OcuZPRoiYHxeATBJSflbewhcwGuG0Qze6ybKL+WWtCgV+mS4DCFbPd/7+ctQPu/JL60RQ==} - engines: {node: '>=22.12.0'} + '@directus/visual-editing@2.1.0': + resolution: {integrity: sha512-zYjbsoHZFE2WK6WXu/3uFJ8/ZiCNkIBsEIZjju3AOHzw1VU+Vc1jbD8Q73o+r8HNfwBmQ7qlkZ91Bh0jLO323w==} '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} @@ -587,67 +586,79 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -752,24 +763,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.2.5': resolution: {integrity: sha512-4ZNKmuEiW5hRKkGp2HWwZ+JrvK4DQLgf8YDaqtZyn7NYdl0cHfatvlnLFSWUayx9yFAUagIgRGRk8pFxS8Qniw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.2.5': resolution: {integrity: sha512-bE6lHQ9GXIf3gCDE53u2pTl99RPZW5V1GLHSRMJ5l/oB/MT+cohu9uwnCK7QUph2xIOu2a6+27kL0REa/kqwZw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.2.5': resolution: {integrity: sha512-y7EeQuSkQbTAkCEQnJXm1asRUuGSWAchGJ3c+Qtxh8LVjXleZast8Mn/rL7tZOm7o35QeIpIcid6ufG7EVTTcA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.2.5': resolution: {integrity: sha512-gQMz0yA8/dskZM2Xyiq2FRShxSrsJNha40Ob/M2n2+JGRrZ0JwTVjLdvtN6vCxuq4ByhOd4a9qEf60hApNR2gQ==} @@ -1367,31 +1382,37 @@ packages: resolution: {integrity: sha512-vtIu34luF1jRktlHtiwm2mjuE8oJCsFiFr8hT5+tFQdqFKjPhbJXn83LswKsOhy0GxAEevpXDI4xxEwkjuXIPA==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.4.1': resolution: {integrity: sha512-H3PaOuGyhFXiyJd+09uPhGl4gocmhyi1BRzvsP8Lv5AQO3p3/ZY7WjV4t2NkBksm9tMjf3YbOVHyPWi2eWsNYw==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.4.1': resolution: {integrity: sha512-4+GmJcaaFntCi1S01YByqp8wLMjV/FyQyHVGm0vedIhL1Vfx7uHkz/sZmKsidRwokBGuxi92GFmSzqT2O8KcNA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-s390x-gnu@1.4.1': resolution: {integrity: sha512-6RDQVCmtFYTlhy89D5ixTqo9bTQqFhvNN0Ey1wJs5r+01Dq15gPHRXv2jF2bQATtMrOfYwv+R2ZR9ew1N1N3YQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.4.1': resolution: {integrity: sha512-XpU9uzIkD86+19NjCXxlVPISMUrVXsXo5htxtuG+uJ59p5JauSRZsIxQxzzfKzkxEjdvANPM/lS1HFoX6A6QeA==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.4.1': resolution: {integrity: sha512-3CDjG/spbTKCSHl66QP2ekHSD+H34i7utuDIM5gzoNBcZ1gTO0Op09Wx5cikXnhORRf9+HyDWzm37vU1PLSM1A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.4.1': resolution: {integrity: sha512-50tYhvbCTnuzMn7vmP8IV2UKF7ITo1oihygEYq9wW2DUb/Y+QMqBHJUSCABRngATjZ4shOK6f2+s0gQX6ElENQ==} @@ -1771,8 +1792,8 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} - directus-sdk-typegen@0.2.0: - resolution: {integrity: sha512-H9A3EaKSPbhL2vLslSrVq3xw5IHWLGM64vmT/wMtsb5DgSNVEYipFb7skuB2nKhP39Md2zYGTNHzjnwqydUI5A==} + directus-sdk-typegen@0.2.1: + resolution: {integrity: sha512-a0tcgCa4p2kAO8AJ+SBC97legirxdC3An6VztsvoZwtttFilI8YmCgCM4TfgDtCR/sh+QrpPPgh/Tae7mDxYfw==} engines: {node: '>=18.0.0'} hasBin: true @@ -3632,9 +3653,9 @@ snapshots: '@types/tough-cookie': 4.0.5 tough-cookie: 4.1.4 - '@directus/sdk@20.0.3': {} + '@directus/sdk@22.0.0': {} - '@directus/visual-editing@2.0.0': + '@directus/visual-editing@2.1.0': dependencies: '@reach/observe-rect': 1.2.0 @@ -4992,7 +5013,7 @@ snapshots: diff@5.2.0: {} - directus-sdk-typegen@0.2.0: + directus-sdk-typegen@0.2.1: dependencies: commander: 8.3.0 diff --git a/cms/nextjs/src/app/[[...permalink]]/PageClient.tsx b/cms/nextjs/src/app/[[...permalink]]/PageClient.tsx index 3a802374..4dd04423 100644 --- a/cms/nextjs/src/app/[[...permalink]]/PageClient.tsx +++ b/cms/nextjs/src/app/[[...permalink]]/PageClient.tsx @@ -7,11 +7,12 @@ import { useVisualEditing } from '@/hooks/useVisualEditing'; import { PageBlock } from '@/types/directus-schema'; import { Button } from '@/components/ui/button'; import { Pencil } from 'lucide-react'; -import { setAttr } from '@directus/visual-editing'; +import { setAttr, setVisualEditingPageContext } from '@/lib/directus/visualEditing'; interface PageClientProps { sections: PageBlock[]; pageId?: string; + contentVersion?: string; } interface VisualEditingOptions { @@ -19,10 +20,12 @@ interface VisualEditingOptions { onSaved?: () => void; } -export default function PageClient({ sections, pageId }: PageClientProps) { +export default function PageClient({ sections, pageId, contentVersion }: PageClientProps) { const { isVisualEditingEnabled, apply } = useVisualEditing(); const router = useRouter(); + setVisualEditingPageContext({ pageId, contentVersion }); + useEffect(() => { if (isVisualEditingEnabled) { apply({ @@ -46,7 +49,7 @@ export default function PageClient({ sections, pageId }: PageClientProps) { {isVisualEditingEnabled && pageId && (
- {/* If you're not using the visual editor it's safe to remove this element. Just a helper to let editors add edit / add new blocks to a page. */} + {/* Opens the page blocks builder — the versioned entry point for M2A content on pages. */}
diff --git a/cms/nextjs/src/components/forms/FormBuilder.tsx b/cms/nextjs/src/components/forms/FormBuilder.tsx index 9a69c504..59b95183 100644 --- a/cms/nextjs/src/components/forms/FormBuilder.tsx +++ b/cms/nextjs/src/components/forms/FormBuilder.tsx @@ -3,13 +3,13 @@ import { useState } from 'react'; import { CheckCircle } from 'lucide-react'; import DynamicForm from './DynamicForm'; -import { submitForm } from '@/lib/directus/forms'; import { FormField } from '@/types/directus-schema'; import { cn } from '@/lib/utils'; interface FormBuilderProps { className?: string; - itemId?: string; + /** block_form id — passed to DynamicForm for draft visual editing paths */ + blockFormId?: string; form: { id: string; on_success?: 'redirect' | 'message' | null; @@ -23,7 +23,7 @@ interface FormBuilderProps { }; } -const FormBuilder = ({ form, className }: FormBuilderProps) => { +const FormBuilder = ({ form, className, blockFormId }: FormBuilderProps) => { const [isSubmitted, setIsSubmitted] = useState(false); const [error, setError] = useState(null); @@ -32,13 +32,28 @@ const FormBuilder = ({ form, className }: FormBuilderProps) => { const handleSubmit = async (data: Record) => { setError(null); try { - const fieldsWithNames = form.fields.map((field) => ({ - id: field.id, - name: field.name || '', - type: field.type || '', - })); + const formData = new FormData(); + formData.append('formId', form.id); - await submitForm(form.id, fieldsWithNames, data); + for (const field of form.fields) { + if (!field.name) continue; + const value = data[field.name]; + if (value === undefined || value === null) continue; + + if (value instanceof File) { + formData.append(field.name, value); + } else if (Array.isArray(value)) { + formData.append(field.name, JSON.stringify(value)); + } else { + formData.append(field.name, String(value)); + } + } + + const response = await fetch('/api/forms/submit', { method: 'POST', body: formData }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(typeof body.error === 'string' ? body.error : 'Form submission failed'); + } if (form.on_success === 'redirect' && form.success_redirect_url) { window.location.href = form.success_redirect_url; @@ -47,7 +62,7 @@ const FormBuilder = ({ form, className }: FormBuilderProps) => { } } catch (err) { console.error('Error submitting form:', err); - setError('Failed to submit the form. Please try again later.'); + setError(err instanceof Error ? err.message : 'Failed to submit the form. Please try again later.'); } }; @@ -75,6 +90,7 @@ const FormBuilder = ({ form, className }: FormBuilderProps) => { onSubmit={handleSubmit} submitLabel={form.submit_label || 'Submit'} id={form.id} + blockFormId={blockFormId} /> ); diff --git a/cms/nextjs/src/components/layout/NavigationBar.tsx b/cms/nextjs/src/components/layout/NavigationBar.tsx index 1295aa58..21a435d8 100644 --- a/cms/nextjs/src/components/layout/NavigationBar.tsx +++ b/cms/nextjs/src/components/layout/NavigationBar.tsx @@ -18,7 +18,7 @@ import { ChevronDown, Menu } from 'lucide-react'; import ThemeToggle from '../ui/ThemeToggle'; import SearchModal from '@/components/ui/SearchModal'; import Container from '@/components/ui/container'; -import { setAttr } from '@directus/visual-editing'; +import { setAttr } from '@/lib/directus/visualEditing'; interface NavigationBarProps { navigation: any; diff --git a/cms/nextjs/src/hooks/useVisualEditing.ts b/cms/nextjs/src/hooks/useVisualEditing.ts index 4d40cdb2..548f6f3f 100644 --- a/cms/nextjs/src/hooks/useVisualEditing.ts +++ b/cms/nextjs/src/hooks/useVisualEditing.ts @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'; import { useSearchParams, usePathname } from 'next/navigation'; -import { apply as applyVisualEditing, setAttr } from '@directus/visual-editing'; +import { apply as applyVisualEditing } from '@directus/visual-editing'; +import { setAttr, setVisualEditingAttrsEnabled } from '@/lib/directus/visualEditing'; interface ApplyOptions { elements?: HTMLElement[] | HTMLElement; @@ -19,12 +20,38 @@ export function useVisualEditing() { const enableVisualEditingEnv = process.env.NEXT_PUBLIC_ENABLE_VISUAL_EDITING !== 'false'; const directusUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL || ''; + const readPersistedVisualEditing = () => { + try { + return localStorage.getItem('visual-editing') === 'true'; + } catch { + return false; + } + }; + + const writePersistedVisualEditing = (enabled: boolean) => { + try { + if (enabled) { + localStorage.setItem('visual-editing', 'true'); + } else { + localStorage.removeItem('visual-editing'); + } + } catch { + // Storage can be unavailable in restrictive browser contexts. + } + }; + useEffect(() => { if (typeof window === 'undefined') return; const param = searchParams.get('visual-editing'); + // Enable when Directus sends ?preview=true (live preview tab) even without + // ?visual-editing=true — both indicate an admin-controlled iframe. + const isPreview = searchParams.get('preview') === 'true'; if (!enableVisualEditingEnv) { + setVisualEditingAttrsEnabled(false); + setIsVisualEditingEnabled(false); + if (param === 'true') { console.warn('Visual editing is not enabled in this environment.'); } @@ -33,9 +60,9 @@ export function useVisualEditing() { } if (param === 'true') { - localStorage.setItem('visual-editing', 'true'); + writePersistedVisualEditing(true); } else if (param === 'false') { - localStorage.removeItem('visual-editing'); + writePersistedVisualEditing(false); const newParams = new URLSearchParams(searchParams.toString()); newParams.delete('visual-editing'); @@ -44,10 +71,12 @@ export function useVisualEditing() { window.history.replaceState({}, '', cleanUrl); } - const persisted = localStorage.getItem('visual-editing') === 'true'; - setIsVisualEditingEnabled(persisted); + const persisted = readPersistedVisualEditing(); + const shouldEnable = persisted || isPreview; + setVisualEditingAttrsEnabled(shouldEnable); + setIsVisualEditingEnabled(shouldEnable); - if (persisted && param !== 'true') { + if (shouldEnable && param !== 'true') { const newParams = new URLSearchParams(searchParams.toString()); newParams.set('visual-editing', 'true'); diff --git a/cms/nextjs/src/lib/directus/directus-utils.ts b/cms/nextjs/src/lib/directus/directus-utils.ts index 4fbc6721..5624602e 100644 --- a/cms/nextjs/src/lib/directus/directus-utils.ts +++ b/cms/nextjs/src/lib/directus/directus-utils.ts @@ -1,5 +1,17 @@ import { DirectusFile } from '@/types/directus-schema'; +/** Map common Directus SDK errors to actionable setup messages. */ +export function formatDirectusRequestError(error: unknown, context: string): string { + if (error && typeof error === 'object' && 'response' in error) { + const status = (error as { response?: Response }).response?.status; + if (status === 401) { + return `${context}: Directus rejected DIRECTUS_SERVER_TOKEN (401). Generate a new static token on your admin user in the Users Directory, update cms/nextjs/.env, and restart pnpm dev.`; + } + } + + return `${context}: ${error instanceof Error ? error.message : 'request failed'}`; +} + export function getDirectusAssetURL(fileOrString: string | DirectusFile | null | undefined): string { if (!fileOrString) return ''; diff --git a/cms/nextjs/src/lib/directus/fetchers.ts b/cms/nextjs/src/lib/directus/fetchers.ts index 2fbfd127..c6077e10 100644 --- a/cms/nextjs/src/lib/directus/fetchers.ts +++ b/cms/nextjs/src/lib/directus/fetchers.ts @@ -2,6 +2,7 @@ import { BlockPost, Page, PageBlock, Post, Redirect, Schema } from '@/types/dire import { useDirectus } from './directus'; import { readItems, aggregate, readItem, readSingleton, withToken, QueryFilter } from '@directus/sdk'; import { RedirectError } from '../redirects'; +import { formatDirectusRequestError } from './directus-utils'; /** * Page fields configuration for Directus queries @@ -169,7 +170,7 @@ export const fetchPageData = async (permalink: string, postPage = 1, token?: str return page; } catch (error) { console.error('Error fetching page data:', error); - throw new Error('Failed to fetch page data'); + throw new Error(formatDirectusRequestError(error, 'Failed to fetch page data')); } }; diff --git a/cms/nextjs/src/lib/directus/forms.ts b/cms/nextjs/src/lib/directus/forms.ts index 74cfc596..916df012 100644 --- a/cms/nextjs/src/lib/directus/forms.ts +++ b/cms/nextjs/src/lib/directus/forms.ts @@ -1,7 +1,7 @@ import { useDirectus } from './directus'; import type { FormSubmission, FormSubmissionValue } from '@/types/directus-schema'; - +/** Server-only — call from API routes, not client components. */ export const submitForm = async ( formId: string, fields: { id: string; name: string; type: string }[], diff --git a/cms/nextjs/src/lib/directus/validateFormSubmission.test.ts b/cms/nextjs/src/lib/directus/validateFormSubmission.test.ts new file mode 100644 index 00000000..64f71262 --- /dev/null +++ b/cms/nextjs/src/lib/directus/validateFormSubmission.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { FormField } from '@/types/directus-schema'; +import { + MAX_FORM_FILE_BYTES, + MAX_FORM_REQUEST_BYTES, + MAX_FORM_REQUEST_ENTRIES, + parseFormRequest, + validateFormSubmission, +} from './validateFormSubmission'; + +const field = (overrides: Partial): FormField => ({ + id: 'field-id', + name: 'field', + type: 'text', + ...overrides, +}); + +test('rejects oversized multipart requests before parsing', async () => { + const request = new Request('https://site.example.test/api/forms/submit', { + method: 'POST', + headers: { 'content-length': String(MAX_FORM_REQUEST_BYTES + 1) }, + body: new FormData(), + }); + + const result = await parseFormRequest(request); + assert.deepEqual(result, { success: false, error: 'Form submission is too large', status: 413 }); +}); + +test('rejects multipart requests with excessive entries', async () => { + const formData = new FormData(); + for (let index = 0; index <= MAX_FORM_REQUEST_ENTRIES; index++) { + formData.set(`field-${index}`, 'value'); + } + const request = new Request('https://site.example.test/api/forms/submit', { method: 'POST', body: formData }); + + const result = await parseFormRequest(request); + assert.equal(result.success, false); + if (!result.success) assert.equal(result.status, 413); +}); + +test('returns parsed data for bounded multipart requests', async () => { + const formData = new FormData(); + formData.set('formId', 'form-id'); + const request = new Request('https://site.example.test/api/forms/submit', { method: 'POST', body: formData }); + + const result = await parseFormRequest(request); + assert.equal(result.success, true); + if (result.success) assert.equal(result.data.get('formId'), 'form-id'); +}); + +test('rejects empty required checkbox controls', () => { + const checkbox = field({ name: 'consent', type: 'checkbox', required: true }); + const group = field({ name: 'topics', type: 'checkbox_group', required: true }); + const formData = new FormData(); + formData.set('consent', 'false'); + formData.set('topics', '[]'); + + assert.equal(validateFormSubmission([checkbox], formData).success, false); + assert.equal(validateFormSubmission([group], formData).success, false); +}); + +test('rejects values outside configured choices', () => { + const choices = [ + { text: 'Basic', value: 'basic' }, + { text: 'Pro', value: 'pro' }, + ]; + const select = field({ name: 'plan', type: 'select', required: true, choices }); + const group = field({ name: 'plans', type: 'checkbox_group', choices }); + const formData = new FormData(); + formData.set('plan', 'enterprise'); + formData.set('plans', JSON.stringify(['basic', 'enterprise'])); + + assert.equal(validateFormSubmission([select], formData).success, false); + assert.equal(validateFormSubmission([group], formData).success, false); +}); + +test('accepts configured choices and checked required controls', () => { + const choices = [{ text: 'Basic', value: 'basic' }]; + const fields = [ + field({ name: 'consent', type: 'checkbox', required: true }), + field({ name: 'plan', type: 'select', required: true, choices }), + field({ name: 'plans', type: 'checkbox_group', required: true, choices }), + ]; + const formData = new FormData(); + formData.set('consent', 'true'); + formData.set('plan', 'basic'); + formData.set('plans', JSON.stringify(['basic'])); + + assert.equal(validateFormSubmission(fields, formData).success, true); +}); + +test('matches the licensed Directus five-megabyte upload limit', () => { + const upload = field({ name: 'upload', type: 'file' }); + const accepted = new FormData(); + accepted.set('upload', new File([new Uint8Array(MAX_FORM_FILE_BYTES)], 'accepted.bin')); + const rejected = new FormData(); + rejected.set('upload', new File([new Uint8Array(MAX_FORM_FILE_BYTES + 1)], 'rejected.bin')); + + assert.equal(MAX_FORM_FILE_BYTES, 5_000_000); + assert.equal(validateFormSubmission([upload], accepted).success, true); + assert.equal(validateFormSubmission([upload], rejected).success, false); +}); diff --git a/cms/nextjs/src/lib/directus/validateFormSubmission.ts b/cms/nextjs/src/lib/directus/validateFormSubmission.ts new file mode 100644 index 00000000..64341557 --- /dev/null +++ b/cms/nextjs/src/lib/directus/validateFormSubmission.ts @@ -0,0 +1,105 @@ +import { buildZodSchema } from '@/lib/zodSchemaBuilder'; +import type { FormField } from '@/types/directus-schema'; + +/** Matches licensed template Forms policy file upload limit (5 MB). */ +export const MAX_FORM_FILE_BYTES = 5_000_000; + +/** Allows up to five maximum-sized files plus multipart field overhead. */ +export const MAX_FORM_REQUEST_BYTES = MAX_FORM_FILE_BYTES * 5 + 1_000_000; +export const MAX_FORM_REQUEST_ENTRIES = 100; + +type ParseFormRequestResult = + | { success: true; data: FormData } + | { success: false; error: string; status: 400 | 413 }; + +export async function parseFormRequest(request: Request): Promise { + const contentLength = Number(request.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_FORM_REQUEST_BYTES) { + return { success: false, error: 'Form submission is too large', status: 413 }; + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return { success: false, error: 'Invalid form submission', status: 400 }; + } + + let entryCount = 0; + let payloadBytes = 0; + for (const [, value] of formData) { + entryCount++; + payloadBytes += typeof value === 'string' ? new TextEncoder().encode(value).byteLength : value.size; + + if (entryCount > MAX_FORM_REQUEST_ENTRIES || payloadBytes > MAX_FORM_REQUEST_BYTES) { + return { success: false, error: 'Form submission is too large', status: 413 }; + } + } + + return { success: true, data: formData }; +} + +function parseFieldValue(field: FormField, raw: FormDataEntryValue): unknown { + if (field.type === 'file') { + return raw instanceof File ? raw : undefined; + } + if (typeof raw !== 'string') { + return undefined; + } + if (field.type === 'checkbox') { + return raw === 'true'; + } + if (field.type === 'checkbox_group') { + try { + const parsed = JSON.parse(raw); + + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + return raw; +} + +export function validateFormSubmission( + fields: FormField[], + formData: FormData, +): { success: true; data: Record } | { success: false; error: string } { + const data: Record = {}; + + for (const field of fields) { + if (!field.name) continue; + const raw = formData.get(field.name); + if (raw === null) continue; + + if (field.type === 'file' && raw instanceof File) { + if (raw.size > MAX_FORM_FILE_BYTES) { + return { + success: false, + error: `${field.label || field.name} must be 5 MB or smaller`, + }; + } + if (raw.size > 0) { + data[field.name] = raw; + } + continue; + } + + const value = parseFieldValue(field, raw); + if (value !== undefined) { + data[field.name] = value; + } + } + + const schema = buildZodSchema(fields); + const result = schema.safeParse(data); + + if (!result.success) { + const first = result.error.issues[0]; + + return { success: false, error: first?.message || 'Validation failed' }; + } + + return { success: true, data: result.data as Record }; +} diff --git a/cms/nextjs/src/lib/directus/visualEditing.ts b/cms/nextjs/src/lib/directus/visualEditing.ts new file mode 100644 index 00000000..e2b62488 --- /dev/null +++ b/cms/nextjs/src/lib/directus/visualEditing.ts @@ -0,0 +1,96 @@ +'use client'; + +import { setAttr as baseSetAttr } from '@directus/visual-editing'; + +interface ApplyOptions { + collection: string; + item: string | number; + fields?: string | string[]; + mode?: 'modal' | 'popover' | 'drawer'; +} + +export type SetBlockAttrOptions = { + blockCollection: string; + blockItemId: string | number; + fields: string | string[]; + mode?: 'modal' | 'popover' | 'drawer'; + /** + * Draft-only path override on the `pages` item, e.g. `blocks.item:block_pricing.pricing_cards.title`. + * Use when the editable field lives deeper than `blocks.item:{block}.{field}`. + */ + pageFields?: string | string[]; +}; + +type PageVisualEditingContext = { + contentVersion?: string; + pageId?: string; +}; + +let pageContext: PageVisualEditingContext = {}; +let visualEditingAttrsEnabled = false; + +/** Set from PageClient so setBlockAttr() can route through the versioned pages item. */ +export function setVisualEditingPageContext(ctx: PageVisualEditingContext) { + pageContext = ctx; +} + +export function getIsDraftPreview(): boolean { + return !!pageContext.contentVersion; +} + +export function setVisualEditingAttrsEnabled(enabled: boolean) { + visualEditingAttrsEnabled = enabled; +} + +export const setAttr = (options: ApplyOptions) => { + if (visualEditingAttrsEnabled) { + return baseSetAttr({ ...options }); + } +}; + +/** Maps block field names to the M2A path on a versioned `pages` item. */ +function toPageBlockFields( + blockCollection: string, + fields: string | string[], + pageFields?: string | string[], +): string | string[] { + if (pageFields) { + return pageFields; + } + + const list = Array.isArray(fields) ? fields : [fields]; + const paths = list.map((field) => `blocks.item:${blockCollection}.${field}`); + + return paths.length === 1 ? paths[0] : paths; +} + +/** + * Visual editing attrs for page-builder blocks. + * + * Page blocks are M2A items (`page_blocks` → `block_hero`, etc.). On published/live + * preview, target the block collection directly for field-level popovers. + * + * When a content version is active (e.g. `?version=draft`), edits belong to the + * versioned `pages` item — so attrs route through that parent with nested + * `blocks.item:…` paths and open in modal mode. + */ +export const setBlockAttr = (options: SetBlockAttrOptions) => { + const { blockCollection, blockItemId, fields, mode, pageFields } = options; + const { contentVersion, pageId } = pageContext; + + if (contentVersion && pageId) { + return setAttr({ + collection: 'pages', + item: pageId, + fields: toPageBlockFields(blockCollection, fields, pageFields), + mode: 'modal', + }); + } + + return setAttr({ + collection: blockCollection, + item: blockItemId, + fields, + mode, + }); +}; diff --git a/cms/nextjs/src/lib/redirects.ts b/cms/nextjs/src/lib/redirects.ts index a751f4c6..885a279b 100644 --- a/cms/nextjs/src/lib/redirects.ts +++ b/cms/nextjs/src/lib/redirects.ts @@ -20,6 +20,7 @@ export async function generateRedirects(): Promise { (redirect): redirect is { url_from: string; url_to: string; response_code: '301' | '302' } => typeof redirect.url_from === 'string' && typeof redirect.url_to === 'string' && + redirect.url_from !== redirect.url_to && (redirect.response_code === '301' || redirect.response_code === '302'), ) .map((redirect) => ({ @@ -32,11 +33,13 @@ export async function generateRedirects(): Promise { // Log as warning instead of error to avoid failing builds const isBuildPhase = process.env.npm_lifecycle_event === 'build' || process.env.NEXT_BUILD === 'true'; if (isBuildPhase) { - console.warn('Could not load redirects from Directus during build (this is normal if Directus is not configured/running)'); + console.warn( + 'Could not load redirects from Directus during build (this is normal if Directus is not configured/running)', + ); } else { console.error('Error generating redirects:', error); } - -return []; + + return []; } } diff --git a/cms/nextjs/src/lib/zodSchemaBuilder.ts b/cms/nextjs/src/lib/zodSchemaBuilder.ts index 1bece613..f76a6a5b 100644 --- a/cms/nextjs/src/lib/zodSchemaBuilder.ts +++ b/cms/nextjs/src/lib/zodSchemaBuilder.ts @@ -6,6 +6,8 @@ export const buildZodSchema = (fields: FormField[]) => { fields.forEach((field) => { let fieldSchema: z.ZodTypeAny; + const fieldLabel = field.label || field.name; + const choiceValues = field.choices?.map((choice) => choice.value) || []; switch (field.type) { case 'checkbox': @@ -83,14 +85,32 @@ export const buildZodSchema = (fields: FormField[]) => { } if (field.required) { - if (fieldSchema instanceof z.ZodString) { - fieldSchema = fieldSchema.nonempty(`${field.label || field.name} is required`); + if (field.type === 'checkbox') { + fieldSchema = fieldSchema.refine((value) => value === true, `${fieldLabel} is required`); + } else if (field.type === 'checkbox_group') { + fieldSchema = fieldSchema.refine((value) => value.length > 0, `${fieldLabel} is required`); + } else if (fieldSchema instanceof z.ZodString) { + fieldSchema = fieldSchema.nonempty(`${fieldLabel} is required`); } } else { // Allow empty strings or undefined for optional fields fieldSchema = fieldSchema.or(z.literal('')).or(z.undefined()); } + if (choiceValues.length > 0) { + if (field.type === 'checkbox_group') { + fieldSchema = fieldSchema.refine( + (value) => value === '' || value === undefined || value.every((choice: string) => choiceValues.includes(choice)), + `${fieldLabel} contains an invalid option`, + ); + } else if (field.type === 'radio' || field.type === 'select') { + fieldSchema = fieldSchema.refine( + (value) => value === '' || value === undefined || choiceValues.includes(value), + `${fieldLabel} must be a configured option`, + ); + } + } + if (field.name) { schema[field.name] = fieldSchema; }