Compare commits

..
5 Commits
65 changed files with 979 additions and 19 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
+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
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"} {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"}
+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[];
+82
View File
@@ -0,0 +1,82 @@
/**
* 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 = [
{
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'
}
];
//# sourceMappingURL=fields.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/globals/Notifications/fields.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Fields for the Notifications global — localized user-facing texts for action\n * results (form submission outcomes, and future contexts). Every text is\n * localized: true so each language has its own value. Empty fields fall back to\n * built-in English defaults (see modules/notifications/defaults).\n *\n * Grouped per context. `form` holds the outcomes of submitForm; more groups\n * (e.g. `newsletter`, `system`) can be added the same way without touching\n * consumers — getNotificationTexts resolves whatever exists, falling back\n * per field.\n */\nexport const notificationsFields: Field[] = [\n {\n name: 'form',\n type: 'group',\n admin: {\n description:\n 'Messages shown after a form is submitted. Leave a field empty to use the built-in default.',\n },\n fields: [\n {\n name: 'success',\n type: 'text',\n admin: { placeholder: 'Thank you — your message has been sent.' },\n localized: true,\n },\n {\n name: 'error',\n type: 'text',\n admin: { placeholder: 'Something went wrong. Please try again later.' },\n localized: true,\n },\n {\n name: 'rateLimited',\n type: 'text',\n admin: { placeholder: 'Too many attempts. Please wait a moment and try again.' },\n localized: true,\n },\n {\n name: 'turnstile',\n type: 'text',\n admin: { placeholder: 'Captcha verification failed. Please try again.' },\n localized: true,\n },\n {\n name: 'validation',\n type: 'text',\n admin: {\n description:\n 'Shown on a validation error. Use {field} to insert the offending field name.',\n placeholder: 'Please check the {field} field and try again.',\n },\n localized: true,\n },\n {\n name: 'consent',\n type: 'text',\n admin: {\n description: 'Shown when the GDPR consent checkbox is left unchecked.',\n placeholder: 'Please accept the privacy policy to continue.',\n },\n localized: true,\n },\n {\n name: 'notFound',\n type: 'text',\n admin: { placeholder: 'This form is no longer available.' },\n localized: true,\n },\n ],\n label: 'Form messages',\n },\n]\n"],"names":["notificationsFields","name","type","admin","description","fields","placeholder","localized","label"],"mappings":"AAEA;;;;;;;;;;CAUC,GACD,OAAO,MAAMA,sBAA+B;IAC1C;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAC,QAAQ;YACN;gBACEJ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAA0C;gBAChEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAgD;gBACtEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAyD;gBAC/EC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAiD;gBACvEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBACLC,aACE;oBACFE,aAAa;gBACf;gBACAC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBACLC,aAAa;oBACbE,aAAa;gBACf;gBACAC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAoC;gBAC1DC,WAAW;YACb;SACD;QACDC,OAAO;IACT;CACD,CAAA"}
+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;
+17
View File
@@ -0,0 +1,17 @@
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() {
return {
slug: 'notifications',
label: 'Notifications',
access: {
read: ()=>true
},
fields: notificationsFields
};
}
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/globals/Notifications/index.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\nimport { notificationsFields } from './fields.js'\n\n/**\n * Builds the Notifications global — localized action-result texts. Readable by\n * any authenticated panel user; server-side helpers read it with overrideAccess\n * so the frontend can resolve texts without a session.\n */\nexport function buildNotifications(): GlobalConfig {\n return {\n slug: 'notifications',\n label: 'Notifications',\n access: {\n read: () => true, // texts are public-facing (shown to end users)\n },\n fields: notificationsFields,\n }\n}\n"],"names":["notificationsFields","buildNotifications","slug","label","access","read","fields"],"mappings":"AACA,SAASA,mBAAmB,QAAQ,cAAa;AAEjD;;;;CAIC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,MAAM;QACNC,OAAO;QACPC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,QAAQN;IACV;AACF"}
@@ -0,0 +1,3 @@
import type { TextFieldClientComponent } from 'payload';
export declare const MaskedField: TextFieldClientComponent;
export default MaskedField;
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useField } from '@payloadcms/ui';
import { useState } from 'react';
export const MaskedField = ({ field, path })=>{
const { setValue, value } = useField({
path
});
const [revealed, setRevealed] = useState(false);
const label = typeof field?.label === 'string' ? field.label : field?.name ?? path;
return /*#__PURE__*/ _jsxs("div", {
className: "field-type text",
children: [
/*#__PURE__*/ _jsx("label", {
className: "field-label",
children: label
}),
/*#__PURE__*/ _jsxs("div", {
style: {
display: 'flex',
gap: '.5rem'
},
children: [
/*#__PURE__*/ _jsx("input", {
autoComplete: "off",
onChange: (e)=>setValue(e.target.value),
style: {
flex: 1
},
type: revealed ? 'text' : 'password',
value: value ?? ''
}),
/*#__PURE__*/ _jsx("button", {
onClick: ()=>setRevealed((r)=>!r),
type: "button",
children: revealed ? 'Hide' : 'Reveal'
})
]
})
]
});
};
export default MaskedField;
//# sourceMappingURL=MaskedField.js.map
@@ -0,0 +1 @@
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\n\nimport { useField } from '@payloadcms/ui'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = ({ field, path }) => {\n const { setValue, value } = useField<string>({ path })\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n return (\n <div className=\"field-type text\">\n <label className=\"field-label\">{label}</label>\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={(e) => setValue(e.target.value)}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={value ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\nexport default MaskedField\n"],"names":["useField","useState","MaskedField","field","path","setValue","value","revealed","setRevealed","label","name","div","className","style","display","gap","input","autoComplete","onChange","e","target","flex","type","button","onClick","r"],"mappings":"AAAA;;AAGA,SAASA,QAAQ,QAAQ,iBAAgB;AACzC,SAASC,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE;IACnE,MAAM,EAAEC,QAAQ,EAAEC,KAAK,EAAE,GAAGN,SAAiB;QAAEI;IAAK;IACpD,MAAM,CAACG,UAAUC,YAAY,GAAGP,SAAS;IACzC,MAAMQ,QAAQ,OAAON,OAAOM,UAAU,WAAWN,MAAMM,KAAK,GAAIN,OAAOO,QAAQN;IAE/E,qBACE,MAACO;QAAIC,WAAU;;0BACb,KAACH;gBAAMG,WAAU;0BAAeH;;0BAChC,MAACE;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbC,UAAU,CAACC,IAAMd,SAASc,EAAEC,MAAM,CAACd,KAAK;wBACxCO,OAAO;4BAAEQ,MAAM;wBAAE;wBACjBC,MAAMf,WAAW,SAAS;wBAC1BD,OAAOA,SAAS;;kCAElB,KAACiB;wBAAOC,SAAS,IAAMhB,YAAY,CAACiB,IAAM,CAACA;wBAAIH,MAAK;kCACjDf,WAAW,SAAS;;;;;;AAK/B,EAAC;AACD,eAAeL,YAAW"}
+5 -1
View File
@@ -37,7 +37,11 @@
name: 'smtpPassword', name: 'smtpPassword',
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'
}
} }
}, },
{ {
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n type: 'row',\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n]\n"],"names":["smtpFields","type","fields","name","admin","placeholder","width","defaultValue","description"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,QAAQ;YACN;gBACEC,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEC,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEH,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEE,OAAO;gBAAM;gBACtBC,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;CACD,CAAA"} {"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n type: 'row',\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n // Masked in the UI (••••) — stored plaintext, readable for SMTP auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n]\n"],"names":["smtpFields","type","fields","name","admin","placeholder","width","defaultValue","description","components","Field"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,QAAQ;YACN;gBACEC,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEC,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEH,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEE,OAAO;gBAAM;gBACtBC,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;YACb,sEAAsE;YACtEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;IACA;QACEP,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;CACD,CAAA"}
+5 -1
View File
@@ -31,7 +31,11 @@
name: 'r2SecretAccessKey', name: 'r2SecretAccessKey',
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'
}
} }
} }
]; ];
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/storage.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare R2 storage credentials.\n * Reserved for future use — media offloading to R2.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so the access keys stay editable in the admin panel while remaining\n * inaccessible to anonymous API requests.\n */\nexport const storageFields: Field[] = [\n {\n name: 'r2Bucket',\n type: 'text',\n admin: {\n description: 'R2 bucket name.',\n },\n },\n {\n name: 'r2Endpoint',\n type: 'text',\n admin: {\n description: 'R2 S3-compatible endpoint URL.',\n },\n },\n {\n name: 'r2AccessKeyId',\n type: 'text',\n admin: {\n description: 'R2 access key ID.',\n },\n },\n {\n name: 'r2SecretAccessKey',\n type: 'text',\n admin: {\n description: 'R2 secret access key.',\n },\n },\n]\n"],"names":["storageFields","name","type","admin","description"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;CACD,CAAA"} {"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/storage.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare R2 storage credentials.\n * Reserved for future use — media offloading to R2.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so the access keys stay editable in the admin panel while remaining\n * inaccessible to anonymous API requests.\n */\nexport const storageFields: Field[] = [\n {\n name: 'r2Bucket',\n type: 'text',\n admin: {\n description: 'R2 bucket name.',\n },\n },\n {\n name: 'r2Endpoint',\n type: 'text',\n admin: {\n description: 'R2 S3-compatible endpoint URL.',\n },\n },\n {\n name: 'r2AccessKeyId',\n type: 'text',\n admin: {\n description: 'R2 access key ID.',\n },\n },\n {\n name: 'r2SecretAccessKey',\n type: 'text',\n admin: {\n description: 'R2 secret access key.',\n // Masked in the UI (••••) — stored plaintext, readable for R2 auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n]\n"],"names":["storageFields","name","type","admin","description","components","Field"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,oEAAoE;YACpEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"}
+5 -1
View File
@@ -17,7 +17,11 @@
name: 'turnstileSecretKey', name: 'turnstileSecretKey',
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'
}
} }
} }
]; ];
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/turnstile.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare Turnstile credentials.\n *\n * siteKey is public (rendered in the widget); secretKey is used for\n * server-side verification. Both are protected at the global level\n * (SiteIntegrations requires an authenticated user) rather than per-field,\n * so they remain editable in the admin panel.\n */\nexport const turnstileFields: Field[] = [\n {\n name: 'turnstileSiteKey',\n type: 'text',\n admin: {\n description: 'Public site key rendered in the Turnstile widget.',\n },\n },\n {\n name: 'turnstileSecretKey',\n type: 'text',\n admin: {\n description: 'Secret key used for server-side verification.',\n },\n },\n]\n"],"names":["turnstileFields","name","type","admin","description"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,kBAA2B;IACtC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;CACD,CAAA"} {"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/turnstile.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare Turnstile credentials.\n *\n * siteKey is public (rendered in the widget); secretKey is used for\n * server-side verification. Both are protected at the global level\n * (SiteIntegrations requires an authenticated user) rather than per-field,\n * so they remain editable in the admin panel.\n */\nexport const turnstileFields: Field[] = [\n {\n name: 'turnstileSiteKey',\n type: 'text',\n admin: {\n description: 'Public site key rendered in the Turnstile widget.',\n },\n },\n {\n name: 'turnstileSecretKey',\n type: 'text',\n admin: {\n description: 'Secret key used for server-side verification.',\n // Masked in the UI (••••) — stored plaintext, readable for verification.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n]\n"],"names":["turnstileFields","name","type","admin","description","components","Field"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,kBAA2B;IACtC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,yEAAyE;YACzEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"}
+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>;
+9 -2
View File
@@ -14,7 +14,7 @@ import { validateSubmission } from './validateSubmission.js';
* which goes out over panelSmtpAdapter. Storing the submission is enough. * which goes out over panelSmtpAdapter. Storing the submission is enough.
* *
* server-only: touches the Turnstile secret. * server-only: touches the Turnstile secret.
*/ export async function submitForm({ data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) { */ export async function submitForm({ consentFieldName, data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) {
// 1. Rate limit — cheapest gate, drops a flood before any real work. // 1. Rate limit — cheapest gate, drops a flood before any real work.
if (maxPerMinute > 0 && ip) { if (maxPerMinute > 0 && ip) {
if (!checkRateLimit({ if (!checkRateLimit({
@@ -43,7 +43,7 @@ import { validateSubmission } from './validateSubmission.js';
} }
// 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 { return {
@@ -51,6 +51,13 @@ import { validateSubmission } from './validateSubmission.js';
success: false success: false
}; };
} }
if (validation.reason === 'consent') {
return {
field: validation.field,
reason: 'consent',
success: false
};
}
return { return {
reason: 'validation', reason: 'validation',
success: false, success: false,
File diff suppressed because one or more lines are too long
+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;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WA6BC"} {"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /**\n * Name of the checkbox field treated as a GDPR consent gate. A form field\n * with this name must be checked for submission to succeed — enforced\n * server-side in submitForm. Defaults to 'consent'.\n */\n consentFieldName?: string\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WAmCC"}
+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 {};
+22 -2
View File
@@ -12,6 +12,14 @@
'g-recaptcha-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;
/**
* 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.
@@ -24,7 +32,7 @@
* *
* 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 async function validateSubmission(payload, formId, data) { */ export async function validateSubmission(payload, formId, data, consentFieldName = DEFAULT_CONSENT_FIELD) {
let form; let form;
try { try {
form = await payload.findByID({ form = await payload.findByID({
@@ -47,7 +55,19 @@
for (const field of fields){ for (const field of fields){
const value = data[field.name]; const value = data[field.name];
const isBlank = value == null || typeof value === 'string' && value.trim() === '' || value === false; 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 { return {
field: field.name, field: field.name,
kind: 'required', kind: 'required',
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;
+17
View File
@@ -0,0 +1,17 @@
/**
* 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 = {
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.'
}
};
//# sourceMappingURL=defaults.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/notifications/defaults.ts"],"sourcesContent":["import type { NotificationTexts } from './types.js'\n\n/**\n * Built-in English fallbacks, used per field when the Notifications global\n * leaves a text empty. Same philosophy as consent FALLBACK: the site works out\n * of the box, editors override per locale as needed.\n */\nexport const NOTIFICATION_FALLBACK: NotificationTexts = {\n form: {\n success: 'Thank you — your message has been sent.',\n error: 'Something went wrong. Please try again later.',\n rateLimited: 'Too many attempts. Please wait a moment and try again.',\n turnstile: 'Captcha verification failed. Please try again.',\n validation: 'Please check the {field} field and try again.',\n consent: 'Please accept the privacy policy to continue.',\n notFound: 'This form is no longer available.',\n },\n}\n"],"names":["NOTIFICATION_FALLBACK","form","success","error","rateLimited","turnstile","validation","consent","notFound"],"mappings":"AAEA;;;;CAIC,GACD,OAAO,MAAMA,wBAA2C;IACtDC,MAAM;QACJC,SAAS;QACTC,OAAO;QACPC,aAAa;QACbC,WAAW;QACXC,YAAY;QACZC,SAAS;QACTC,UAAU;IACZ;AACF,EAAC"}
+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 {};
+27
View File
@@ -0,0 +1,27 @@
import { getGlobal } from '../payload/index.js';
import { NOTIFICATION_FALLBACK } from './defaults.js';
/**
* 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 }) {
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
}
};
}
//# sourceMappingURL=getNotificationTexts.js.map
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/notifications/getNotificationTexts.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { NotificationsData, NotificationTexts } from './types.js'\n\nimport { getGlobal } from '../payload/index.js'\nimport { NOTIFICATION_FALLBACK } from './defaults.js'\n\ntype GetNotificationTextsArgs = {\n /** Active locale — selects the language variant of each text. */\n locale?: string\n payload: BasePayload\n}\n\n/**\n * Resolves notification texts from the Notifications global, falling back to\n * English defaults per field. Mirrors getConsentTexts: one read, per-field\n * fallback, locale-aware. The frontend maps a submitForm result code to the\n * matching text and styles it however it likes (toast, inline, banner).\n */\nexport async function getNotificationTexts({\n locale,\n payload,\n}: GetNotificationTextsArgs): Promise<NotificationTexts> {\n const g = await getGlobal<NotificationsData>(payload, 'notifications', { locale })\n\n const f = g.form ?? {}\n const fb = NOTIFICATION_FALLBACK.form\n\n return {\n form: {\n consent: f.consent || fb.consent,\n error: f.error || fb.error,\n notFound: f.notFound || fb.notFound,\n rateLimited: f.rateLimited || fb.rateLimited,\n success: f.success || fb.success,\n turnstile: f.turnstile || fb.turnstile,\n validation: f.validation || fb.validation,\n },\n }\n}\n"],"names":["getGlobal","NOTIFICATION_FALLBACK","getNotificationTexts","locale","payload","g","f","form","fb","consent","error","notFound","rateLimited","success","turnstile","validation"],"mappings":"AAIA,SAASA,SAAS,QAAQ,sBAAqB;AAC/C,SAASC,qBAAqB,QAAQ,gBAAe;AAQrD;;;;;CAKC,GACD,OAAO,eAAeC,qBAAqB,EACzCC,MAAM,EACNC,OAAO,EACkB;IACzB,MAAMC,IAAI,MAAML,UAA6BI,SAAS,iBAAiB;QAAED;IAAO;IAEhF,MAAMG,IAAID,EAAEE,IAAI,IAAI,CAAC;IACrB,MAAMC,KAAKP,sBAAsBM,IAAI;IAErC,OAAO;QACLA,MAAM;YACJE,SAASH,EAAEG,OAAO,IAAID,GAAGC,OAAO;YAChCC,OAAOJ,EAAEI,KAAK,IAAIF,GAAGE,KAAK;YAC1BC,UAAUL,EAAEK,QAAQ,IAAIH,GAAGG,QAAQ;YACnCC,aAAaN,EAAEM,WAAW,IAAIJ,GAAGI,WAAW;YAC5CC,SAASP,EAAEO,OAAO,IAAIL,GAAGK,OAAO;YAChCC,WAAWR,EAAEQ,SAAS,IAAIN,GAAGM,SAAS;YACtCC,YAAYT,EAAES,UAAU,IAAIP,GAAGO,UAAU;QAC3C;IACF;AACF"}
+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';
+5
View File
@@ -0,0 +1,5 @@
export { NOTIFICATION_FALLBACK } from './defaults.js';
export { getNotificationTexts } from './getNotificationTexts.js';
export { resolveFormMessage } from './resolveFormMessage.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/notifications/index.ts"],"sourcesContent":["export { NOTIFICATION_FALLBACK } from './defaults.js'\nexport { getNotificationTexts } from './getNotificationTexts.js'\nexport { resolveFormMessage } from './resolveFormMessage.js'\nexport type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js'\n"],"names":["NOTIFICATION_FALLBACK","getNotificationTexts","resolveFormMessage"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,gBAAe;AACrD,SAASC,oBAAoB,QAAQ,4BAA2B;AAChE,SAASC,kBAAkB,QAAQ,0BAAyB"}
+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;
+35
View File
@@ -0,0 +1,35 @@
/**
* 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, texts) {
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;
}
}
//# sourceMappingURL=resolveFormMessage.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/notifications/resolveFormMessage.ts"],"sourcesContent":["import type { SubmitFormResult } from '../forms/index.js'\nimport type { FormNotificationTexts } from './types.js'\n\n/**\n * Maps a submitForm result to the user-facing message, interpolating {field}\n * for validation errors. This is the bridge the frontend uses: it gets a result\n * code from submitForm and the resolved texts from getNotificationTexts, and\n * this turns them into one string to display. Keeping the mapping here means the\n * frontend never hard-codes messages or knows about result codes.\n *\n * Never surfaces raw backend/exception detail — 'error' maps to a friendly\n * generic message, not the thrown error's text (which could leak internals).\n */\nexport function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string {\n if (result.success) {return texts.success}\n\n switch (result.reason) {\n case 'consent':\n return texts.consent\n case 'not_found':\n return texts.notFound\n case 'rate_limited':\n return texts.rateLimited\n case 'turnstile':\n return texts.turnstile\n case 'validation': {\n // Interpolate {field} with the offending field name when present.\n const field = 'field' in result && result.field ? result.field : ''\n return texts.validation.replace('{field}', field)\n }\n case 'error':\n default:\n return texts.error\n }\n}\n"],"names":["resolveFormMessage","result","texts","success","reason","consent","notFound","rateLimited","turnstile","field","validation","replace","error"],"mappings":"AAGA;;;;;;;;;CASC,GACD,OAAO,SAASA,mBAAmBC,MAAwB,EAAEC,KAA4B;IACvF,IAAID,OAAOE,OAAO,EAAE;QAAC,OAAOD,MAAMC,OAAO;IAAA;IAEzC,OAAQF,OAAOG,MAAM;QACnB,KAAK;YACH,OAAOF,MAAMG,OAAO;QACtB,KAAK;YACH,OAAOH,MAAMI,QAAQ;QACvB,KAAK;YACH,OAAOJ,MAAMK,WAAW;QAC1B,KAAK;YACH,OAAOL,MAAMM,SAAS;QACxB,KAAK;YAAc;gBACjB,kEAAkE;gBAClE,MAAMC,QAAQ,WAAWR,UAAUA,OAAOQ,KAAK,GAAGR,OAAOQ,KAAK,GAAG;gBACjE,OAAOP,MAAMQ,UAAU,CAACC,OAAO,CAAC,WAAWF;YAC7C;QACA,KAAK;QACL;YACE,OAAOP,MAAMU,KAAK;IACtB;AACF"}
+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>;
};
+6
View File
@@ -0,0 +1,6 @@
/**
* Resolved notification texts, ready for the frontend. Grouped per context;
* `form` maps submitForm result codes to user-facing messages.
*/ /** Raw shape read from the Notifications global (all fields optional). */ export { };
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/notifications/types.ts"],"sourcesContent":["/**\n * Resolved notification texts, ready for the frontend. Grouped per context;\n * `form` maps submitForm result codes to user-facing messages.\n */\nexport type FormNotificationTexts = {\n success: string\n error: string\n rateLimited: string\n turnstile: string\n /** May contain the {field} placeholder — resolve with resolveValidationText. */\n validation: string\n /** Shown when a required GDPR consent checkbox was left unchecked. */\n consent: string\n notFound: string\n}\n\nexport type NotificationTexts = {\n form: FormNotificationTexts\n}\n\n/** Raw shape read from the Notifications global (all fields optional). */\nexport type NotificationsData = {\n form?: Partial<FormNotificationTexts>\n}\n"],"names":[],"mappings":"AAAA;;;CAGC,GAiBD,wEAAwE,GACxE,WAEC"}
+70
View File
@@ -0,0 +1,70 @@
/**
* A single HTTP header, in the shape Next.js next.config headers() expects.
*/
export type SecurityHeader = {
key: string;
value: string;
};
export type BuildSecurityHeadersArgs = {
/**
* Extra headers to append or override. Same-key entries replace the default,
* so you can e.g. add your project's Content-Security-Policy here — CSP is
* intentionally NOT a default because it depends on the project's own
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
*/
additional?: SecurityHeader[];
/**
* X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
* modern browsers, but X-Frame-Options is kept for older ones. Set to null
* to omit (e.g. if you set frame-ancestors in your project CSP).
*/
frameOptions?: 'DENY' | 'SAMEORIGIN' | null;
/**
* Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and
* tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF
* in local/dev over plain HTTP, or you may lock the browser to https on
* localhost. Set the env guard in your next.config (see docs).
*/
hsts?: boolean;
/** Add includeSubDomains to HSTS. Default true. */
hstsIncludeSubDomains?: boolean;
/** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */
hstsMaxAge?: number;
/** Add preload to HSTS (only if you'll submit to the preload list). Default false. */
hstsPreload?: boolean;
/**
* Permissions-Policy. Default disables camera, microphone, geolocation. Pass
* your own string to override, or null to omit.
*/
permissionsPolicy?: null | string;
/** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */
referrerPolicy?: null | string;
};
/**
* Builds the generic, project-independent security headers every site should
* send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
* Permissions-Policy. These are identical across projects, so the plugin owns
* the boilerplate; the client spreads the result into next.config's headers().
*
* Content-Security-Policy is deliberately excluded: a useful CSP enumerates the
* exact domains a project loads from (its CDN, analytics, embeds), so it can't
* be generic without being either too loose (useless) or too strict (breaks the
* site). Add your project's CSP via `additional`.
*
* @example
* // next.config.ts
* import { buildSecurityHeaders } from '@intecion/ipal-kit'
* const securityHeaders = buildSecurityHeaders({
* hsts: process.env.NODE_ENV === 'production', // off in dev over http
* additional: [
* { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
* ],
* })
* const nextConfig = {
* async headers() {
* return [{ source: '/:path*', headers: securityHeaders }]
* },
* }
*/
export declare function buildSecurityHeaders(args?: BuildSecurityHeadersArgs): SecurityHeader[];
+81
View File
@@ -0,0 +1,81 @@
/**
* A single HTTP header, in the shape Next.js next.config headers() expects.
*/ /**
* Builds the generic, project-independent security headers every site should
* send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
* Permissions-Policy. These are identical across projects, so the plugin owns
* the boilerplate; the client spreads the result into next.config's headers().
*
* Content-Security-Policy is deliberately excluded: a useful CSP enumerates the
* exact domains a project loads from (its CDN, analytics, embeds), so it can't
* be generic without being either too loose (useless) or too strict (breaks the
* site). Add your project's CSP via `additional`.
*
* @example
* // next.config.ts
* import { buildSecurityHeaders } from '@intecion/ipal-kit'
* const securityHeaders = buildSecurityHeaders({
* hsts: process.env.NODE_ENV === 'production', // off in dev over http
* additional: [
* { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
* ],
* })
* const nextConfig = {
* async headers() {
* return [{ source: '/:path*', headers: securityHeaders }]
* },
* }
*/ export function buildSecurityHeaders(args = {}) {
const { additional = [], frameOptions = 'DENY', hsts = true, hstsIncludeSubDomains = true, hstsMaxAge = 63072000, hstsPreload = false, permissionsPolicy = 'camera=(), microphone=(), geolocation=()', referrerPolicy = 'strict-origin-when-cross-origin' } = args;
const headers = [];
if (hsts) {
const parts = [
`max-age=${hstsMaxAge}`
];
if (hstsIncludeSubDomains) {
parts.push('includeSubDomains');
}
if (hstsPreload) {
parts.push('preload');
}
headers.push({
key: 'Strict-Transport-Security',
value: parts.join('; ')
});
}
if (frameOptions) {
headers.push({
key: 'X-Frame-Options',
value: frameOptions
});
}
// Prevents MIME-type sniffing — always safe, no project specifics.
headers.push({
key: 'X-Content-Type-Options',
value: 'nosniff'
});
if (referrerPolicy) {
headers.push({
key: 'Referrer-Policy',
value: referrerPolicy
});
}
if (permissionsPolicy) {
headers.push({
key: 'Permissions-Policy',
value: permissionsPolicy
});
}
// Merge additional: same-key entries override the defaults above.
for (const extra of additional){
const i = headers.findIndex((h)=>h.key.toLowerCase() === extra.key.toLowerCase());
if (i >= 0) {
headers[i] = extra;
} else {
headers.push(extra);
}
}
return headers;
}
//# sourceMappingURL=buildSecurityHeaders.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js';
+3
View File
@@ -0,0 +1,3 @@
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/security/index.ts"],"sourcesContent":["export { buildSecurityHeaders } from './buildSecurityHeaders.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'\n"],"names":["buildSecurityHeaders"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,4BAA2B"}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@intecion/ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.0.3", "version": "1.0.6",
"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
+21 -1
View File
@@ -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' }
} }
+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>
}
@@ -0,0 +1,113 @@
/**
* A single HTTP header, in the shape Next.js next.config headers() expects.
*/
export type SecurityHeader = { key: string; value: string }
export type BuildSecurityHeadersArgs = {
/**
* Extra headers to append or override. Same-key entries replace the default,
* so you can e.g. add your project's Content-Security-Policy here — CSP is
* intentionally NOT a default because it depends on the project's own
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
*/
additional?: SecurityHeader[]
/**
* X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
* modern browsers, but X-Frame-Options is kept for older ones. Set to null
* to omit (e.g. if you set frame-ancestors in your project CSP).
*/
frameOptions?: 'DENY' | 'SAMEORIGIN' | null
/**
* Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and
* tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF
* in local/dev over plain HTTP, or you may lock the browser to https on
* localhost. Set the env guard in your next.config (see docs).
*/
hsts?: boolean
/** Add includeSubDomains to HSTS. Default true. */
hstsIncludeSubDomains?: boolean
/** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */
hstsMaxAge?: number
/** Add preload to HSTS (only if you'll submit to the preload list). Default false. */
hstsPreload?: boolean
/**
* Permissions-Policy. Default disables camera, microphone, geolocation. Pass
* your own string to override, or null to omit.
*/
permissionsPolicy?: null | string
/** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */
referrerPolicy?: null | string
}
/**
* Builds the generic, project-independent security headers every site should
* send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
* Permissions-Policy. These are identical across projects, so the plugin owns
* the boilerplate; the client spreads the result into next.config's headers().
*
* Content-Security-Policy is deliberately excluded: a useful CSP enumerates the
* exact domains a project loads from (its CDN, analytics, embeds), so it can't
* be generic without being either too loose (useless) or too strict (breaks the
* site). Add your project's CSP via `additional`.
*
* @example
* // next.config.ts
* import { buildSecurityHeaders } from '@intecion/ipal-kit'
* const securityHeaders = buildSecurityHeaders({
* hsts: process.env.NODE_ENV === 'production', // off in dev over http
* additional: [
* { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
* ],
* })
* const nextConfig = {
* async headers() {
* return [{ source: '/:path*', headers: securityHeaders }]
* },
* }
*/
export function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): SecurityHeader[] {
const {
additional = [],
frameOptions = 'DENY',
hsts = true,
hstsIncludeSubDomains = true,
hstsMaxAge = 63072000,
hstsPreload = false,
permissionsPolicy = 'camera=(), microphone=(), geolocation=()',
referrerPolicy = 'strict-origin-when-cross-origin',
} = args
const headers: SecurityHeader[] = []
if (hsts) {
const parts = [`max-age=${hstsMaxAge}`]
if (hstsIncludeSubDomains) {parts.push('includeSubDomains')}
if (hstsPreload) {parts.push('preload')}
headers.push({ key: 'Strict-Transport-Security', value: parts.join('; ') })
}
if (frameOptions) {
headers.push({ key: 'X-Frame-Options', value: frameOptions })
}
// Prevents MIME-type sniffing — always safe, no project specifics.
headers.push({ key: 'X-Content-Type-Options', value: 'nosniff' })
if (referrerPolicy) {
headers.push({ key: 'Referrer-Policy', value: referrerPolicy })
}
if (permissionsPolicy) {
headers.push({ key: 'Permissions-Policy', value: permissionsPolicy })
}
// Merge additional: same-key entries override the defaults above.
for (const extra of additional) {
const i = headers.findIndex((h) => h.key.toLowerCase() === extra.key.toLowerCase())
if (i >= 0) {headers[i] = extra}
else {headers.push(extra)}
}
return headers
}
+2
View File
@@ -0,0 +1,2 @@
export { buildSecurityHeaders } from './buildSecurityHeaders.js'
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'