Compare commits

...
8 Commits
34 changed files with 505 additions and 41 deletions
+1
View File
@@ -1,3 +1,4 @@
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
export { Analytics } from '../modules/analytics/client.js'; export { Analytics } from '../modules/analytics/client.js';
/** /**
* Entry point: ipal-kit/client * Entry point: ipal-kit/client
+13
View File
@@ -0,0 +1,13 @@
import type { Field } from 'payload';
/**
* Fields for the Notifications global — localized user-facing texts for action
* results (form submission outcomes, and future contexts). Every text is
* localized: true so each language has its own value. Empty fields fall back to
* built-in English defaults (see modules/notifications/defaults).
*
* Grouped per context. `form` holds the outcomes of submitForm; more groups
* (e.g. `newsletter`, `system`) can be added the same way without touching
* consumers — getNotificationTexts resolves whatever exists, falling back
* per field.
*/
export declare const notificationsFields: Field[];
+7
View File
@@ -0,0 +1,7 @@
import type { GlobalConfig } from 'payload';
/**
* Builds the Notifications global — localized action-result texts. Readable by
* any authenticated panel user; server-side helpers read it with overrideAccess
* so the frontend can resolve texts without a session.
*/
export declare function buildNotifications(): GlobalConfig;
@@ -0,0 +1,3 @@
import type { TextFieldClientComponent } from 'payload';
export declare const MaskedField: TextFieldClientComponent;
export default MaskedField;
+12 -1
View File
@@ -1,6 +1,11 @@
import 'server-only'; import 'server-only';
import type { BasePayload } from 'payload'; import type { BasePayload } from 'payload';
export type SubmitFormArgs = { export type SubmitFormArgs = {
/**
* Name of the GDPR consent checkbox. A field with this name must be checked
* for the submission to succeed (enforced server-side). Defaults to 'consent'.
*/
consentFieldName?: string;
/** Submitted field data — shape matches the form's fields. */ /** Submitted field data — shape matches the form's fields. */
data: Record<string, unknown>; data: Record<string, unknown>;
/** Form-builder form ID this submission belongs to. */ /** Form-builder form ID this submission belongs to. */
@@ -30,6 +35,7 @@ export type SubmitFormArgs = {
* `field` and `kind` narrow it down when a specific field is * `field` and `kind` narrow it down when a specific field is
* at fault (absent for whole-payload problems like unknown keys) * at fault (absent for whole-payload problems like unknown keys)
* - `not_found` — no form with this id * - `not_found` — no form with this id
* - `consent` — a required GDPR consent checkbox was left unchecked
* - `error` — persistence failed unexpectedly * - `error` — persistence failed unexpectedly
*/ */
export type SubmitFailure = { export type SubmitFailure = {
@@ -39,6 +45,11 @@ export type SubmitFailure = {
kind?: 'required' | 'too_long' | 'unknown_fields'; kind?: 'required' | 'too_long' | 'unknown_fields';
reason: 'validation'; reason: 'validation';
success: false; success: false;
} | {
field?: string;
/** A GDPR consent field existed on the form but wasn't checked. */
reason: 'consent';
success: false;
} | { } | {
reason: 'error'; reason: 'error';
success: false; success: false;
@@ -69,4 +80,4 @@ export type SubmitFormResult = {
* *
* server-only: touches the Turnstile secret. * server-only: touches the Turnstile secret.
*/ */
export declare function submitForm({ data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise<SubmitFormResult>; export declare function submitForm({ consentFieldName, data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise<SubmitFormResult>;
+6
View File
@@ -18,6 +18,12 @@ export type FormsCollectionOverrides = {
* form-builder plugin. Kept minimal; the plugin passes these through. * form-builder plugin. Kept minimal; the plugin passes these through.
*/ */
export type FormsOption = { export type FormsOption = {
/**
* Name of the checkbox field treated as a GDPR consent gate. A form field
* with this name must be checked for submission to succeed — enforced
* server-side in submitForm. Defaults to 'consent'.
*/
consentFieldName?: string;
/** Field types available in the form builder. Sensible defaults applied. */ /** Field types available in the form builder. Sensible defaults applied. */
fields?: { fields?: {
checkbox?: boolean; checkbox?: boolean;
+5 -1
View File
@@ -27,6 +27,10 @@ export type FormValidationResult = {
cleaned: Record<string, unknown>; cleaned: Record<string, unknown>;
form: FormDoc; form: FormDoc;
ok: true; ok: true;
} | {
field: string;
ok: false;
reason: 'consent';
} | { } | {
ok: false; ok: false;
reason: 'not_found'; reason: 'not_found';
@@ -44,5 +48,5 @@ export type FormValidationResult = {
* Returns the loaded form on success so the caller doesn't fetch it twice, and * Returns the loaded form on success so the caller doesn't fetch it twice, and
* a code + offending field on failure so the frontend can point at it. * a code + offending field on failure so the frontend can point at it.
*/ */
export declare function validateSubmission(payload: BasePayload, formId: string, data: Record<string, unknown>): Promise<FormValidationResult>; export declare function validateSubmission(payload: BasePayload, formId: string, data: Record<string, unknown>, consentFieldName?: string): Promise<FormValidationResult>;
export {}; export {};
+14 -2
View File
@@ -1,6 +1,16 @@
/** Field block types that don't carry a submittable value. */ const NON_DATA_BLOCKS = new Set([ /** Field block types that don't carry a submittable value. */ const NON_DATA_BLOCKS = new Set([
'message' 'message'
]); ]);
/**
* Keys injected by the captcha widget itself, not by the form definition.
* Cloudflare Turnstile adds a hidden <input name="cf-turnstile-response"> after
* a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the
* plugin drives Turnstile end-to-end, these are legitimate artifacts — they
* must not count as "unknown fields" and trip the anti-tampering check.
*/ const CAPTCHA_KEYS = new Set([
'cf-turnstile-response',
'g-recaptcha-response'
]);
/** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000; /** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000;
/** /**
* Checks submitted data against the form's own definition, rather than trusting * Checks submitted data against the form's own definition, rather than trusting
@@ -60,8 +70,10 @@
} }
} }
// Reject outright if the payload carried keys the form doesn't define — a // Reject outright if the payload carried keys the form doesn't define — a
// sign the request wasn't produced by the rendered form. // sign the request wasn't produced by the rendered form. Captcha keys are
const unknownKeys = Object.keys(data).filter((k)=>!known.has(k)); // exempt: the widget injects them into the rendered form, so they're expected,
// not tampering.
const unknownKeys = Object.keys(data).filter((k)=>!known.has(k) && !CAPTCHA_KEYS.has(k));
if (unknownKeys.length > 0) { if (unknownKeys.length > 0) {
return { return {
kind: 'unknown_fields', kind: 'unknown_fields',
File diff suppressed because one or more lines are too long
+8 -4
View File
@@ -32,6 +32,10 @@ export type LocaleMiddlewareResult = {
location: string; location: string;
type: 'redirect'; type: 'redirect';
} | { } | {
cookie?: {
name: string;
value: string;
};
type: 'next'; type: 'next';
}; };
type CreateLocaleMiddlewareArgs = { type CreateLocaleMiddlewareArgs = {
@@ -68,10 +72,10 @@ type CreateLocaleMiddlewareArgs = {
* import { localeMiddleware } from './ipal.middleware' // created from this factory * import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function proxy(req) { * export function proxy(req) {
* const r = localeMiddleware(req) * const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next() * // Both results may carry an optional cookie — 'next' when the visitor
* const res = NextResponse.redirect(r.location) * // switched language (URL locale differs from the stored one) and consented,
* // cookie is optional: only present when the visitor consented to the * // 'redirect' on the initial locale negotiation. Set it whenever present.
* // gating category (functional by default). Guard before setting. * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res * return res
* } * }
+32 -12
View File
@@ -25,18 +25,43 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
* import { localeMiddleware } from './ipal.middleware' // created from this factory * import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function proxy(req) { * export function proxy(req) {
* const r = localeMiddleware(req) * const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next() * // Both results may carry an optional cookie — 'next' when the visitor
* const res = NextResponse.redirect(r.location) * // switched language (URL locale differs from the stored one) and consented,
* // cookie is optional: only present when the visitor consented to the * // 'redirect' on the initial locale negotiation. Set it whenever present.
* // gating category (functional by default). Guard before setting. * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res * return res
* } * }
*/ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) { */ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) {
// Whether the locale cookie may be written: 'necessary' is always granted;
// 'functional' (default) requires the visitor to have consented.
function mayPersistLocale(request) {
if (consentCategory === 'necessary') {
return true;
}
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
return consent?.[consentCategory] === true;
}
return function localeMiddleware(request) { return function localeMiddleware(request) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Already locale-prefixed → nothing to do // Already locale-prefixed (e.g. the visitor switched language by
if (isValidLocale(firstSegment(pathname), config)) { // navigating to /en). Routing is fine — but if the URL's locale differs
// from the stored cookie, the visitor is *choosing* a language, and we
// should remember it — provided they consented to the gating category.
// Without consent we leave the cookie untouched: the switch works for this
// visit but isn't persisted, which is exactly the functional-cookie rule.
const urlLocale = firstSegment(pathname);
if (isValidLocale(urlLocale, config)) {
const currentCookie = request.cookies.get(cookieName)?.value ?? null;
if (currentCookie !== urlLocale && mayPersistLocale(request)) {
return {
type: 'next',
cookie: {
name: cookieName,
value: urlLocale
}
};
}
return { return {
type: 'next' type: 'next'
}; };
@@ -57,15 +82,10 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
// Without consent the locale is still detected each request (routing works), // Without consent the locale is still detected each request (routing works),
// it just isn't remembered across visits — which is the whole point of // it just isn't remembered across visits — which is the whole point of
// gating a functional cookie behind consent. // gating a functional cookie behind consent.
let mayPersist = consentCategory === 'necessary';
if (!mayPersist) {
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
mayPersist = consent?.[consentCategory] === true;
}
return { return {
type: 'redirect', type: 'redirect',
location: url.toString(), location: url.toString(),
...mayPersist ? { ...mayPersistLocale(request) ? {
cookie: { cookie: {
name: cookieName, name: cookieName,
value: locale value: locale
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
import type { NotificationTexts } from './types.js';
/**
* Built-in English fallbacks, used per field when the Notifications global
* leaves a text empty. Same philosophy as consent FALLBACK: the site works out
* of the box, editors override per locale as needed.
*/
export declare const NOTIFICATION_FALLBACK: NotificationTexts;
+15
View File
@@ -0,0 +1,15 @@
import type { BasePayload } from 'payload';
import type { NotificationTexts } from './types.js';
type GetNotificationTextsArgs = {
/** Active locale — selects the language variant of each text. */
locale?: string;
payload: BasePayload;
};
/**
* Resolves notification texts from the Notifications global, falling back to
* English defaults per field. Mirrors getConsentTexts: one read, per-field
* fallback, locale-aware. The frontend maps a submitForm result code to the
* matching text and styles it however it likes (toast, inline, banner).
*/
export declare function getNotificationTexts({ locale, payload, }: GetNotificationTextsArgs): Promise<NotificationTexts>;
export {};
+4
View File
@@ -0,0 +1,4 @@
export { NOTIFICATION_FALLBACK } from './defaults.js';
export { getNotificationTexts } from './getNotificationTexts.js';
export { resolveFormMessage } from './resolveFormMessage.js';
export type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js';
+13
View File
@@ -0,0 +1,13 @@
import type { SubmitFormResult } from '../forms/index.js';
import type { FormNotificationTexts } from './types.js';
/**
* Maps a submitForm result to the user-facing message, interpolating {field}
* for validation errors. This is the bridge the frontend uses: it gets a result
* code from submitForm and the resolved texts from getNotificationTexts, and
* this turns them into one string to display. Keeping the mapping here means the
* frontend never hard-codes messages or knows about result codes.
*
* Never surfaces raw backend/exception detail — 'error' maps to a friendly
* generic message, not the thrown error's text (which could leak internals).
*/
export declare function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string;
+22
View File
@@ -0,0 +1,22 @@
/**
* Resolved notification texts, ready for the frontend. Grouped per context;
* `form` maps submitForm result codes to user-facing messages.
*/
export type FormNotificationTexts = {
success: string;
error: string;
rateLimited: string;
turnstile: string;
/** May contain the {field} placeholder — resolve with resolveValidationText. */
validation: string;
/** Shown when a required GDPR consent checkbox was left unchecked. */
consent: string;
notFound: string;
};
export type NotificationTexts = {
form: FormNotificationTexts;
};
/** Raw shape read from the Notifications global (all fields optional). */
export type NotificationsData = {
form?: Partial<FormNotificationTexts>;
};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@intecion/ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.0.1", "version": "1.0.5",
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.", "description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
+1
View File
@@ -1,4 +1,5 @@
'use client' 'use client'
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'
export { Analytics } from '../modules/analytics/client.js' export { Analytics } from '../modules/analytics/client.js'
/** /**
* Entry point: ipal-kit/client * Entry point: ipal-kit/client
+75
View File
@@ -0,0 +1,75 @@
import type { Field } from 'payload'
/**
* Fields for the Notifications global — localized user-facing texts for action
* results (form submission outcomes, and future contexts). Every text is
* localized: true so each language has its own value. Empty fields fall back to
* built-in English defaults (see modules/notifications/defaults).
*
* Grouped per context. `form` holds the outcomes of submitForm; more groups
* (e.g. `newsletter`, `system`) can be added the same way without touching
* consumers — getNotificationTexts resolves whatever exists, falling back
* per field.
*/
export const notificationsFields: Field[] = [
{
name: 'form',
type: 'group',
admin: {
description:
'Messages shown after a form is submitted. Leave a field empty to use the built-in default.',
},
fields: [
{
name: 'success',
type: 'text',
admin: { placeholder: 'Thank you — your message has been sent.' },
localized: true,
},
{
name: 'error',
type: 'text',
admin: { placeholder: 'Something went wrong. Please try again later.' },
localized: true,
},
{
name: 'rateLimited',
type: 'text',
admin: { placeholder: 'Too many attempts. Please wait a moment and try again.' },
localized: true,
},
{
name: 'turnstile',
type: 'text',
admin: { placeholder: 'Captcha verification failed. Please try again.' },
localized: true,
},
{
name: 'validation',
type: 'text',
admin: {
description:
'Shown on a validation error. Use {field} to insert the offending field name.',
placeholder: 'Please check the {field} field and try again.',
},
localized: true,
},
{
name: 'consent',
type: 'text',
admin: {
description: 'Shown when the GDPR consent checkbox is left unchecked.',
placeholder: 'Please accept the privacy policy to continue.',
},
localized: true,
},
{
name: 'notFound',
type: 'text',
admin: { placeholder: 'This form is no longer available.' },
localized: true,
},
],
label: 'Form messages',
},
]
+18
View File
@@ -0,0 +1,18 @@
import type { GlobalConfig } from 'payload'
import { notificationsFields } from './fields.js'
/**
* Builds the Notifications global — localized action-result texts. Readable by
* any authenticated panel user; server-side helpers read it with overrideAccess
* so the frontend can resolve texts without a session.
*/
export function buildNotifications(): GlobalConfig {
return {
slug: 'notifications',
label: 'Notifications',
access: {
read: () => true, // texts are public-facing (shown to end users)
},
fields: notificationsFields,
}
}
@@ -0,0 +1,30 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import { useField } from '@payloadcms/ui'
import { useState } from 'react'
export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
const { setValue, value } = useField<string>({ path })
const [revealed, setRevealed] = useState(false)
const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)
return (
<div className="field-type text">
<label className="field-label">{label}</label>
<div style={{ display: 'flex', gap: '.5rem' }}>
<input
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
style={{ flex: 1 }}
type={revealed ? 'text' : 'password'}
value={value ?? ''}
/>
<button onClick={() => setRevealed((r) => !r)} type="button">
{revealed ? 'Hide' : 'Reveal'}
</button>
</div>
</div>
)
}
export default MaskedField
@@ -36,6 +36,10 @@ export const smtpFields: Field[] = [
type: 'text', type: 'text',
admin: { admin: {
description: 'SMTP account password.', description: 'SMTP account password.',
// Masked in the UI (••••) — stored plaintext, readable for SMTP auth.
components: {
Field: '@intecion/ipal-kit/client#MaskedField',
},
}, },
}, },
{ {
@@ -35,6 +35,10 @@ export const storageFields: Field[] = [
type: 'text', type: 'text',
admin: { admin: {
description: 'R2 secret access key.', description: 'R2 secret access key.',
// Masked in the UI (••••) — stored plaintext, readable for R2 auth.
components: {
Field: '@intecion/ipal-kit/client#MaskedField',
},
}, },
}, },
] ]
@@ -21,6 +21,10 @@ export const turnstileFields: Field[] = [
type: 'text', type: 'text',
admin: { admin: {
description: 'Secret key used for server-side verification.', description: 'Secret key used for server-side verification.',
// Masked in the UI (••••) — stored plaintext, readable for verification.
components: {
Field: '@intecion/ipal-kit/client#MaskedField',
},
}, },
}, },
] ]
+17 -1
View File
@@ -7,6 +7,11 @@ import { checkRateLimit } from './rateLimit.js'
import { validateSubmission } from './validateSubmission.js' import { validateSubmission } from './validateSubmission.js'
export type SubmitFormArgs = { export type SubmitFormArgs = {
/**
* Name of the GDPR consent checkbox. A field with this name must be checked
* for the submission to succeed (enforced server-side). Defaults to 'consent'.
*/
consentFieldName?: string
/** Submitted field data — shape matches the form's fields. */ /** Submitted field data — shape matches the form's fields. */
data: Record<string, unknown> data: Record<string, unknown>
/** Form-builder form ID this submission belongs to. */ /** Form-builder form ID this submission belongs to. */
@@ -37,6 +42,7 @@ export type SubmitFormArgs = {
* `field` and `kind` narrow it down when a specific field is * `field` and `kind` narrow it down when a specific field is
* at fault (absent for whole-payload problems like unknown keys) * at fault (absent for whole-payload problems like unknown keys)
* - `not_found` — no form with this id * - `not_found` — no form with this id
* - `consent` — a required GDPR consent checkbox was left unchecked
* - `error` — persistence failed unexpectedly * - `error` — persistence failed unexpectedly
*/ */
export type SubmitFailure = export type SubmitFailure =
@@ -48,6 +54,12 @@ export type SubmitFailure =
reason: 'validation' reason: 'validation'
success: false success: false
} }
| {
field?: string
/** A GDPR consent field existed on the form but wasn't checked. */
reason: 'consent'
success: false
}
| { reason: 'error'; success: false } | { reason: 'error'; success: false }
| { reason: 'not_found'; success: false } | { reason: 'not_found'; success: false }
| { reason: 'rate_limited'; success: false } | { reason: 'rate_limited'; success: false }
@@ -69,6 +81,7 @@ export type SubmitFormResult = { submissionId: number | string; success: true }
* server-only: touches the Turnstile secret. * server-only: touches the Turnstile secret.
*/ */
export async function submitForm({ export async function submitForm({
consentFieldName,
data, data,
formId, formId,
ip, ip,
@@ -93,11 +106,14 @@ export async function submitForm({
// 3. Validate against the form's schema. A public endpoint can't trust the // 3. Validate against the form's schema. A public endpoint can't trust the
// shape of `data` — drop unknown keys, enforce required, cap length. // shape of `data` — drop unknown keys, enforce required, cap length.
const validation = await validateSubmission(payload, formId, data) const validation = await validateSubmission(payload, formId, data, consentFieldName)
if (!validation.ok) { if (!validation.ok) {
if (validation.reason === 'not_found') { if (validation.reason === 'not_found') {
return { reason: 'not_found', success: false } return { reason: 'not_found', success: false }
} }
if (validation.reason === 'consent') {
return { field: validation.field, reason: 'consent', success: false }
}
return { return {
reason: 'validation', reason: 'validation',
success: false, success: false,
+6
View File
@@ -19,6 +19,12 @@ export type FormsCollectionOverrides = {
* form-builder plugin. Kept minimal; the plugin passes these through. * form-builder plugin. Kept minimal; the plugin passes these through.
*/ */
export type FormsOption = { export type FormsOption = {
/**
* Name of the checkbox field treated as a GDPR consent gate. A form field
* with this name must be checked for submission to succeed — enforced
* server-side in submitForm. Defaults to 'consent'.
*/
consentFieldName?: string
/** Field types available in the form builder. Sensible defaults applied. */ /** Field types available in the form builder. Sensible defaults applied. */
fields?: { fields?: {
checkbox?: boolean checkbox?: boolean
+34 -3
View File
@@ -29,14 +29,34 @@ export type FormValidationResult =
reason: 'invalid' reason: 'invalid'
} }
| { cleaned: Record<string, unknown>; form: FormDoc; ok: true } | { cleaned: Record<string, unknown>; form: FormDoc; ok: true }
| { field: string; ok: false; reason: 'consent' }
| { ok: false; reason: 'not_found' } | { ok: false; reason: 'not_found' }
/** Field block types that don't carry a submittable value. */ /** Field block types that don't carry a submittable value. */
const NON_DATA_BLOCKS = new Set(['message']) const NON_DATA_BLOCKS = new Set(['message'])
/**
* Keys injected by the captcha widget itself, not by the form definition.
* Cloudflare Turnstile adds a hidden <input name="cf-turnstile-response"> after
* a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the
* plugin drives Turnstile end-to-end, these are legitimate artifacts — they
* must not count as "unknown fields" and trip the anti-tampering check.
*/
const CAPTCHA_KEYS = new Set(['cf-turnstile-response', 'g-recaptcha-response'])
/** Hard ceiling on a single field's length, independent of the form config. */ /** Hard ceiling on a single field's length, independent of the form config. */
const MAX_FIELD_LENGTH = 5000 const MAX_FIELD_LENGTH = 5000
/**
* Default name for a GDPR consent field. A checkbox with this name is treated
* as a consent gate: it MUST be checked for the submission to go through,
* enforced here server-side regardless of how the field was configured in the
* panel (so an editor can't weaken it by forgetting `required` or, worse,
* pre-ticking it with defaultValue: true — which GDPR forbids). Configurable
* via FormsOption.consentFieldName.
*/
const DEFAULT_CONSENT_FIELD = 'consent'
/** /**
* Checks submitted data against the form's own definition, rather than trusting * Checks submitted data against the form's own definition, rather than trusting
* whatever arrived. * whatever arrived.
@@ -54,6 +74,7 @@ export async function validateSubmission(
payload: BasePayload, payload: BasePayload,
formId: string, formId: string,
data: Record<string, unknown>, data: Record<string, unknown>,
consentFieldName: string = DEFAULT_CONSENT_FIELD,
): Promise<FormValidationResult> { ): Promise<FormValidationResult> {
let form: FormDoc let form: FormDoc
try { try {
@@ -79,7 +100,15 @@ export async function validateSubmission(
const isBlank = const isBlank =
value == null || (typeof value === 'string' && value.trim() === '') || value === false value == null || (typeof value === 'string' && value.trim() === '') || value === false
if (field.required && isBlank) { // GDPR consent gate: a field matching the consent name must be truthy
// (checked). Enforced independently of `required`, so it can't be weakened
// in the panel. This is the one field where server-side enforcement is the
// legal guarantee — the frontend can't bypass it, the editor can't misset it.
if (field.name === consentFieldName) {
if (value !== true) {
return { field: field.name, ok: false, reason: 'consent' }
}
} else if (field.required && isBlank) {
return { field: field.name, kind: 'required', ok: false, reason: 'invalid' } return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }
} }
@@ -95,8 +124,10 @@ export async function validateSubmission(
} }
// Reject outright if the payload carried keys the form doesn't define — a // Reject outright if the payload carried keys the form doesn't define — a
// sign the request wasn't produced by the rendered form. // sign the request wasn't produced by the rendered form. Captcha keys are
const unknownKeys = Object.keys(data).filter((k) => !known.has(k)) // exempt: the widget injects them into the rendered form, so they're expected,
// not tampering.
const unknownKeys = Object.keys(data).filter((k) => !known.has(k) && !CAPTCHA_KEYS.has(k))
if (unknownKeys.length > 0) { if (unknownKeys.length > 0) {
return { kind: 'unknown_fields', ok: false, reason: 'invalid' } return { kind: 'unknown_fields', ok: false, reason: 'invalid' }
} }
+26 -14
View File
@@ -21,7 +21,7 @@ type MiddlewareRequest = {
*/ */
export type LocaleMiddlewareResult = export type LocaleMiddlewareResult =
| { cookie?: { name: string; value: string }; location: string; type: 'redirect' } | { cookie?: { name: string; value: string }; location: string; type: 'redirect' }
| { type: 'next' } | { cookie?: { name: string; value: string }; type: 'next' }
type CreateLocaleMiddlewareArgs = { type CreateLocaleMiddlewareArgs = {
config: I18nConfig config: I18nConfig
@@ -66,10 +66,10 @@ function firstSegment(pathname: string): string {
* import { localeMiddleware } from './ipal.middleware' // created from this factory * import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function proxy(req) { * export function proxy(req) {
* const r = localeMiddleware(req) * const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next() * // Both results may carry an optional cookie — 'next' when the visitor
* const res = NextResponse.redirect(r.location) * // switched language (URL locale differs from the stored one) and consented,
* // cookie is optional: only present when the visitor consented to the * // 'redirect' on the initial locale negotiation. Set it whenever present.
* // gating category (functional by default). Guard before setting. * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res * return res
* } * }
@@ -80,11 +80,29 @@ export function createLocaleMiddleware({
consentCookieName = CONSENT_COOKIE, consentCookieName = CONSENT_COOKIE,
cookieName = LOCALE_COOKIE_NAME, cookieName = LOCALE_COOKIE_NAME,
}: CreateLocaleMiddlewareArgs) { }: CreateLocaleMiddlewareArgs) {
// Whether the locale cookie may be written: 'necessary' is always granted;
// 'functional' (default) requires the visitor to have consented.
function mayPersistLocale(request: MiddlewareRequest): boolean {
if (consentCategory === 'necessary') {return true}
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
return consent?.[consentCategory] === true
}
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult { return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
// Already locale-prefixed → nothing to do // Already locale-prefixed (e.g. the visitor switched language by
if (isValidLocale(firstSegment(pathname), config)) { // navigating to /en). Routing is fine — but if the URL's locale differs
// from the stored cookie, the visitor is *choosing* a language, and we
// should remember it — provided they consented to the gating category.
// Without consent we leave the cookie untouched: the switch works for this
// visit but isn't persisted, which is exactly the functional-cookie rule.
const urlLocale = firstSegment(pathname)
if (isValidLocale(urlLocale, config)) {
const currentCookie = request.cookies.get(cookieName)?.value ?? null
if (currentCookie !== urlLocale && mayPersistLocale(request)) {
return { type: 'next', cookie: { name: cookieName, value: urlLocale } }
}
return { type: 'next' } return { type: 'next' }
} }
@@ -106,16 +124,10 @@ export function createLocaleMiddleware({
// Without consent the locale is still detected each request (routing works), // Without consent the locale is still detected each request (routing works),
// it just isn't remembered across visits — which is the whole point of // it just isn't remembered across visits — which is the whole point of
// gating a functional cookie behind consent. // gating a functional cookie behind consent.
let mayPersist = consentCategory === 'necessary'
if (!mayPersist) {
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
mayPersist = consent?.[consentCategory] === true
}
return { return {
type: 'redirect', type: 'redirect',
location: url.toString(), location: url.toString(),
...(mayPersist ? { cookie: { name: cookieName, value: locale } } : {}), ...(mayPersistLocale(request) ? { cookie: { name: cookieName, value: locale } } : {}),
} }
} }
} }
+18
View File
@@ -0,0 +1,18 @@
import type { NotificationTexts } from './types.js'
/**
* Built-in English fallbacks, used per field when the Notifications global
* leaves a text empty. Same philosophy as consent FALLBACK: the site works out
* of the box, editors override per locale as needed.
*/
export const NOTIFICATION_FALLBACK: NotificationTexts = {
form: {
success: 'Thank you — your message has been sent.',
error: 'Something went wrong. Please try again later.',
rateLimited: 'Too many attempts. Please wait a moment and try again.',
turnstile: 'Captcha verification failed. Please try again.',
validation: 'Please check the {field} field and try again.',
consent: 'Please accept the privacy policy to continue.',
notFound: 'This form is no longer available.',
},
}
@@ -0,0 +1,40 @@
import type { BasePayload } from 'payload'
import type { NotificationsData, NotificationTexts } from './types.js'
import { getGlobal } from '../payload/index.js'
import { NOTIFICATION_FALLBACK } from './defaults.js'
type GetNotificationTextsArgs = {
/** Active locale — selects the language variant of each text. */
locale?: string
payload: BasePayload
}
/**
* Resolves notification texts from the Notifications global, falling back to
* English defaults per field. Mirrors getConsentTexts: one read, per-field
* fallback, locale-aware. The frontend maps a submitForm result code to the
* matching text and styles it however it likes (toast, inline, banner).
*/
export async function getNotificationTexts({
locale,
payload,
}: GetNotificationTextsArgs): Promise<NotificationTexts> {
const g = await getGlobal<NotificationsData>(payload, 'notifications', { locale })
const f = g.form ?? {}
const fb = NOTIFICATION_FALLBACK.form
return {
form: {
consent: f.consent || fb.consent,
error: f.error || fb.error,
notFound: f.notFound || fb.notFound,
rateLimited: f.rateLimited || fb.rateLimited,
success: f.success || fb.success,
turnstile: f.turnstile || fb.turnstile,
validation: f.validation || fb.validation,
},
}
}
+4
View File
@@ -0,0 +1,4 @@
export { NOTIFICATION_FALLBACK } from './defaults.js'
export { getNotificationTexts } from './getNotificationTexts.js'
export { resolveFormMessage } from './resolveFormMessage.js'
export type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js'
@@ -0,0 +1,35 @@
import type { SubmitFormResult } from '../forms/index.js'
import type { FormNotificationTexts } from './types.js'
/**
* Maps a submitForm result to the user-facing message, interpolating {field}
* for validation errors. This is the bridge the frontend uses: it gets a result
* code from submitForm and the resolved texts from getNotificationTexts, and
* this turns them into one string to display. Keeping the mapping here means the
* frontend never hard-codes messages or knows about result codes.
*
* Never surfaces raw backend/exception detail — 'error' maps to a friendly
* generic message, not the thrown error's text (which could leak internals).
*/
export function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string {
if (result.success) {return texts.success}
switch (result.reason) {
case 'consent':
return texts.consent
case 'not_found':
return texts.notFound
case 'rate_limited':
return texts.rateLimited
case 'turnstile':
return texts.turnstile
case 'validation': {
// Interpolate {field} with the offending field name when present.
const field = 'field' in result && result.field ? result.field : ''
return texts.validation.replace('{field}', field)
}
case 'error':
default:
return texts.error
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Resolved notification texts, ready for the frontend. Grouped per context;
* `form` maps submitForm result codes to user-facing messages.
*/
export type FormNotificationTexts = {
success: string
error: string
rateLimited: string
turnstile: string
/** May contain the {field} placeholder — resolve with resolveValidationText. */
validation: string
/** Shown when a required GDPR consent checkbox was left unchecked. */
consent: string
notFound: string
}
export type NotificationTexts = {
form: FormNotificationTexts
}
/** Raw shape read from the Notifications global (all fields optional). */
export type NotificationsData = {
form?: Partial<FormNotificationTexts>
}