added validation for forms, sensitive text fields have been masked
This commit is contained in:
Vendored
+1
@@ -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
|
||||||
|
|||||||
Vendored
+12
-1
@@ -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>;
|
||||||
|
|||||||
Vendored
+6
@@ -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
@@ -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 {};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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. */
|
||||||
@@ -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. */
|
/** 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.
|
||||||
@@ -63,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 {
|
||||||
@@ -88,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' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user