diff --git a/dist/exports/client.d.ts b/dist/exports/client.d.ts index c33e6eb..777b699 100644 --- a/dist/exports/client.d.ts +++ b/dist/exports/client.d.ts @@ -1,3 +1,4 @@ +export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'; export { Analytics } from '../modules/analytics/client.js'; /** * Entry point: ipal-kit/client diff --git a/dist/modules/forms/submitForm.d.ts b/dist/modules/forms/submitForm.d.ts index b2b2814..62b494f 100644 --- a/dist/modules/forms/submitForm.d.ts +++ b/dist/modules/forms/submitForm.d.ts @@ -1,6 +1,11 @@ import 'server-only'; import type { BasePayload } from 'payload'; 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. */ data: Record; /** 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 * at fault (absent for whole-payload problems like unknown keys) * - `not_found` — no form with this id + * - `consent` — a required GDPR consent checkbox was left unchecked * - `error` — persistence failed unexpectedly */ export type SubmitFailure = { @@ -39,6 +45,11 @@ export type SubmitFailure = { kind?: 'required' | 'too_long' | 'unknown_fields'; reason: 'validation'; success: false; +} | { + field?: string; + /** A GDPR consent field existed on the form but wasn't checked. */ + reason: 'consent'; + success: false; } | { reason: 'error'; success: false; @@ -69,4 +80,4 @@ export type SubmitFormResult = { * * server-only: touches the Turnstile secret. */ -export declare function submitForm({ data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise; +export declare function submitForm({ consentFieldName, data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise; diff --git a/dist/modules/forms/types.d.ts b/dist/modules/forms/types.d.ts index 70192b2..ec2a2b6 100644 --- a/dist/modules/forms/types.d.ts +++ b/dist/modules/forms/types.d.ts @@ -18,6 +18,12 @@ export type FormsCollectionOverrides = { * form-builder plugin. Kept minimal; the plugin passes these through. */ 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. */ fields?: { checkbox?: boolean; diff --git a/dist/modules/forms/validateSubmission.d.ts b/dist/modules/forms/validateSubmission.d.ts index 536d3a0..a6903b5 100644 --- a/dist/modules/forms/validateSubmission.d.ts +++ b/dist/modules/forms/validateSubmission.d.ts @@ -27,6 +27,10 @@ export type FormValidationResult = { cleaned: Record; form: FormDoc; ok: true; +} | { + field: string; + ok: false; + reason: 'consent'; } | { ok: false; 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 * a code + offending field on failure so the frontend can point at it. */ -export declare function validateSubmission(payload: BasePayload, formId: string, data: Record): Promise; +export declare function validateSubmission(payload: BasePayload, formId: string, data: Record, consentFieldName?: string): Promise; export {}; diff --git a/src/exports/client.ts b/src/exports/client.ts index a2b0523..3e0bffa 100644 --- a/src/exports/client.ts +++ b/src/exports/client.ts @@ -1,4 +1,5 @@ 'use client' +export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js' export { Analytics } from '../modules/analytics/client.js' /** * Entry point: ipal-kit/client diff --git a/src/globals/Notifications/fields.ts b/src/globals/Notifications/fields.ts new file mode 100644 index 0000000..fd2891b --- /dev/null +++ b/src/globals/Notifications/fields.ts @@ -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', + }, +] diff --git a/src/globals/Notifications/index.ts b/src/globals/Notifications/index.ts new file mode 100644 index 0000000..d3a1e4d --- /dev/null +++ b/src/globals/Notifications/index.ts @@ -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, + } +} diff --git a/src/globals/SiteIntegrations/components/MaskedField.tsx b/src/globals/SiteIntegrations/components/MaskedField.tsx new file mode 100644 index 0000000..1dd939c --- /dev/null +++ b/src/globals/SiteIntegrations/components/MaskedField.tsx @@ -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({ path }) + const [revealed, setRevealed] = useState(false) + const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path) + + return ( +
+ +
+ setValue(e.target.value)} + style={{ flex: 1 }} + type={revealed ? 'text' : 'password'} + value={value ?? ''} + /> + +
+
+ ) +} +export default MaskedField diff --git a/src/globals/SiteIntegrations/fields/smtp.ts b/src/globals/SiteIntegrations/fields/smtp.ts index 4bc9d5e..12b1b9a 100644 --- a/src/globals/SiteIntegrations/fields/smtp.ts +++ b/src/globals/SiteIntegrations/fields/smtp.ts @@ -36,6 +36,10 @@ export const smtpFields: Field[] = [ type: 'text', admin: { description: 'SMTP account password.', + // Masked in the UI (••••) — stored plaintext, readable for SMTP auth. + components: { + Field: '@intecion/ipal-kit/client#MaskedField', + }, }, }, { diff --git a/src/globals/SiteIntegrations/fields/storage.ts b/src/globals/SiteIntegrations/fields/storage.ts index 20d7b78..07b4f70 100644 --- a/src/globals/SiteIntegrations/fields/storage.ts +++ b/src/globals/SiteIntegrations/fields/storage.ts @@ -35,6 +35,10 @@ export const storageFields: Field[] = [ type: 'text', admin: { description: 'R2 secret access key.', + // Masked in the UI (••••) — stored plaintext, readable for R2 auth. + components: { + Field: '@intecion/ipal-kit/client#MaskedField', + }, }, }, ] diff --git a/src/globals/SiteIntegrations/fields/turnstile.ts b/src/globals/SiteIntegrations/fields/turnstile.ts index 54a1a75..49d4d84 100644 --- a/src/globals/SiteIntegrations/fields/turnstile.ts +++ b/src/globals/SiteIntegrations/fields/turnstile.ts @@ -21,6 +21,10 @@ export const turnstileFields: Field[] = [ type: 'text', admin: { description: 'Secret key used for server-side verification.', + // Masked in the UI (••••) — stored plaintext, readable for verification. + components: { + Field: '@intecion/ipal-kit/client#MaskedField', + }, }, }, ] diff --git a/src/modules/forms/submitForm.ts b/src/modules/forms/submitForm.ts index 216da1b..dc1b3bb 100644 --- a/src/modules/forms/submitForm.ts +++ b/src/modules/forms/submitForm.ts @@ -7,6 +7,11 @@ import { checkRateLimit } from './rateLimit.js' import { validateSubmission } from './validateSubmission.js' 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. */ data: Record /** 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 * at fault (absent for whole-payload problems like unknown keys) * - `not_found` — no form with this id + * - `consent` — a required GDPR consent checkbox was left unchecked * - `error` — persistence failed unexpectedly */ export type SubmitFailure = @@ -48,6 +54,12 @@ export type SubmitFailure = reason: 'validation' 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: 'not_found'; 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. */ export async function submitForm({ + consentFieldName, data, formId, ip, @@ -93,11 +106,14 @@ export async function submitForm({ // 3. Validate against the form's schema. A public endpoint can't trust the // 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.reason === 'not_found') { return { reason: 'not_found', success: false } } + if (validation.reason === 'consent') { + return { field: validation.field, reason: 'consent', success: false } + } return { reason: 'validation', success: false, diff --git a/src/modules/forms/types.ts b/src/modules/forms/types.ts index 39b0d5d..e53a6d2 100644 --- a/src/modules/forms/types.ts +++ b/src/modules/forms/types.ts @@ -19,6 +19,12 @@ export type FormsCollectionOverrides = { * form-builder plugin. Kept minimal; the plugin passes these through. */ 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. */ fields?: { checkbox?: boolean diff --git a/src/modules/forms/validateSubmission.ts b/src/modules/forms/validateSubmission.ts index a19a228..4269c62 100644 --- a/src/modules/forms/validateSubmission.ts +++ b/src/modules/forms/validateSubmission.ts @@ -29,6 +29,7 @@ export type FormValidationResult = reason: 'invalid' } | { cleaned: Record; form: FormDoc; ok: true } + | { field: string; ok: false; reason: 'consent' } | { ok: false; reason: 'not_found' } /** Field block types that don't carry a submittable value. */ @@ -46,6 +47,16 @@ 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 +/** + * 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 * whatever arrived. @@ -63,6 +74,7 @@ export async function validateSubmission( payload: BasePayload, formId: string, data: Record, + consentFieldName: string = DEFAULT_CONSENT_FIELD, ): Promise { let form: FormDoc try { @@ -88,7 +100,15 @@ export async function validateSubmission( const isBlank = 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' } } diff --git a/src/modules/notifications/defaults.ts b/src/modules/notifications/defaults.ts new file mode 100644 index 0000000..9a9faac --- /dev/null +++ b/src/modules/notifications/defaults.ts @@ -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.', + }, +} diff --git a/src/modules/notifications/getNotificationTexts.ts b/src/modules/notifications/getNotificationTexts.ts new file mode 100644 index 0000000..7d81904 --- /dev/null +++ b/src/modules/notifications/getNotificationTexts.ts @@ -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 { + const g = await getGlobal(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, + }, + } +} diff --git a/src/modules/notifications/index.ts b/src/modules/notifications/index.ts new file mode 100644 index 0000000..f0702c0 --- /dev/null +++ b/src/modules/notifications/index.ts @@ -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' diff --git a/src/modules/notifications/resolveFormMessage.ts b/src/modules/notifications/resolveFormMessage.ts new file mode 100644 index 0000000..2ade63e --- /dev/null +++ b/src/modules/notifications/resolveFormMessage.ts @@ -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 + } +} diff --git a/src/modules/notifications/types.ts b/src/modules/notifications/types.ts new file mode 100644 index 0000000..c6f0666 --- /dev/null +++ b/src/modules/notifications/types.ts @@ -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 +}