From b56fee1b6963f03d2b1870314343a18c8ee530a4 Mon Sep 17 00:00:00 2001 From: Lindsey Zylstra Date: Fri, 12 Jun 2026 13:26:13 -0700 Subject: [PATCH 1/9] =?UTF-8?q?feat(v12):=20cms/nextjs=20=E2=80=94=20SDK?= =?UTF-8?q?=2022=20and=20versioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump Directus SDK packages, handle published/draft version keys, harden form submission, and skip circular redirects. --- cms/nextjs/.env.example | 12 ++- cms/nextjs/README.md | 10 +- cms/nextjs/package.json | 6 +- cms/nextjs/pnpm-lock.yaml | 53 +++++++---- cms/nextjs/src/app/[[...permalink]]/page.tsx | 4 +- cms/nextjs/src/app/api/draft/route.ts | 6 +- cms/nextjs/src/app/api/forms/submit/route.ts | 40 ++++++++ cms/nextjs/src/app/blog/[slug]/page.tsx | 4 +- .../src/components/forms/FormBuilder.tsx | 31 ++++++- cms/nextjs/src/lib/directus/directus-utils.ts | 12 +++ cms/nextjs/src/lib/directus/fetchers.ts | 3 +- cms/nextjs/src/lib/directus/forms.ts | 2 +- .../lib/directus/validateFormSubmission.ts | 92 +++++++++++++++++++ cms/nextjs/src/lib/redirects.ts | 1 + 14 files changed, 241 insertions(+), 35 deletions(-) create mode 100644 cms/nextjs/src/app/api/forms/submit/route.ts create mode 100644 cms/nextjs/src/lib/directus/validateFormSubmission.ts 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..f2d5f0f9 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) @@ -78,7 +86,7 @@ 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. - **`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. diff --git a/cms/nextjs/package.json b/cms/nextjs/package.json index 2a3bc0ff..4afadd32 100644 --- a/cms/nextjs/package.json +++ b/cms/nextjs/package.json @@ -13,8 +13,8 @@ "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 +52,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", 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]]/page.tsx b/cms/nextjs/src/app/[[...permalink]]/page.tsx index 7357d74f..4d579cc5 100644 --- a/cms/nextjs/src/app/[[...permalink]]/page.tsx +++ b/cms/nextjs/src/app/[[...permalink]]/page.tsx @@ -64,8 +64,8 @@ export default async function Page({ const version = typeof searchParamsResolved.version === 'string' ? searchParamsResolved.version : ''; const preview = searchParamsResolved.preview === 'true'; const token = preview ? process.env.DIRECTUS_SERVER_TOKEN : undefined; - // Live preview adds version = main which is not required when fetching the main version. - const fixedVersion = version !== 'main' ? version : undefined; + // Live preview adds version=published ('main' before Directus 12) which is not required when fetching the published version. + const fixedVersion = version !== 'main' && version !== 'published' ? version : undefined; try { let page: Page; diff --git a/cms/nextjs/src/app/api/draft/route.ts b/cms/nextjs/src/app/api/draft/route.ts index ac73e59d..79f32789 100644 --- a/cms/nextjs/src/app/api/draft/route.ts +++ b/cms/nextjs/src/app/api/draft/route.ts @@ -4,6 +4,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const slug = searchParams.get('slug'); const token = searchParams.get('token'); + const version = searchParams.get('version'); if (!token || token !== process.env.DIRECTUS_SERVER_TOKEN) { return new Response('Invalid token', { status: 401 }); @@ -15,10 +16,13 @@ export async function GET(request: Request) { (await draftMode()).enable(); + // Forward the requested content version (e.g. draft) so the page fetches it. + const location = `/blog/${slug}?preview=true${version ? `&version=${encodeURIComponent(version)}` : ''}`; + return new Response(null, { status: 307, headers: { - Location: `/blog/${slug}?preview=true`, + Location: location, }, }); } diff --git a/cms/nextjs/src/app/api/forms/submit/route.ts b/cms/nextjs/src/app/api/forms/submit/route.ts new file mode 100644 index 00000000..7a9b17ad --- /dev/null +++ b/cms/nextjs/src/app/api/forms/submit/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; +import { submitForm } from '@/lib/directus/forms'; +import { parseFormFieldsJson, validateFormSubmission } from '@/lib/directus/validateFormSubmission'; +export async function POST(request: Request) { + const formData = await request.formData(); + const formId = formData.get('formId'); + + if (typeof formId !== 'string' || !formId.trim()) { + return NextResponse.json({ error: 'Missing or invalid formId' }, { status: 400 }); + } + + const fieldsRaw = formData.get('fields'); + if (typeof fieldsRaw !== 'string') { + return NextResponse.json({ error: 'Missing or invalid fields' }, { status: 400 }); + } + + const parsedFields = parseFormFieldsJson(fieldsRaw); + if ('error' in parsedFields) { + return NextResponse.json({ error: parsedFields.error }, { status: 400 }); + } + + const validation = validateFormSubmission(parsedFields, formData); + if (!validation.success) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const fieldsForSubmit = parsedFields.map((field) => ({ + id: field.id, + name: field.name || '', + type: field.type || '', + })); + + try { + await submitForm(formId.trim(), fieldsForSubmit, validation.data); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error submitting form:', error); + return NextResponse.json({ error: 'Failed to submit form' }, { status: 500 }); + } +} diff --git a/cms/nextjs/src/app/blog/[slug]/page.tsx b/cms/nextjs/src/app/blog/[slug]/page.tsx index 34ebf102..5cad5509 100644 --- a/cms/nextjs/src/app/blog/[slug]/page.tsx +++ b/cms/nextjs/src/app/blog/[slug]/page.tsx @@ -14,8 +14,8 @@ export default async function BlogPostPage({ const token = preview === 'true' ? process.env.DIRECTUS_SERVER_TOKEN : undefined; const isDraft = preview === 'true' || (!!version && version !== 'published'); - // Live preview adds version = main which is not required when fetching the main version. - const fixedVersion = version !== 'main' ? version : undefined; + // Live preview adds version=published ('main' before Directus 12) which is not required when fetching the published version. + const fixedVersion = version !== 'main' && version !== 'published' ? version : undefined; try { let postId = id; let post: Post | null; diff --git a/cms/nextjs/src/components/forms/FormBuilder.tsx b/cms/nextjs/src/components/forms/FormBuilder.tsx index 9a69c504..a06895df 100644 --- a/cms/nextjs/src/components/forms/FormBuilder.tsx +++ b/cms/nextjs/src/components/forms/FormBuilder.tsx @@ -3,7 +3,6 @@ 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'; @@ -32,13 +31,37 @@ const FormBuilder = ({ form, className }: FormBuilderProps) => { const handleSubmit = async (data: Record) => { setError(null); try { - const fieldsWithNames = form.fields.map((field) => ({ + const fieldsPayload = form.fields.map((field) => ({ id: field.id, name: field.name || '', type: field.type || '', + label: field.label, + required: field.required, + validation: field.validation, })); - await submitForm(form.id, fieldsWithNames, data); + const formData = new FormData(); + formData.append('formId', form.id); + formData.append('fields', JSON.stringify(fieldsPayload)); + + for (const field of fieldsPayload) { + 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 +70,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.'); } }; 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.ts b/cms/nextjs/src/lib/directus/validateFormSubmission.ts new file mode 100644 index 00000000..be9b3e4b --- /dev/null +++ b/cms/nextjs/src/lib/directus/validateFormSubmission.ts @@ -0,0 +1,92 @@ +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 type FormFieldPayload = Pick; + +export function parseFormFieldsJson(raw: string): FormFieldPayload[] | { error: string } { + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return { error: 'fields must be an array' }; + } + for (const field of parsed) { + if ( + typeof field !== 'object' || + field === null || + typeof field.id !== 'string' || + typeof field.name !== 'string' || + typeof field.type !== 'string' + ) { + return { error: 'Each field must include id, name, and type' }; + } + } + return parsed as FormFieldPayload[]; + } catch { + return { error: 'Invalid fields JSON' }; + } +} + +function parseFieldValue(field: FormFieldPayload, 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: FormFieldPayload[], + 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 ${MAX_FORM_FILE_BYTES / (1024 * 1024)} 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 as FormField[]); + 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/redirects.ts b/cms/nextjs/src/lib/redirects.ts index a751f4c6..ee6dc3b1 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) => ({ From 5b10d1e1f0f1c0e166a6c98fae11ca6691702bc1 Mon Sep 17 00:00:00 2001 From: Lindsey Zylstra Date: Fri, 12 Jun 2026 16:28:41 -0700 Subject: [PATCH 2/9] Cleanup --- cms/nextjs/src/app/[[...permalink]]/page.tsx | 4 ++-- cms/nextjs/src/app/blog/[slug]/page.tsx | 4 ++-- cms/nextjs/src/lib/redirects.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cms/nextjs/src/app/[[...permalink]]/page.tsx b/cms/nextjs/src/app/[[...permalink]]/page.tsx index 4d579cc5..402b29ad 100644 --- a/cms/nextjs/src/app/[[...permalink]]/page.tsx +++ b/cms/nextjs/src/app/[[...permalink]]/page.tsx @@ -64,8 +64,8 @@ export default async function Page({ const version = typeof searchParamsResolved.version === 'string' ? searchParamsResolved.version : ''; const preview = searchParamsResolved.preview === 'true'; const token = preview ? process.env.DIRECTUS_SERVER_TOKEN : undefined; - // Live preview adds version=published ('main' before Directus 12) which is not required when fetching the published version. - const fixedVersion = version !== 'main' && version !== 'published' ? version : undefined; + // Live preview adds version=published which is not required when fetching the published version. + const fixedVersion = version !== 'published' ? version : undefined; try { let page: Page; diff --git a/cms/nextjs/src/app/blog/[slug]/page.tsx b/cms/nextjs/src/app/blog/[slug]/page.tsx index 5cad5509..b76cb401 100644 --- a/cms/nextjs/src/app/blog/[slug]/page.tsx +++ b/cms/nextjs/src/app/blog/[slug]/page.tsx @@ -14,8 +14,8 @@ export default async function BlogPostPage({ const token = preview === 'true' ? process.env.DIRECTUS_SERVER_TOKEN : undefined; const isDraft = preview === 'true' || (!!version && version !== 'published'); - // Live preview adds version=published ('main' before Directus 12) which is not required when fetching the published version. - const fixedVersion = version !== 'main' && version !== 'published' ? version : undefined; + // Live preview adds version=published which is not required when fetching the published version. + const fixedVersion = version !== 'published' ? version : undefined; try { let postId = id; let post: Post | null; diff --git a/cms/nextjs/src/lib/redirects.ts b/cms/nextjs/src/lib/redirects.ts index ee6dc3b1..684c8fce 100644 --- a/cms/nextjs/src/lib/redirects.ts +++ b/cms/nextjs/src/lib/redirects.ts @@ -37,7 +37,7 @@ export async function generateRedirects(): Promise { } else { console.error('Error generating redirects:', error); } - -return []; + + return []; } } From 81ecf0ec6e5adc25208a5014dd837830500d0f7a Mon Sep 17 00:00:00 2001 From: Lindsey Zylstra Date: Tue, 16 Jun 2026 11:53:41 -0700 Subject: [PATCH 3/9] Updates to version and token handling --- cms/nextjs/README.md | 4 +++- cms/nextjs/src/app/[[...permalink]]/page.tsx | 5 +++-- cms/nextjs/src/app/blog/[slug]/page.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/cms/nextjs/README.md b/cms/nextjs/README.md index f2d5f0f9..aa41d1d5 100644 --- a/cms/nextjs/README.md +++ b/cms/nextjs/README.md @@ -86,7 +86,9 @@ 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`**: Static token from your Directus **admin account** (created during first-launch onboarding). 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. diff --git a/cms/nextjs/src/app/[[...permalink]]/page.tsx b/cms/nextjs/src/app/[[...permalink]]/page.tsx index 402b29ad..7679dd2e 100644 --- a/cms/nextjs/src/app/[[...permalink]]/page.tsx +++ b/cms/nextjs/src/app/[[...permalink]]/page.tsx @@ -64,8 +64,9 @@ export default async function Page({ const version = typeof searchParamsResolved.version === 'string' ? searchParamsResolved.version : ''; const preview = searchParamsResolved.preview === 'true'; const token = preview ? process.env.DIRECTUS_SERVER_TOKEN : undefined; - // Live preview adds version=published which is not required when fetching the published version. - const fixedVersion = version !== 'published' ? version : undefined; + // version=published is the live key in Directus v12+; version=main was used by older Directus versions. + // Both represent published content and don't require an explicit version parameter. + const fixedVersion = version !== 'published' && version !== 'main' ? version : undefined; try { let page: Page; diff --git a/cms/nextjs/src/app/blog/[slug]/page.tsx b/cms/nextjs/src/app/blog/[slug]/page.tsx index b76cb401..ffe8da43 100644 --- a/cms/nextjs/src/app/blog/[slug]/page.tsx +++ b/cms/nextjs/src/app/blog/[slug]/page.tsx @@ -12,10 +12,10 @@ export default async function BlogPostPage({ const { slug } = await params; const { id, version, preview } = await searchParams; const token = preview === 'true' ? process.env.DIRECTUS_SERVER_TOKEN : undefined; - const isDraft = preview === 'true' || (!!version && version !== 'published'); - - // Live preview adds version=published which is not required when fetching the published version. - const fixedVersion = version !== 'published' ? version : undefined; + // version=published is the live key in Directus v12+; version=main was used by older Directus versions. + // Both represent published content and don't require an explicit version parameter. + const isDraft = preview === 'true' || (!!version && version !== 'published' && version !== 'main'); + const fixedVersion = version !== 'published' && version !== 'main' ? version : undefined; try { let postId = id; let post: Post | null; From 247fcce119d82bb01a694dab4f0a42b190924859 Mon Sep 17 00:00:00 2001 From: Lindsey Zylstra Date: Tue, 16 Jun 2026 11:58:49 -0700 Subject: [PATCH 4/9] Form validation updates --- cms/nextjs/src/app/api/forms/submit/route.ts | 35 ++++++++++++----- .../src/components/forms/FormBuilder.tsx | 13 +------ .../lib/directus/validateFormSubmission.ts | 38 ++++--------------- 3 files changed, 36 insertions(+), 50 deletions(-) diff --git a/cms/nextjs/src/app/api/forms/submit/route.ts b/cms/nextjs/src/app/api/forms/submit/route.ts index 7a9b17ad..177569ca 100644 --- a/cms/nextjs/src/app/api/forms/submit/route.ts +++ b/cms/nextjs/src/app/api/forms/submit/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import { submitForm } from '@/lib/directus/forms'; -import { parseFormFieldsJson, validateFormSubmission } from '@/lib/directus/validateFormSubmission'; +import { 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 formId = formData.get('formId'); @@ -9,22 +12,36 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Missing or invalid formId' }, { status: 400 }); } - const fieldsRaw = formData.get('fields'); - if (typeof fieldsRaw !== 'string') { - return NextResponse.json({ error: 'Missing or invalid fields' }, { status: 400 }); + const TOKEN = process.env.DIRECTUS_SERVER_TOKEN; + if (!TOKEN) { + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); } - const parsedFields = parseFormFieldsJson(fieldsRaw); - if ('error' in parsedFields) { - return NextResponse.json({ error: parsedFields.error }, { status: 400 }); + // 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(); + let fields: FormField[]; + try { + const form = await directus.request( + withToken( + TOKEN, + readItem('forms', formId.trim(), { + fields: [{ fields: ['id', 'name', 'type', 'label', 'required', 'validation'] }], + } as any), + ), + ); + fields = ((form as any).fields as FormField[]) || []; + } catch { + return NextResponse.json({ error: 'Form not found' }, { status: 404 }); } - const validation = validateFormSubmission(parsedFields, formData); + const validation = validateFormSubmission(fields, formData); if (!validation.success) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const fieldsForSubmit = parsedFields.map((field) => ({ + const fieldsForSubmit = fields.map((field) => ({ id: field.id, name: field.name || '', type: field.type || '', diff --git a/cms/nextjs/src/components/forms/FormBuilder.tsx b/cms/nextjs/src/components/forms/FormBuilder.tsx index a06895df..dd891cde 100644 --- a/cms/nextjs/src/components/forms/FormBuilder.tsx +++ b/cms/nextjs/src/components/forms/FormBuilder.tsx @@ -31,20 +31,11 @@ const FormBuilder = ({ form, className }: FormBuilderProps) => { const handleSubmit = async (data: Record) => { setError(null); try { - const fieldsPayload = form.fields.map((field) => ({ - id: field.id, - name: field.name || '', - type: field.type || '', - label: field.label, - required: field.required, - validation: field.validation, - })); - const formData = new FormData(); formData.append('formId', form.id); - formData.append('fields', JSON.stringify(fieldsPayload)); - for (const field of fieldsPayload) { + for (const field of form.fields) { + if (!field.name) continue; const value = data[field.name]; if (value === undefined || value === null) continue; diff --git a/cms/nextjs/src/lib/directus/validateFormSubmission.ts b/cms/nextjs/src/lib/directus/validateFormSubmission.ts index be9b3e4b..9cb33fe7 100644 --- a/cms/nextjs/src/lib/directus/validateFormSubmission.ts +++ b/cms/nextjs/src/lib/directus/validateFormSubmission.ts @@ -4,32 +4,7 @@ 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 type FormFieldPayload = Pick; - -export function parseFormFieldsJson(raw: string): FormFieldPayload[] | { error: string } { - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return { error: 'fields must be an array' }; - } - for (const field of parsed) { - if ( - typeof field !== 'object' || - field === null || - typeof field.id !== 'string' || - typeof field.name !== 'string' || - typeof field.type !== 'string' - ) { - return { error: 'Each field must include id, name, and type' }; - } - } - return parsed as FormFieldPayload[]; - } catch { - return { error: 'Invalid fields JSON' }; - } -} - -function parseFieldValue(field: FormFieldPayload, raw: FormDataEntryValue): unknown { +function parseFieldValue(field: FormField, raw: FormDataEntryValue): unknown { if (field.type === 'file') { return raw instanceof File ? raw : undefined; } @@ -42,16 +17,18 @@ function parseFieldValue(field: FormFieldPayload, raw: FormDataEntryValue): unkn if (field.type === 'checkbox_group') { try { const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; } catch { return []; } } - return raw; + +return raw; } export function validateFormSubmission( - fields: FormFieldPayload[], + fields: FormField[], formData: FormData, ): { success: true; data: Record } | { success: false; error: string } { const data: Record = {}; @@ -80,12 +57,13 @@ export function validateFormSubmission( } } - const schema = buildZodSchema(fields as FormField[]); + 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: false, error: first?.message || 'Validation failed' }; } return { success: true, data: result.data as Record }; From 6c76e422c9dca9aa6fee7ac6c451e0eb8952a751 Mon Sep 17 00:00:00 2001 From: Lindsey Zylstra Date: Tue, 16 Jun 2026 16:20:18 -0700 Subject: [PATCH 5/9] Fix VE in live preview --- cms/nextjs/src/components/forms/DynamicForm.tsx | 4 +--- cms/nextjs/src/hooks/useVisualEditing.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cms/nextjs/src/components/forms/DynamicForm.tsx b/cms/nextjs/src/components/forms/DynamicForm.tsx index 8c0a3f0a..b1bd3f8c 100644 --- a/cms/nextjs/src/components/forms/DynamicForm.tsx +++ b/cms/nextjs/src/components/forms/DynamicForm.tsx @@ -56,9 +56,7 @@ const DynamicForm = ({ fields, onSubmit, submitLabel, id }: DynamicFormProps) => })} > {sortedFields.map((field) => ( -
- -
+ ))}
Date: Wed, 17 Jun 2026 12:53:02 -0700 Subject: [PATCH 6/9] Fix VE draft pages --- .../src/app/[[...permalink]]/PageClient.tsx | 9 +- cms/nextjs/src/app/[[...permalink]]/page.tsx | 2 +- .../src/app/blog/[slug]/BlogPostClient.tsx | 2 +- cms/nextjs/src/components/blocks/Form.tsx | 24 +++-- cms/nextjs/src/components/blocks/Gallery.tsx | 20 ++-- cms/nextjs/src/components/blocks/Hero.tsx | 48 ++++++---- cms/nextjs/src/components/blocks/Posts.tsx | 20 ++-- cms/nextjs/src/components/blocks/Pricing.tsx | 20 ++-- .../src/components/blocks/PricingCard.tsx | 83 +++++++---------- cms/nextjs/src/components/blocks/RichText.tsx | 20 ++-- .../src/components/forms/DynamicForm.tsx | 50 +++++++--- .../src/components/forms/FormBuilder.tsx | 6 +- .../src/components/layout/NavigationBar.tsx | 2 +- cms/nextjs/src/hooks/useVisualEditing.ts | 3 +- cms/nextjs/src/lib/directus/visualEditing.ts | 93 +++++++++++++++++++ 15 files changed, 258 insertions(+), 144 deletions(-) create mode 100644 cms/nextjs/src/lib/directus/visualEditing.ts 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 dd891cde..59b95183 100644 --- a/cms/nextjs/src/components/forms/FormBuilder.tsx +++ b/cms/nextjs/src/components/forms/FormBuilder.tsx @@ -8,7 +8,8 @@ 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; @@ -22,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); @@ -89,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 28dd13ff..4ce291fe 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 } from '@/lib/directus/visualEditing'; interface ApplyOptions { elements?: HTMLElement[] | HTMLElement; diff --git a/cms/nextjs/src/lib/directus/visualEditing.ts b/cms/nextjs/src/lib/directus/visualEditing.ts new file mode 100644 index 00000000..6d8f46dd --- /dev/null +++ b/cms/nextjs/src/lib/directus/visualEditing.ts @@ -0,0 +1,93 @@ +'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 = {}; + +/** 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; +} + +function isVisualEditingActive() { + if (typeof window === 'undefined') return false; + return localStorage.getItem('visual-editing') === 'true'; +} + +export const setAttr = (options: ApplyOptions) => { + if (isVisualEditingActive()) { + 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, + }); +}; From eb76bb8c4e831a04c071a8b695005a82e368cbbc Mon Sep 17 00:00:00 2001 From: Lindsey Zylstra Date: Wed, 17 Jun 2026 14:36:55 -0700 Subject: [PATCH 7/9] Fix formatting --- cms/nextjs/src/app/api/forms/submit/route.ts | 2 ++ cms/nextjs/src/lib/directus/visualEditing.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cms/nextjs/src/app/api/forms/submit/route.ts b/cms/nextjs/src/app/api/forms/submit/route.ts index 177569ca..121d63b2 100644 --- a/cms/nextjs/src/app/api/forms/submit/route.ts +++ b/cms/nextjs/src/app/api/forms/submit/route.ts @@ -49,9 +49,11 @@ export async function POST(request: Request) { try { await submitForm(formId.trim(), fieldsForSubmit, validation.data); + return NextResponse.json({ success: true }); } catch (error) { console.error('Error submitting form:', error); + return NextResponse.json({ error: 'Failed to submit form' }, { status: 500 }); } } diff --git a/cms/nextjs/src/lib/directus/visualEditing.ts b/cms/nextjs/src/lib/directus/visualEditing.ts index 6d8f46dd..422ea2b5 100644 --- a/cms/nextjs/src/lib/directus/visualEditing.ts +++ b/cms/nextjs/src/lib/directus/visualEditing.ts @@ -38,7 +38,10 @@ export function getIsDraftPreview(): boolean { } function isVisualEditingActive() { - if (typeof window === 'undefined') return false; + if (typeof window === 'undefined') { + return false; + } + return localStorage.getItem('visual-editing') === 'true'; } @@ -54,10 +57,13 @@ function toPageBlockFields( fields: string | string[], pageFields?: string | string[], ): string | string[] { - if (pageFields) return pageFields; + 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; } From c1d5b5230fb8dff33a90bbdc217970b771238400 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Wed, 1 Jul 2026 23:52:45 -0400 Subject: [PATCH 8/9] Fix Next.js preview editing and form submit --- cms/nextjs/src/app/api/forms/submit/route.ts | 5 ++++- cms/nextjs/src/lib/directus/validateFormSubmission.ts | 8 ++++---- cms/nextjs/src/lib/directus/visualEditing.ts | 8 +++++++- cms/nextjs/src/lib/redirects.ts | 4 +++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/cms/nextjs/src/app/api/forms/submit/route.ts b/cms/nextjs/src/app/api/forms/submit/route.ts index 121d63b2..8a233e1a 100644 --- a/cms/nextjs/src/app/api/forms/submit/route.ts +++ b/cms/nextjs/src/app/api/forms/submit/route.ts @@ -27,10 +27,13 @@ export async function POST(request: Request) { withToken( TOKEN, readItem('forms', formId.trim(), { - fields: [{ fields: ['id', 'name', 'type', 'label', 'required', 'validation'] }], + fields: ['id', 'is_active', { fields: ['id', 'name', 'type', 'label', 'required', 'validation'] }], } as any), ), ); + if (!(form as any).is_active) { + return NextResponse.json({ error: 'Form not found' }, { status: 404 }); + } fields = ((form as any).fields as FormField[]) || []; } catch { return NextResponse.json({ error: 'Form not found' }, { status: 404 }); diff --git a/cms/nextjs/src/lib/directus/validateFormSubmission.ts b/cms/nextjs/src/lib/directus/validateFormSubmission.ts index 9cb33fe7..05abe7a3 100644 --- a/cms/nextjs/src/lib/directus/validateFormSubmission.ts +++ b/cms/nextjs/src/lib/directus/validateFormSubmission.ts @@ -23,8 +23,8 @@ function parseFieldValue(field: FormField, raw: FormDataEntryValue): unknown { return []; } } - -return raw; + + return raw; } export function validateFormSubmission( @@ -62,8 +62,8 @@ export function validateFormSubmission( if (!result.success) { const first = result.error.issues[0]; - -return { success: false, error: first?.message || 'Validation failed' }; + + 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 index 422ea2b5..fcfceafe 100644 --- a/cms/nextjs/src/lib/directus/visualEditing.ts +++ b/cms/nextjs/src/lib/directus/visualEditing.ts @@ -42,7 +42,13 @@ function isVisualEditingActive() { return false; } - return localStorage.getItem('visual-editing') === 'true'; + const params = new URLSearchParams(window.location.search); + + return ( + params.get('visual-editing') === 'true' || + params.get('preview') === 'true' || + localStorage.getItem('visual-editing') === 'true' + ); } export const setAttr = (options: ApplyOptions) => { diff --git a/cms/nextjs/src/lib/redirects.ts b/cms/nextjs/src/lib/redirects.ts index 684c8fce..885a279b 100644 --- a/cms/nextjs/src/lib/redirects.ts +++ b/cms/nextjs/src/lib/redirects.ts @@ -33,7 +33,9 @@ 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); } From ca684092a0148c04cf8448d82f715dbe31065f53 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Sun, 12 Jul 2026 11:59:47 -0400 Subject: [PATCH 9/9] fix(nextjs): harden forms and visual editing --- cms-i18n/nextjs/README.md | 3 + cms-i18n/nextjs/package.json | 1 + .../nextjs/src/app/api/forms/submit/route.ts | 21 ++-- .../directus/validateFormSubmission.test.ts | 103 ++++++++++++++++++ .../lib/directus/validateFormSubmission.ts | 39 ++++++- cms-i18n/nextjs/src/lib/zodSchemaBuilder.ts | 24 +++- cms/nextjs/README.md | 5 +- cms/nextjs/package.json | 4 + cms/nextjs/src/app/api/forms/submit/route.ts | 31 +++--- cms/nextjs/src/hooks/useVisualEditing.ts | 32 +++++- .../directus/validateFormSubmission.test.ts | 103 ++++++++++++++++++ .../lib/directus/validateFormSubmission.ts | 39 ++++++- cms/nextjs/src/lib/directus/visualEditing.ts | 17 +-- cms/nextjs/src/lib/zodSchemaBuilder.ts | 24 +++- 14 files changed, 398 insertions(+), 48 deletions(-) create mode 100644 cms-i18n/nextjs/src/lib/directus/validateFormSubmission.test.ts create mode 100644 cms/nextjs/src/lib/directus/validateFormSubmission.test.ts 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/README.md b/cms/nextjs/README.md index aa41d1d5..81efbd90 100644 --- a/cms/nextjs/README.md +++ b/cms/nextjs/README.md @@ -54,7 +54,7 @@ API. 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)) @@ -93,6 +93,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/nextjs/package.json b/cms/nextjs/package.json index 4afadd32..fffd22b2 100644 --- a/cms/nextjs/package.json +++ b/cms/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}\"", @@ -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/src/app/api/forms/submit/route.ts b/cms/nextjs/src/app/api/forms/submit/route.ts index 8a233e1a..715614c9 100644 --- a/cms/nextjs/src/app/api/forms/submit/route.ts +++ b/cms/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,23 +25,23 @@ 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: ['id', '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) { - return NextResponse.json({ error: 'Form not found' }, { status: 404 }); + + if (!(form as any).is_active || !Array.isArray((form as any).fields)) { + return NextResponse.json({ error: 'Missing or invalid form' }, { status: 400 }); } - fields = ((form as any).fields as FormField[]) || []; + + fields = ((form as any).fields as FormField[]).filter((field): field is FormField => typeof field !== 'string'); } catch { - return NextResponse.json({ error: 'Form not found' }, { status: 404 }); + return NextResponse.json({ error: 'Missing or invalid form' }, { status: 400 }); } const validation = validateFormSubmission(fields, formData); diff --git a/cms/nextjs/src/hooks/useVisualEditing.ts b/cms/nextjs/src/hooks/useVisualEditing.ts index 4ce291fe..548f6f3f 100644 --- a/cms/nextjs/src/hooks/useVisualEditing.ts +++ b/cms/nextjs/src/hooks/useVisualEditing.ts @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, usePathname } from 'next/navigation'; import { apply as applyVisualEditing } from '@directus/visual-editing'; -import { setAttr } from '@/lib/directus/visualEditing'; +import { setAttr, setVisualEditingAttrsEnabled } from '@/lib/directus/visualEditing'; interface ApplyOptions { elements?: HTMLElement[] | HTMLElement; @@ -20,6 +20,26 @@ 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; @@ -29,6 +49,9 @@ export function useVisualEditing() { 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.'); } @@ -37,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'); @@ -48,8 +71,9 @@ export function useVisualEditing() { window.history.replaceState({}, '', cleanUrl); } - const persisted = localStorage.getItem('visual-editing') === 'true'; + const persisted = readPersistedVisualEditing(); const shouldEnable = persisted || isPreview; + setVisualEditingAttrsEnabled(shouldEnable); setIsVisualEditingEnabled(shouldEnable); if (shouldEnable && param !== 'true') { 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 index 05abe7a3..64341557 100644 --- a/cms/nextjs/src/lib/directus/validateFormSubmission.ts +++ b/cms/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/nextjs/src/lib/directus/visualEditing.ts b/cms/nextjs/src/lib/directus/visualEditing.ts index fcfceafe..e2b62488 100644 --- a/cms/nextjs/src/lib/directus/visualEditing.ts +++ b/cms/nextjs/src/lib/directus/visualEditing.ts @@ -27,6 +27,7 @@ type PageVisualEditingContext = { }; let pageContext: PageVisualEditingContext = {}; +let visualEditingAttrsEnabled = false; /** Set from PageClient so setBlockAttr() can route through the versioned pages item. */ export function setVisualEditingPageContext(ctx: PageVisualEditingContext) { @@ -37,22 +38,12 @@ export function getIsDraftPreview(): boolean { return !!pageContext.contentVersion; } -function isVisualEditingActive() { - if (typeof window === 'undefined') { - return false; - } - - const params = new URLSearchParams(window.location.search); - - return ( - params.get('visual-editing') === 'true' || - params.get('preview') === 'true' || - localStorage.getItem('visual-editing') === 'true' - ); +export function setVisualEditingAttrsEnabled(enabled: boolean) { + visualEditingAttrsEnabled = enabled; } export const setAttr = (options: ApplyOptions) => { - if (isVisualEditingActive()) { + if (visualEditingAttrsEnabled) { return baseSetAttr({ ...options }); } }; 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; }