Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a322230ae | ||
|
|
f919c288b2 | ||
|
|
21b948a4f8 | ||
|
|
d04271f942 | ||
|
|
4f08e9ea4d | ||
|
|
67347f1e34 | ||
|
|
75f2b6400d | ||
|
|
26aec1c5af | ||
|
|
0fe7ca0575 | ||
|
|
d745a764fe | ||
|
|
6c273118a2 | ||
|
|
a010a7368a | ||
|
|
08bd00f01f | ||
|
|
cef7f46718 | ||
|
|
ac48f1e9d1 | ||
|
|
3d8bc9019f | ||
|
|
cd65c2799f | ||
|
|
282be290cd | ||
|
|
080f41c7d8 | ||
|
|
37e417e057 | ||
|
|
05b3dc8cb1 |
Vendored
+3
@@ -1,4 +1,5 @@
|
|||||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
||||||
|
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.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
|
||||||
@@ -11,3 +12,5 @@ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentCont
|
|||||||
export type { CookieBannerClassNames } from '../modules/consent/client.js';
|
export type { CookieBannerClassNames } from '../modules/consent/client.js';
|
||||||
export { Turnstile } from '../modules/turnstile/client.js';
|
export { Turnstile } from '../modules/turnstile/client.js';
|
||||||
export type { TurnstileProps } from '../modules/turnstile/client.js';
|
export type { TurnstileProps } from '../modules/turnstile/client.js';
|
||||||
|
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
||||||
|
export type { FormNotificationTexts } from '../modules/notifications/types.js';
|
||||||
|
|||||||
Vendored
+3
@@ -1,4 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
||||||
|
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.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
|
||||||
@@ -8,5 +10,6 @@ export { Analytics } from '../modules/analytics/client.js';
|
|||||||
* client-only code.
|
* client-only code.
|
||||||
*/ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js';
|
*/ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js';
|
||||||
export { Turnstile } from '../modules/turnstile/client.js';
|
export { Turnstile } from '../modules/turnstile/client.js';
|
||||||
|
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
||||||
|
|
||||||
//# sourceMappingURL=client.js.map
|
//# sourceMappingURL=client.js.map
|
||||||
Vendored
+1
-1
@@ -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 { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.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\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile","resolveFormMessage"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC;AAG1D,SAASC,kBAAkB,QAAQ,iDAAgD"}
|
||||||
Vendored
+82
@@ -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
@@ -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"}
|
||||||
Vendored
+17
@@ -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
@@ -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,54 @@
|
|||||||
|
'use client';
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
export const MaskedField = (props)=>{
|
||||||
|
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {};
|
||||||
|
const [internalValue, setInternalValue] = useState(propValue ?? '');
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const label = typeof field?.label === 'string' ? field.label : field?.name ?? path;
|
||||||
|
const handleChange = (e)=>{
|
||||||
|
const newVal = e.target.value;
|
||||||
|
setInternalValue(newVal);
|
||||||
|
if (typeof propSetValue === 'function') {
|
||||||
|
propSetValue(newVal);
|
||||||
|
}
|
||||||
|
if (typeof propOnChange === 'function') {
|
||||||
|
propOnChange(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const currentValue = propValue !== undefined ? propValue : internalValue;
|
||||||
|
return /*#__PURE__*/ _jsxs("div", {
|
||||||
|
className: "field-type text",
|
||||||
|
children: [
|
||||||
|
label && /*#__PURE__*/ _jsx("label", {
|
||||||
|
className: "field-label",
|
||||||
|
children: label
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsxs("div", {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '.5rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("input", {
|
||||||
|
autoComplete: "off",
|
||||||
|
onChange: handleChange,
|
||||||
|
style: {
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
type: revealed ? 'text' : 'password',
|
||||||
|
value: currentValue ?? ''
|
||||||
|
}),
|
||||||
|
/*#__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'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = (props: any) => {\n const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}\n const [internalValue, setInternalValue] = useState(propValue ?? '')\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const newVal = e.target.value\n setInternalValue(newVal)\n if (typeof propSetValue === 'function') {\n propSetValue(newVal)\n }\n if (typeof propOnChange === 'function') {\n propOnChange(e)\n }\n }\n\n const currentValue = propValue !== undefined ? propValue : internalValue\n\n return (\n <div className=\"field-type text\">\n {label && <label className=\"field-label\">{label}</label>}\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={handleChange}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={currentValue ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\n\nexport default MaskedField\n"],"names":["useState","MaskedField","props","field","path","value","propValue","setValue","propSetValue","onChange","propOnChange","internalValue","setInternalValue","revealed","setRevealed","label","name","handleChange","e","newVal","target","currentValue","undefined","div","className","style","display","gap","input","autoComplete","flex","type","button","onClick","r"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAACC;IACpD,MAAM,EAAEC,KAAK,EAAEC,IAAI,EAAEC,OAAOC,SAAS,EAAEC,UAAUC,YAAY,EAAEC,UAAUC,YAAY,EAAE,GAAGR,SAAS,CAAC;IACpG,MAAM,CAACS,eAAeC,iBAAiB,GAAGZ,SAASM,aAAa;IAChE,MAAM,CAACO,UAAUC,YAAY,GAAGd,SAAS;IACzC,MAAMe,QAAQ,OAAOZ,OAAOY,UAAU,WAAWZ,MAAMY,KAAK,GAAIZ,OAAOa,QAAQZ;IAE/E,MAAMa,eAAe,CAACC;QACpB,MAAMC,SAASD,EAAEE,MAAM,CAACf,KAAK;QAC7BO,iBAAiBO;QACjB,IAAI,OAAOX,iBAAiB,YAAY;YACtCA,aAAaW;QACf;QACA,IAAI,OAAOT,iBAAiB,YAAY;YACtCA,aAAaQ;QACf;IACF;IAEA,MAAMG,eAAef,cAAcgB,YAAYhB,YAAYK;IAE3D,qBACE,MAACY;QAAIC,WAAU;;YACZT,uBAAS,KAACA;gBAAMS,WAAU;0BAAeT;;0BAC1C,MAACQ;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbpB,UAAUQ;wBACVQ,OAAO;4BAAEK,MAAM;wBAAE;wBACjBC,MAAMlB,WAAW,SAAS;wBAC1BR,OAAOgB,gBAAgB;;kCAEzB,KAACW;wBAAOC,SAAS,IAAMnB,YAAY,CAACoB,IAAM,CAACA;wBAAIH,MAAK;kCACjDlB,WAAW,SAAS;;;;;;AAK/B,EAAC;AAED,eAAeZ,YAAW"}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Admin UI: a small "send test email" tool for the SiteIntegrations email tab.
|
||||||
|
* Enter an address, click Send, and it POSTs to /api/ipal/test-email, which
|
||||||
|
* sends through the currently-selected transport (SMTP or Graph). Shows the
|
||||||
|
* result inline so you can confirm delivery — or read the exact error — without
|
||||||
|
* leaving the panel.
|
||||||
|
*
|
||||||
|
* Assigned via a `ui` field's admin.components.Field.
|
||||||
|
*/
|
||||||
|
export declare const TestEmailButton: () => import("react/jsx-runtime").JSX.Element;
|
||||||
|
export default TestEmailButton;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
/**
|
||||||
|
* Admin UI: a small "send test email" tool for the SiteIntegrations email tab.
|
||||||
|
* Enter an address, click Send, and it POSTs to /api/ipal/test-email, which
|
||||||
|
* sends through the currently-selected transport (SMTP or Graph). Shows the
|
||||||
|
* result inline so you can confirm delivery — or read the exact error — without
|
||||||
|
* leaving the panel.
|
||||||
|
*
|
||||||
|
* Assigned via a `ui` field's admin.components.Field.
|
||||||
|
*/ export const TestEmailButton = ()=>{
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
const [status, setStatus] = useState({
|
||||||
|
kind: 'idle'
|
||||||
|
});
|
||||||
|
const send = async ()=>{
|
||||||
|
if (!to.trim()) {
|
||||||
|
setStatus({
|
||||||
|
kind: 'error',
|
||||||
|
msg: 'Enter a recipient address.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({
|
||||||
|
kind: 'sending'
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ipal/test-email', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: to.trim()
|
||||||
|
}),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
setStatus({
|
||||||
|
kind: 'ok',
|
||||||
|
msg: data.message ?? 'Test email sent.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
kind: 'error',
|
||||||
|
msg: data.error ?? 'Send failed.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus({
|
||||||
|
kind: 'error',
|
||||||
|
msg: 'Request failed. Is the server running?'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return /*#__PURE__*/ _jsxs("div", {
|
||||||
|
className: "field-type",
|
||||||
|
style: {
|
||||||
|
marginTop: '1rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("label", {
|
||||||
|
className: "field-label",
|
||||||
|
children: "Send a test email"
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("p", {
|
||||||
|
style: {
|
||||||
|
fontSize: '.85rem',
|
||||||
|
marginTop: 0,
|
||||||
|
opacity: 0.7
|
||||||
|
},
|
||||||
|
children: "Sends through the transport selected above. Save your changes first."
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsxs("div", {
|
||||||
|
style: {
|
||||||
|
alignItems: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '.5rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("input", {
|
||||||
|
onChange: (e)=>setTo(e.target.value),
|
||||||
|
placeholder: "[email protected]",
|
||||||
|
style: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '220px'
|
||||||
|
},
|
||||||
|
type: "email",
|
||||||
|
value: to
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("button", {
|
||||||
|
className: "btn btn--style-secondary",
|
||||||
|
disabled: status.kind === 'sending',
|
||||||
|
onClick: send,
|
||||||
|
style: {
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
},
|
||||||
|
type: "button",
|
||||||
|
children: status.kind === 'sending' ? 'Sending…' : 'Send test'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
status.kind === 'ok' && /*#__PURE__*/ _jsxs("p", {
|
||||||
|
style: {
|
||||||
|
color: 'var(--theme-success-500, green)',
|
||||||
|
marginTop: '.5rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
"✓ ",
|
||||||
|
status.msg
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
status.kind === 'error' && /*#__PURE__*/ _jsxs("p", {
|
||||||
|
style: {
|
||||||
|
color: 'var(--theme-error-500, crimson)',
|
||||||
|
marginTop: '.5rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
"✗ ",
|
||||||
|
status.msg
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export default TestEmailButton;
|
||||||
|
|
||||||
|
//# sourceMappingURL=TestEmailButton.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/TestEmailButton.tsx"],"sourcesContent":["'use client'\n\nimport { useState } from 'react'\n\n/**\n * Admin UI: a small \"send test email\" tool for the SiteIntegrations email tab.\n * Enter an address, click Send, and it POSTs to /api/ipal/test-email, which\n * sends through the currently-selected transport (SMTP or Graph). Shows the\n * result inline so you can confirm delivery — or read the exact error — without\n * leaving the panel.\n *\n * Assigned via a `ui` field's admin.components.Field.\n */\nexport const TestEmailButton = () => {\n const [to, setTo] = useState('')\n const [status, setStatus] = useState<\n | { kind: 'error'; msg: string }\n | { kind: 'idle' }\n | { kind: 'ok'; msg: string }\n | { kind: 'sending' }\n >({ kind: 'idle' })\n\n const send = async () => {\n if (!to.trim()) {\n setStatus({ kind: 'error', msg: 'Enter a recipient address.' })\n return\n }\n setStatus({ kind: 'sending' })\n try {\n const res = await fetch('/api/ipal/test-email', {\n body: JSON.stringify({ to: to.trim() }),\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n method: 'POST',\n })\n const data = await res.json()\n if (data.ok) {\n setStatus({ kind: 'ok', msg: data.message ?? 'Test email sent.' })\n } else {\n setStatus({ kind: 'error', msg: data.error ?? 'Send failed.' })\n }\n } catch {\n setStatus({ kind: 'error', msg: 'Request failed. Is the server running?' })\n }\n }\n\n return (\n <div className=\"field-type\" style={{ marginTop: '1rem' }}>\n <label className=\"field-label\">Send a test email</label>\n <p style={{ fontSize: '.85rem', marginTop: 0, opacity: 0.7 }}>\n Sends through the transport selected above. Save your changes first.\n </p>\n <div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>\n <input\n onChange={(e) => setTo(e.target.value)}\n placeholder=\"[email protected]\"\n style={{ flex: 1, minWidth: '220px' }}\n type=\"email\"\n value={to}\n />\n <button\n className=\"btn btn--style-secondary\"\n disabled={status.kind === 'sending'}\n onClick={send}\n style={{ whiteSpace: 'nowrap' }}\n type=\"button\"\n >\n {status.kind === 'sending' ? 'Sending…' : 'Send test'}\n </button>\n </div>\n {status.kind === 'ok' && (\n <p style={{ color: 'var(--theme-success-500, green)', marginTop: '.5rem' }}>\n ✓ {status.msg}\n </p>\n )}\n {status.kind === 'error' && (\n <p style={{ color: 'var(--theme-error-500, crimson)', marginTop: '.5rem' }}>\n ✗ {status.msg}\n </p>\n )}\n </div>\n )\n}\n\nexport default TestEmailButton\n"],"names":["useState","TestEmailButton","to","setTo","status","setStatus","kind","send","trim","msg","res","fetch","body","JSON","stringify","credentials","headers","method","data","json","ok","message","error","div","className","style","marginTop","label","p","fontSize","opacity","alignItems","display","flexWrap","gap","input","onChange","e","target","value","placeholder","flex","minWidth","type","button","disabled","onClick","whiteSpace","color"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC;;;;;;;;CAQC,GACD,OAAO,MAAMC,kBAAkB;IAC7B,MAAM,CAACC,IAAIC,MAAM,GAAGH,SAAS;IAC7B,MAAM,CAACI,QAAQC,UAAU,GAAGL,SAK1B;QAAEM,MAAM;IAAO;IAEjB,MAAMC,OAAO;QACX,IAAI,CAACL,GAAGM,IAAI,IAAI;YACdH,UAAU;gBAAEC,MAAM;gBAASG,KAAK;YAA6B;YAC7D;QACF;QACAJ,UAAU;YAAEC,MAAM;QAAU;QAC5B,IAAI;YACF,MAAMI,MAAM,MAAMC,MAAM,wBAAwB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBAAEZ,IAAIA,GAAGM,IAAI;gBAAG;gBACrCO,aAAa;gBACbC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,QAAQ;YACV;YACA,MAAMC,OAAO,MAAMR,IAAIS,IAAI;YAC3B,IAAID,KAAKE,EAAE,EAAE;gBACXf,UAAU;oBAAEC,MAAM;oBAAMG,KAAKS,KAAKG,OAAO,IAAI;gBAAmB;YAClE,OAAO;gBACLhB,UAAU;oBAAEC,MAAM;oBAASG,KAAKS,KAAKI,KAAK,IAAI;gBAAe;YAC/D;QACF,EAAE,OAAM;YACNjB,UAAU;gBAAEC,MAAM;gBAASG,KAAK;YAAyC;QAC3E;IACF;IAEA,qBACE,MAACc;QAAIC,WAAU;QAAaC,OAAO;YAAEC,WAAW;QAAO;;0BACrD,KAACC;gBAAMH,WAAU;0BAAc;;0BAC/B,KAACI;gBAAEH,OAAO;oBAAEI,UAAU;oBAAUH,WAAW;oBAAGI,SAAS;gBAAI;0BAAG;;0BAG9D,MAACP;gBAAIE,OAAO;oBAAEM,YAAY;oBAAUC,SAAS;oBAAQC,UAAU;oBAAQC,KAAK;gBAAQ;;kCAClF,KAACC;wBACCC,UAAU,CAACC,IAAMlC,MAAMkC,EAAEC,MAAM,CAACC,KAAK;wBACrCC,aAAY;wBACZf,OAAO;4BAAEgB,MAAM;4BAAGC,UAAU;wBAAQ;wBACpCC,MAAK;wBACLJ,OAAOrC;;kCAET,KAAC0C;wBACCpB,WAAU;wBACVqB,UAAUzC,OAAOE,IAAI,KAAK;wBAC1BwC,SAASvC;wBACTkB,OAAO;4BAAEsB,YAAY;wBAAS;wBAC9BJ,MAAK;kCAEJvC,OAAOE,IAAI,KAAK,YAAY,aAAa;;;;YAG7CF,OAAOE,IAAI,KAAK,sBACf,MAACsB;gBAAEH,OAAO;oBAAEuB,OAAO;oBAAmCtB,WAAW;gBAAQ;;oBAAG;oBACvEtB,OAAOK,GAAG;;;YAGhBL,OAAOE,IAAI,KAAK,yBACf,MAACsB;gBAAEH,OAAO;oBAAEuB,OAAO;oBAAmCtB,WAAW;gBAAQ;;oBAAG;oBACvEtB,OAAOK,GAAG;;;;;AAKvB,EAAC;AAED,eAAeR,gBAAe"}
|
||||||
+36
-1
@@ -5,8 +5,30 @@
|
|||||||
* user), so all fields — including the password — stay editable in the admin
|
* user), so all fields — including the password — stay editable in the admin
|
||||||
* panel while remaining inaccessible to anonymous API requests.
|
* panel while remaining inaccessible to anonymous API requests.
|
||||||
*/ export const smtpFields = [
|
*/ export const smtpFields = [
|
||||||
|
{
|
||||||
|
name: 'emailTransport',
|
||||||
|
type: 'select',
|
||||||
|
admin: {
|
||||||
|
description: 'How outbound email is sent. "Microsoft Graph" is only available when configured by the administrator (Intecion).'
|
||||||
|
},
|
||||||
|
defaultValue: 'smtp',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'SMTP',
|
||||||
|
value: 'smtp'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Microsoft Graph (Exchange)',
|
||||||
|
value: 'graph'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'row',
|
type: 'row',
|
||||||
|
admin: {
|
||||||
|
// Hide SMTP fields when Graph is selected — they're not used then.
|
||||||
|
condition: (_, siblingData)=>siblingData?.emailTransport !== 'graph'
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'smtpHost',
|
name: 'smtpHost',
|
||||||
@@ -37,7 +59,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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,6 +79,15 @@
|
|||||||
admin: {
|
admin: {
|
||||||
description: 'Default "from" display name.'
|
description: 'Default "from" display name.'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'emailTest',
|
||||||
|
type: 'ui',
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: '@intecion/ipal-kit/client#TestEmailButton'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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 name: 'emailTransport',\n type: 'select',\n admin: {\n description:\n 'How outbound email is sent. \"Microsoft Graph\" is only available when configured by the administrator (Intecion).',\n // The Graph option only makes sense when agency credentials exist in env.\n // We can't read process.env in the admin UI directly, so a client project\n // that hasn't set up Graph should filter this option via integrationsFields\n // override, or simply leave it on 'smtp'. The adapter enforces the real\n // availability at send time regardless of what's selected here.\n },\n defaultValue: 'smtp',\n options: [\n { label: 'SMTP', value: 'smtp' },\n { label: 'Microsoft Graph (Exchange)', value: 'graph' },\n ],\n },\n {\n type: 'row',\n admin: {\n // Hide SMTP fields when Graph is selected — they're not used then.\n condition: (_, siblingData) => siblingData?.emailTransport !== 'graph',\n },\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 name: 'emailTest',\n type: 'ui',\n admin: {\n components: {\n Field: '@intecion/ipal-kit/client#TestEmailButton',\n },\n },\n },\n]\n"],"names":["smtpFields","name","type","admin","description","defaultValue","options","label","value","condition","_","siblingData","emailTransport","fields","placeholder","width","components","Field"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QAMJ;QACAC,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAQC,OAAO;YAAO;YAC/B;gBAAED,OAAO;gBAA8BC,OAAO;YAAQ;SACvD;IACH;IACA;QACEN,MAAM;QACNC,OAAO;YACL,mEAAmE;YACnEM,WAAW,CAACC,GAAGC,cAAgBA,aAAaC,mBAAmB;QACjE;QACAC,QAAQ;YACN;gBACEZ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEW,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEY,OAAO;gBAAM;gBACtBV,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,sEAAsE;YACtEY,YAAY;gBACVC,OAAO;YACT;QACF;IACF;IACA;QACEhB,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;YACLa,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"}
|
||||||
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||||
Vendored
+8
@@ -7,6 +7,10 @@ export type { ConsentCategory, ConsentState, ConsentTexts } from './modules/cons
|
|||||||
export type { ContentCollectionOption, ContentOption, ResolvedRoute, } from './modules/content/index.js';
|
export type { ContentCollectionOption, ContentOption, ResolvedRoute, } from './modules/content/index.js';
|
||||||
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute, } from './modules/content/index.js';
|
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute, } from './modules/content/index.js';
|
||||||
export type { ArchiveEntries } from './modules/content/index.js';
|
export type { ArchiveEntries } from './modules/content/index.js';
|
||||||
|
export { graphAdapter } from './modules/email/graphAdapter.js';
|
||||||
|
export type { GraphAdapterArgs } from './modules/email/graphAdapter.js';
|
||||||
|
export { mailAdapter } from './modules/email/mailAdapter.js';
|
||||||
|
export type { MailAdapterArgs } from './modules/email/mailAdapter.js';
|
||||||
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';
|
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';
|
||||||
export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';
|
export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';
|
||||||
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||||
@@ -20,10 +24,14 @@ export type { PagesOption, SystemPageRole } from './modules/pages/index.js';
|
|||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
||||||
export type { GlobalQueryOptions } from './modules/payload/index.js';
|
export type { GlobalQueryOptions } from './modules/payload/index.js';
|
||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
||||||
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
|
export { NOTIFICATION_FALLBACK, getNotificationTexts, resolveFormMessage, } from './modules/notifications/index.js';
|
||||||
|
export type { FormNotificationTexts, NotificationsData, NotificationTexts, } from './modules/notifications/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
export type { IpalOptions } from './types.js';
|
export type { IpalOptions } from './types.js';
|
||||||
|
|||||||
Vendored
+4
@@ -2,6 +2,8 @@ export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSe
|
|||||||
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
||||||
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js';
|
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js';
|
||||||
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js';
|
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js';
|
||||||
|
export { graphAdapter } from './modules/email/graphAdapter.js';
|
||||||
|
export { mailAdapter } from './modules/email/mailAdapter.js';
|
||||||
// Imported straight from the file, NOT from ./modules/email/index.js — that
|
// Imported straight from the file, NOT from ./modules/email/index.js — that
|
||||||
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
|
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
|
||||||
// Payload loads the config (or runs generate:importmap) as a plain Node script.
|
// Payload loads the config (or runs generate:importmap) as a plain Node script.
|
||||||
@@ -12,9 +14,11 @@ export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefiniti
|
|||||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
||||||
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
|
export { NOTIFICATION_FALLBACK, getNotificationTexts, resolveFormMessage } from './modules/notifications/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import type { PayloadEmailAdapter } from 'payload';
|
||||||
|
export type GraphAdapterArgs = {
|
||||||
|
fallbackFromAddress?: string;
|
||||||
|
fallbackFromName?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Payload email adapter that sends through Microsoft Graph (our Exchange),
|
||||||
|
* using app-only client-credentials auth. Drop-in alternative to
|
||||||
|
* panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail
|
||||||
|
* and the form-builder's submission emails work unchanged.
|
||||||
|
*
|
||||||
|
* Split of configuration (deliberate):
|
||||||
|
* - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.
|
||||||
|
* The client never sees or sets them — it's our Exchange, one mailbox
|
||||||
|
* (GRAPH_SENDER, e.g. [email protected]) for every project.
|
||||||
|
* - From-display + recipient = per-project, from the panel (SiteIntegrations),
|
||||||
|
* so an editor controls how the mail is labelled and where it lands.
|
||||||
|
*
|
||||||
|
* Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()
|
||||||
|
*
|
||||||
|
* Azure setup (one-time, our side): App registration → Mail.Send APPLICATION
|
||||||
|
* permission → admin consent → in Exchange, grant the app "Send As" on the
|
||||||
|
* shared mailbox GRAPH_SENDER.
|
||||||
|
*/
|
||||||
|
export declare const graphAdapter: (args?: GraphAdapterArgs) => PayloadEmailAdapter;
|
||||||
Vendored
+189
@@ -0,0 +1,189 @@
|
|||||||
|
import { getSiteIntegrations } from '../payload/index.js';
|
||||||
|
/** Reads + validates the agency Graph credentials from env. */ function readGraphEnv() {
|
||||||
|
const tenantId = process.env.GRAPH_TENANT_ID;
|
||||||
|
const clientId = process.env.GRAPH_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GRAPH_CLIENT_SECRET;
|
||||||
|
const sender = process.env.GRAPH_SENDER;
|
||||||
|
if (!tenantId || !clientId || !clientSecret || !sender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
sender,
|
||||||
|
tenantId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fetches an app-only access token via the OAuth2 client-credentials flow.
|
||||||
|
* Scope MUST be '.../.default' — passing 'Mail.Send' directly is rejected
|
||||||
|
* (AADSTS1002012). Tokens last ~1h; we fetch per send for simplicity and to
|
||||||
|
* avoid holding state in a possibly multi-instance deployment. If you send at
|
||||||
|
* high volume, cache by expiry.
|
||||||
|
*/ async function getAccessToken(env) {
|
||||||
|
const url = `https://login.microsoftonline.com/${env.tenantId}/oauth2/v2.0/token`;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: env.clientId,
|
||||||
|
client_secret: env.clientSecret,
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
scope: 'https://graph.microsoft.com/.default'
|
||||||
|
});
|
||||||
|
const res = await fetch(url, {
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text();
|
||||||
|
throw new Error(`Graph token request failed (${res.status}): ${detail}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error('Graph token response had no access_token');
|
||||||
|
}
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
/** Normalizes Payload's to/cc (string | string[] | Address[]) into Graph recipients. */ function toRecipients(value) {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const list = Array.isArray(value) ? value : [
|
||||||
|
value
|
||||||
|
];
|
||||||
|
return list.map((v)=>typeof v === 'string' ? v : v.address).filter((a)=>typeof a === 'string' && a.length > 0).map((address)=>({
|
||||||
|
emailAddress: {
|
||||||
|
address
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Payload email adapter that sends through Microsoft Graph (our Exchange),
|
||||||
|
* using app-only client-credentials auth. Drop-in alternative to
|
||||||
|
* panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail
|
||||||
|
* and the form-builder's submission emails work unchanged.
|
||||||
|
*
|
||||||
|
* Split of configuration (deliberate):
|
||||||
|
* - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.
|
||||||
|
* The client never sees or sets them — it's our Exchange, one mailbox
|
||||||
|
* (GRAPH_SENDER, e.g. [email protected]) for every project.
|
||||||
|
* - From-display + recipient = per-project, from the panel (SiteIntegrations),
|
||||||
|
* so an editor controls how the mail is labelled and where it lands.
|
||||||
|
*
|
||||||
|
* Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()
|
||||||
|
*
|
||||||
|
* Azure setup (one-time, our side): App registration → Mail.Send APPLICATION
|
||||||
|
* permission → admin consent → in Exchange, grant the app "Send As" on the
|
||||||
|
* shared mailbox GRAPH_SENDER.
|
||||||
|
*/ export const graphAdapter = (args = {})=>({ payload })=>({
|
||||||
|
name: 'ipal-graph',
|
||||||
|
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
|
||||||
|
defaultFromName: args.fallbackFromName ?? 'Website',
|
||||||
|
sendEmail: async (message)=>{
|
||||||
|
const env = readGraphEnv();
|
||||||
|
if (!env) {
|
||||||
|
payload.logger.error('[ipal] Email not sent: Graph is not configured. Set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER.');
|
||||||
|
return {
|
||||||
|
error: 'Graph is not configured (missing env vars).',
|
||||||
|
sent: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Display name on the From, WITHOUT triggering Send-As.
|
||||||
|
//
|
||||||
|
// The trick: we may set a `from` as long as its ADDRESS stays the sender
|
||||||
|
// mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
|
||||||
|
// demands Send-As when the from ADDRESS differs from the mailbox, so a
|
||||||
|
// same-address / custom-name From is allowed and gives each project its
|
||||||
|
// own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
|
||||||
|
//
|
||||||
|
// The panel's from-address becomes Reply-To (so replies reach the client),
|
||||||
|
// and the panel's from-name becomes the sender display name.
|
||||||
|
const panel = await getSiteIntegrations(payload);
|
||||||
|
const replyToAddress = panel.smtpFromAddress || undefined;
|
||||||
|
const senderName = panel.smtpFromName || undefined;
|
||||||
|
const to = toRecipients(message.to);
|
||||||
|
if (to.length === 0) {
|
||||||
|
payload.logger.error('[ipal] Email not sent: no valid recipient.');
|
||||||
|
return {
|
||||||
|
error: 'No valid recipient.',
|
||||||
|
sent: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Graph accepts either HTML or Text; Payload gives us html and/or text.
|
||||||
|
const isHtml = typeof message.html === 'string' && message.html.length > 0;
|
||||||
|
const content = isHtml ? String(message.html) : String(message.text ?? '');
|
||||||
|
// Reply-To: prefer whatever the caller set; otherwise the panel address.
|
||||||
|
const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [
|
||||||
|
{
|
||||||
|
emailAddress: {
|
||||||
|
address: replyToAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
] : [];
|
||||||
|
const graphMessage = {
|
||||||
|
body: {
|
||||||
|
content,
|
||||||
|
contentType: isHtml ? 'HTML' : 'Text'
|
||||||
|
},
|
||||||
|
subject: message.subject ?? '',
|
||||||
|
toRecipients: to,
|
||||||
|
...message.cc ? {
|
||||||
|
ccRecipients: toRecipients(message.cc)
|
||||||
|
} : {},
|
||||||
|
...message.bcc ? {
|
||||||
|
bccRecipients: toRecipients(message.bcc)
|
||||||
|
} : {},
|
||||||
|
// From with the sender's OWN address (no Send-As) plus an optional
|
||||||
|
// display name from the panel. Omit entirely when no name is set —
|
||||||
|
// Graph then uses the mailbox's default name.
|
||||||
|
...senderName ? {
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
name: senderName,
|
||||||
|
address: env.sender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} : {},
|
||||||
|
...replyTo.length > 0 ? {
|
||||||
|
replyTo
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(env);
|
||||||
|
// App-only: MUST target /users/{sender}, never /me.
|
||||||
|
const res = await fetch(`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(env.sender)}/sendMail`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: graphMessage,
|
||||||
|
saveToSentItems: false
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
// sendMail returns 202 Accepted with an empty body on success.
|
||||||
|
if (res.status === 202) {
|
||||||
|
return {
|
||||||
|
sent: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const detail = await res.text();
|
||||||
|
payload.logger.error(`[ipal] Graph sendMail failed (${res.status}): ${detail}`);
|
||||||
|
return {
|
||||||
|
error: `Graph sendMail failed (${res.status}).`,
|
||||||
|
sent: false
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
payload.logger.error(`[ipal] Graph send error: ${msg}`);
|
||||||
|
return {
|
||||||
|
error: 'Graph send error.',
|
||||||
|
sent: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//# sourceMappingURL=graphAdapter.js.map
|
||||||
+1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -1,2 +1,6 @@
|
|||||||
|
export { graphAdapter } from './graphAdapter.js';
|
||||||
|
export type { GraphAdapterArgs } from './graphAdapter.js';
|
||||||
|
export { mailAdapter } from './mailAdapter.js';
|
||||||
|
export type { MailAdapterArgs } from './mailAdapter.js';
|
||||||
export { sendEmail } from './sendEmail.js';
|
export { sendEmail } from './sendEmail.js';
|
||||||
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js';
|
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js';
|
||||||
|
|||||||
Vendored
+2
@@ -1,3 +1,5 @@
|
|||||||
|
export { graphAdapter } from './graphAdapter.js';
|
||||||
|
export { mailAdapter } from './mailAdapter.js';
|
||||||
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
|
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
|
||||||
// so this must never be imported from a client component.
|
// so this must never be imported from a client component.
|
||||||
export { sendEmail } from './sendEmail.js';
|
export { sendEmail } from './sendEmail.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["sendEmail"],"mappings":"AAAA,mFAAmF;AACnF,0DAA0D;AAC1D,SAASA,SAAS,QAAQ,iBAAgB"}
|
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["export { graphAdapter } from './graphAdapter.js'\nexport type { GraphAdapterArgs } from './graphAdapter.js'\nexport { mailAdapter } from './mailAdapter.js'\nexport type { MailAdapterArgs } from './mailAdapter.js'\n// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["graphAdapter","mailAdapter","sendEmail"],"mappings":"AAAA,SAASA,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,mFAAmF;AACnF,0DAA0D;AAC1D,SAASC,SAAS,QAAQ,iBAAgB"}
|
||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import type { PayloadEmailAdapter } from 'payload';
|
||||||
|
import { type GraphAdapterArgs } from './graphAdapter.js';
|
||||||
|
import { type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js';
|
||||||
|
export type MailAdapterArgs = {
|
||||||
|
fallbackFromAddress?: string;
|
||||||
|
fallbackFromName?: string;
|
||||||
|
graph?: GraphAdapterArgs;
|
||||||
|
smtp?: PanelSmtpAdapterArgs;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Dispatcher email adapter: wired into the config ONCE, but picks the transport
|
||||||
|
* (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.
|
||||||
|
* This is what makes the choice switchable in the panel — Payload builds the
|
||||||
|
* email adapter at boot and can't swap it at runtime, so instead of choosing
|
||||||
|
* between two adapters at boot we install one that delegates on every send.
|
||||||
|
*
|
||||||
|
* Availability guard: Graph only runs if its agency credentials exist in env
|
||||||
|
* (this is *our* Exchange). If the panel says 'graph' but env isn't set up,
|
||||||
|
* we DON'T silently fail — we log clearly and fall back to SMTP, so a client
|
||||||
|
* flipping the switch without the backing config still gets mail out (over SMTP)
|
||||||
|
* rather than silent nothing. If neither is usable, the send reports an error.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // payload.config.ts
|
||||||
|
* import { mailAdapter } from '@intecion/ipal-kit'
|
||||||
|
* email: mailAdapter()
|
||||||
|
*/
|
||||||
|
export declare const mailAdapter: (args?: MailAdapterArgs) => PayloadEmailAdapter;
|
||||||
Vendored
+58
@@ -0,0 +1,58 @@
|
|||||||
|
import { getSiteIntegrations } from '../payload/index.js';
|
||||||
|
import { graphAdapter } from './graphAdapter.js';
|
||||||
|
import { panelSmtpAdapter } from './panelSmtpAdapter.js';
|
||||||
|
/** True when the agency Graph credentials are present in the environment. */ function graphAvailable() {
|
||||||
|
return Boolean(process.env.GRAPH_TENANT_ID && process.env.GRAPH_CLIENT_ID && process.env.GRAPH_CLIENT_SECRET && process.env.GRAPH_SENDER);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Dispatcher email adapter: wired into the config ONCE, but picks the transport
|
||||||
|
* (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.
|
||||||
|
* This is what makes the choice switchable in the panel — Payload builds the
|
||||||
|
* email adapter at boot and can't swap it at runtime, so instead of choosing
|
||||||
|
* between two adapters at boot we install one that delegates on every send.
|
||||||
|
*
|
||||||
|
* Availability guard: Graph only runs if its agency credentials exist in env
|
||||||
|
* (this is *our* Exchange). If the panel says 'graph' but env isn't set up,
|
||||||
|
* we DON'T silently fail — we log clearly and fall back to SMTP, so a client
|
||||||
|
* flipping the switch without the backing config still gets mail out (over SMTP)
|
||||||
|
* rather than silent nothing. If neither is usable, the send reports an error.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // payload.config.ts
|
||||||
|
* import { mailAdapter } from '@intecion/ipal-kit'
|
||||||
|
* email: mailAdapter()
|
||||||
|
*/ export const mailAdapter = (args = {})=>(deps)=>{
|
||||||
|
// Build both delegates once; each still resolves its own config per send.
|
||||||
|
const smtp = panelSmtpAdapter({
|
||||||
|
fallbackFromAddress: args.fallbackFromAddress,
|
||||||
|
fallbackFromName: args.fallbackFromName,
|
||||||
|
...args.smtp
|
||||||
|
})(deps);
|
||||||
|
const graph = graphAdapter({
|
||||||
|
fallbackFromAddress: args.fallbackFromAddress,
|
||||||
|
fallbackFromName: args.fallbackFromName,
|
||||||
|
...args.graph
|
||||||
|
})(deps);
|
||||||
|
const { payload } = deps;
|
||||||
|
return {
|
||||||
|
name: 'ipal-mail-dispatcher',
|
||||||
|
defaultFromAddress: smtp.defaultFromAddress,
|
||||||
|
defaultFromName: smtp.defaultFromName,
|
||||||
|
sendEmail: async (message)=>{
|
||||||
|
const settings = await getSiteIntegrations(payload);
|
||||||
|
const choice = settings.emailTransport ?? 'smtp';
|
||||||
|
if (choice === 'graph') {
|
||||||
|
if (graphAvailable()) {
|
||||||
|
return graph.sendEmail(message);
|
||||||
|
}
|
||||||
|
// Panel asked for Graph but the agency creds aren't configured for
|
||||||
|
// this project. Fall back to SMTP rather than silently dropping mail.
|
||||||
|
payload.logger.warn('[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.');
|
||||||
|
return smtp.sendEmail(message);
|
||||||
|
}
|
||||||
|
return smtp.sendEmail(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=mailAdapter.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/email/mailAdapter.ts"],"sourcesContent":["import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'\n\nimport { getSiteIntegrations } from '../payload/index.js'\nimport { graphAdapter, type GraphAdapterArgs } from './graphAdapter.js'\nimport { panelSmtpAdapter, type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js'\n\ntype TransportIntegrations = {\n /** 'smtp' | 'graph' — chosen by the editor in SiteIntegrations. */\n emailTransport?: 'graph' | 'smtp' | null\n}\n\nexport type MailAdapterArgs = {\n fallbackFromAddress?: string\n fallbackFromName?: string\n graph?: GraphAdapterArgs\n smtp?: PanelSmtpAdapterArgs\n}\n\n/** True when the agency Graph credentials are present in the environment. */\nfunction graphAvailable(): boolean {\n return Boolean(\n process.env.GRAPH_TENANT_ID &&\n process.env.GRAPH_CLIENT_ID &&\n process.env.GRAPH_CLIENT_SECRET &&\n process.env.GRAPH_SENDER,\n )\n}\n\n/**\n * Dispatcher email adapter: wired into the config ONCE, but picks the transport\n * (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.\n * This is what makes the choice switchable in the panel — Payload builds the\n * email adapter at boot and can't swap it at runtime, so instead of choosing\n * between two adapters at boot we install one that delegates on every send.\n *\n * Availability guard: Graph only runs if its agency credentials exist in env\n * (this is *our* Exchange). If the panel says 'graph' but env isn't set up,\n * we DON'T silently fail — we log clearly and fall back to SMTP, so a client\n * flipping the switch without the backing config still gets mail out (over SMTP)\n * rather than silent nothing. If neither is usable, the send reports an error.\n *\n * @example\n * // payload.config.ts\n * import { mailAdapter } from '@intecion/ipal-kit'\n * email: mailAdapter()\n */\nexport const mailAdapter =\n (args: MailAdapterArgs = {}): PayloadEmailAdapter =>\n (deps) => {\n // Build both delegates once; each still resolves its own config per send.\n const smtp = panelSmtpAdapter({\n fallbackFromAddress: args.fallbackFromAddress,\n fallbackFromName: args.fallbackFromName,\n ...args.smtp,\n })(deps)\n const graph = graphAdapter({\n fallbackFromAddress: args.fallbackFromAddress,\n fallbackFromName: args.fallbackFromName,\n ...args.graph,\n })(deps)\n\n const { payload } = deps\n\n return {\n name: 'ipal-mail-dispatcher',\n defaultFromAddress: smtp.defaultFromAddress,\n defaultFromName: smtp.defaultFromName,\n\n sendEmail: async (message: SendEmailOptions) => {\n const settings = await getSiteIntegrations<TransportIntegrations>(payload)\n const choice = settings.emailTransport ?? 'smtp'\n\n if (choice === 'graph') {\n if (graphAvailable()) {\n return graph.sendEmail(message)\n }\n // Panel asked for Graph but the agency creds aren't configured for\n // this project. Fall back to SMTP rather than silently dropping mail.\n payload.logger.warn(\n '[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.',\n )\n return smtp.sendEmail(message)\n }\n\n return smtp.sendEmail(message)\n },\n }\n }\n"],"names":["getSiteIntegrations","graphAdapter","panelSmtpAdapter","graphAvailable","Boolean","process","env","GRAPH_TENANT_ID","GRAPH_CLIENT_ID","GRAPH_CLIENT_SECRET","GRAPH_SENDER","mailAdapter","args","deps","smtp","fallbackFromAddress","fallbackFromName","graph","payload","name","defaultFromAddress","defaultFromName","sendEmail","message","settings","choice","emailTransport","logger","warn"],"mappings":"AAEA,SAASA,mBAAmB,QAAQ,sBAAqB;AACzD,SAASC,YAAY,QAA+B,oBAAmB;AACvE,SAASC,gBAAgB,QAAmC,wBAAuB;AAcnF,2EAA2E,GAC3E,SAASC;IACP,OAAOC,QACLC,QAAQC,GAAG,CAACC,eAAe,IAC3BF,QAAQC,GAAG,CAACE,eAAe,IAC3BH,QAAQC,GAAG,CAACG,mBAAmB,IAC/BJ,QAAQC,GAAG,CAACI,YAAY;AAE5B;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,cACX,CAACC,OAAwB,CAAC,CAAC,GAC3B,CAACC;QACC,0EAA0E;QAC1E,MAAMC,OAAOZ,iBAAiB;YAC5Ba,qBAAqBH,KAAKG,mBAAmB;YAC7CC,kBAAkBJ,KAAKI,gBAAgB;YACvC,GAAGJ,KAAKE,IAAI;QACd,GAAGD;QACH,MAAMI,QAAQhB,aAAa;YACzBc,qBAAqBH,KAAKG,mBAAmB;YAC7CC,kBAAkBJ,KAAKI,gBAAgB;YACvC,GAAGJ,KAAKK,KAAK;QACf,GAAGJ;QAEH,MAAM,EAAEK,OAAO,EAAE,GAAGL;QAEpB,OAAO;YACLM,MAAM;YACNC,oBAAoBN,KAAKM,kBAAkB;YAC3CC,iBAAiBP,KAAKO,eAAe;YAErCC,WAAW,OAAOC;gBAChB,MAAMC,WAAW,MAAMxB,oBAA2CkB;gBAClE,MAAMO,SAASD,SAASE,cAAc,IAAI;gBAE1C,IAAID,WAAW,SAAS;oBACtB,IAAItB,kBAAkB;wBACpB,OAAOc,MAAMK,SAAS,CAACC;oBACzB;oBACA,mEAAmE;oBACnE,sEAAsE;oBACtEL,QAAQS,MAAM,CAACC,IAAI,CACjB;oBAEF,OAAOd,KAAKQ,SAAS,CAACC;gBACxB;gBAEA,OAAOT,KAAKQ,SAAS,CAACC;YACxB;QACF;IACF,EAAC"}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Endpoint } from 'payload';
|
||||||
|
/**
|
||||||
|
* Custom endpoint: send a test email to a given address through whatever
|
||||||
|
* transport is currently active (SMTP or Graph — mailAdapter reads the panel
|
||||||
|
* setting per send, so the test exercises the REAL path a form email would
|
||||||
|
* take). Mounted at POST /api/ipal/test-email.
|
||||||
|
*
|
||||||
|
* Admin-only: uses payload.sendEmail (server-side), and requires an
|
||||||
|
* authenticated admin user — a test-send button must never be open to the
|
||||||
|
* public (it would be an open relay / spam vector).
|
||||||
|
*
|
||||||
|
* Returns the adapter's own result so the panel can show exactly what happened,
|
||||||
|
* including the transport-specific error (SMTP auth failure, Graph 401, etc.).
|
||||||
|
*/
|
||||||
|
export declare const testEmailEndpoint: Endpoint;
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
import { addDataAndFileToRequest } from 'payload';
|
||||||
|
/**
|
||||||
|
* Custom endpoint: send a test email to a given address through whatever
|
||||||
|
* transport is currently active (SMTP or Graph — mailAdapter reads the panel
|
||||||
|
* setting per send, so the test exercises the REAL path a form email would
|
||||||
|
* take). Mounted at POST /api/ipal/test-email.
|
||||||
|
*
|
||||||
|
* Admin-only: uses payload.sendEmail (server-side), and requires an
|
||||||
|
* authenticated admin user — a test-send button must never be open to the
|
||||||
|
* public (it would be an open relay / spam vector).
|
||||||
|
*
|
||||||
|
* Returns the adapter's own result so the panel can show exactly what happened,
|
||||||
|
* including the transport-specific error (SMTP auth failure, Graph 401, etc.).
|
||||||
|
*/ export const testEmailEndpoint = {
|
||||||
|
handler: async (req)=>{
|
||||||
|
// Auth: only signed-in admins may trigger a send.
|
||||||
|
if (!req.user) {
|
||||||
|
return Response.json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
ok: false
|
||||||
|
}, {
|
||||||
|
status: 401
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await addDataAndFileToRequest(req);
|
||||||
|
const to = req.data?.to?.trim();
|
||||||
|
if (!to || !/^[^@\s]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(to)) {
|
||||||
|
return Response.json({
|
||||||
|
error: 'Provide a valid recipient address.',
|
||||||
|
ok: false
|
||||||
|
}, {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const info = await req.payload.sendEmail({
|
||||||
|
html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',
|
||||||
|
subject: 'ipal-kit — test email',
|
||||||
|
text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',
|
||||||
|
to
|
||||||
|
});
|
||||||
|
// Payload's sendEmail resolves with the adapter's result. Our adapters
|
||||||
|
// return { sent: boolean, error?: string }; nodemailer returns info with
|
||||||
|
// messageId. Normalize to a simple ok/message for the panel.
|
||||||
|
const sent = info && typeof info === 'object' && 'sent' in info ? info.sent !== false : true;
|
||||||
|
if (!sent) {
|
||||||
|
const error = info?.error ?? 'Send failed (see server logs).';
|
||||||
|
return Response.json({
|
||||||
|
error,
|
||||||
|
ok: false
|
||||||
|
}, {
|
||||||
|
status: 502
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
message: `Test email sent to ${to}.`,
|
||||||
|
ok: true
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
req.payload.logger.error(`[ipal] Test email failed: ${message}`);
|
||||||
|
return Response.json({
|
||||||
|
error: 'Send failed. Check transport settings and server logs.',
|
||||||
|
ok: false
|
||||||
|
}, {
|
||||||
|
status: 502
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
method: 'post',
|
||||||
|
path: '/ipal/test-email'
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=testEmailEndpoint.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../../src/modules/email/test/testEmailEndpoint.ts"],"sourcesContent":["import type { Endpoint, PayloadRequest } from 'payload'\n\nimport { addDataAndFileToRequest } from 'payload'\n\n/**\n * Custom endpoint: send a test email to a given address through whatever\n * transport is currently active (SMTP or Graph — mailAdapter reads the panel\n * setting per send, so the test exercises the REAL path a form email would\n * take). Mounted at POST /api/ipal/test-email.\n *\n * Admin-only: uses payload.sendEmail (server-side), and requires an\n * authenticated admin user — a test-send button must never be open to the\n * public (it would be an open relay / spam vector).\n *\n * Returns the adapter's own result so the panel can show exactly what happened,\n * including the transport-specific error (SMTP auth failure, Graph 401, etc.).\n */\nexport const testEmailEndpoint: Endpoint = {\n handler: async (req: PayloadRequest) => {\n // Auth: only signed-in admins may trigger a send.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized', ok: false }, { status: 401 })\n }\n\n await addDataAndFileToRequest(req)\n const to = (req.data?.to as string | undefined)?.trim()\n\n if (!to || !/^[^@\\s]+@[^\\s@][^\\s.@]*\\.[^\\s@]+$/.test(to)) {\n return Response.json(\n { error: 'Provide a valid recipient address.', ok: false },\n { status: 400 },\n )\n }\n\n try {\n const info = await req.payload.sendEmail({\n html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',\n subject: 'ipal-kit — test email',\n text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',\n to,\n })\n\n // Payload's sendEmail resolves with the adapter's result. Our adapters\n // return { sent: boolean, error?: string }; nodemailer returns info with\n // messageId. Normalize to a simple ok/message for the panel.\n const sent =\n info && typeof info === 'object' && 'sent' in info\n ? (info as { sent?: boolean }).sent !== false\n : true\n\n if (!sent) {\n const error = (info as { error?: string })?.error ?? 'Send failed (see server logs).'\n return Response.json({ error, ok: false }, { status: 502 })\n }\n\n return Response.json({ message: `Test email sent to ${to}.`, ok: true })\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n req.payload.logger.error(`[ipal] Test email failed: ${message}`)\n return Response.json(\n { error: 'Send failed. Check transport settings and server logs.', ok: false },\n { status: 502 },\n )\n }\n },\n method: 'post',\n path: '/ipal/test-email',\n}\n"],"names":["addDataAndFileToRequest","testEmailEndpoint","handler","req","user","Response","json","error","ok","status","to","data","trim","test","info","payload","sendEmail","html","subject","text","sent","message","err","Error","String","logger","method","path"],"mappings":"AAEA,SAASA,uBAAuB,QAAQ,UAAS;AAEjD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,oBAA8B;IACzCC,SAAS,OAAOC;QACd,kDAAkD;QAClD,IAAI,CAACA,IAAIC,IAAI,EAAE;YACb,OAAOC,SAASC,IAAI,CAAC;gBAAEC,OAAO;gBAAgBC,IAAI;YAAM,GAAG;gBAAEC,QAAQ;YAAI;QAC3E;QAEA,MAAMT,wBAAwBG;QAC9B,MAAMO,KAAMP,IAAIQ,IAAI,EAAED,IAA2BE;QAEjD,IAAI,CAACF,MAAM,CAAC,oCAAoCG,IAAI,CAACH,KAAK;YACxD,OAAOL,SAASC,IAAI,CAClB;gBAAEC,OAAO;gBAAsCC,IAAI;YAAM,GACzD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,IAAI;YACF,MAAMK,OAAO,MAAMX,IAAIY,OAAO,CAACC,SAAS,CAAC;gBACvCC,MAAM;gBACNC,SAAS;gBACTC,MAAM;gBACNT;YACF;YAEA,uEAAuE;YACvE,yEAAyE;YACzE,6DAA6D;YAC7D,MAAMU,OACJN,QAAQ,OAAOA,SAAS,YAAY,UAAUA,OAC1C,AAACA,KAA4BM,IAAI,KAAK,QACtC;YAEN,IAAI,CAACA,MAAM;gBACT,MAAMb,QAAQ,AAACO,MAA6BP,SAAS;gBACrD,OAAOF,SAASC,IAAI,CAAC;oBAAEC;oBAAOC,IAAI;gBAAM,GAAG;oBAAEC,QAAQ;gBAAI;YAC3D;YAEA,OAAOJ,SAASC,IAAI,CAAC;gBAAEe,SAAS,CAAC,mBAAmB,EAAEX,GAAG,CAAC,CAAC;gBAAEF,IAAI;YAAK;QACxE,EAAE,OAAOc,KAAK;YACZ,MAAMD,UAAUC,eAAeC,QAAQD,IAAID,OAAO,GAAGG,OAAOF;YAC5DnB,IAAIY,OAAO,CAACU,MAAM,CAAClB,KAAK,CAAC,CAAC,0BAA0B,EAAEc,SAAS;YAC/D,OAAOhB,SAASC,IAAI,CAClB;gBAAEC,OAAO;gBAA0DC,IAAI;YAAM,GAC7E;gBAAEC,QAAQ;YAAI;QAElB;IACF;IACAiB,QAAQ;IACRC,MAAM;AACR,EAAC"}
|
||||||
Vendored
+9
-2
@@ -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,
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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"}
|
||||||
+22
-2
@@ -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',
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+8
-1
@@ -1,6 +1,13 @@
|
|||||||
import type { I18nConfig } from './types.js';
|
import type { I18nConfig } from './types.js';
|
||||||
/** Cookie name the template uses to persist a visitor's locale choice. */
|
/** Cookie name the template uses to persist a visitor's locale choice. */
|
||||||
export declare const LOCALE_COOKIE_NAME = "ipal-locale";
|
/**
|
||||||
|
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||||
|
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||||
|
* other libraries that read the active locale (instead of a plugin-specific
|
||||||
|
* name). Written only under functional consent; cleared when that consent is
|
||||||
|
* withdrawn (see consent cookieMap).
|
||||||
|
*/
|
||||||
|
export declare const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
|
||||||
type NegotiateLocaleArgs = {
|
type NegotiateLocaleArgs = {
|
||||||
/** Raw Accept-Language header value */
|
/** Raw Accept-Language header value */
|
||||||
acceptLanguage?: null | string;
|
acceptLanguage?: null | string;
|
||||||
|
|||||||
Vendored
+7
-1
@@ -1,5 +1,11 @@
|
|||||||
import { getLocaleCodes, isValidLocale } from './helpers.js';
|
import { getLocaleCodes, isValidLocale } from './helpers.js';
|
||||||
/** Cookie name the template uses to persist a visitor's locale choice. */ export const LOCALE_COOKIE_NAME = 'ipal-locale';
|
/** Cookie name the template uses to persist a visitor's locale choice. */ /**
|
||||||
|
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||||
|
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||||
|
* other libraries that read the active locale (instead of a plugin-specific
|
||||||
|
* name). Written only under functional consent; cleared when that consent is
|
||||||
|
* withdrawn (see consent cookieMap).
|
||||||
|
*/ export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE';
|
||||||
/**
|
/**
|
||||||
* Resolves which locale to serve, in priority order:
|
* Resolves which locale to serve, in priority order:
|
||||||
* 1. Cookie (explicit prior choice)
|
* 1. Cookie (explicit prior choice)
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+17
@@ -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
@@ -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"}
|
||||||
@@ -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"}
|
||||||
Vendored
+5
@@ -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
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
Vendored
+6
@@ -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
@@ -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
@@ -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
@@ -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
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
||||||
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js';
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
||||||
|
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+12
-1
@@ -1,8 +1,10 @@
|
|||||||
import { buildCookieSettings } from './globals/CookieSettings/index.js';
|
import { buildCookieSettings } from './globals/CookieSettings/index.js';
|
||||||
|
import { buildNotifications } from './globals/Notifications/index.js';
|
||||||
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js';
|
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js';
|
||||||
import { buildSiteSettings } from './globals/SiteSettings/index.js';
|
import { buildSiteSettings } from './globals/SiteSettings/index.js';
|
||||||
import { injectRoles } from './modules/access/index.js';
|
import { injectRoles } from './modules/access/index.js';
|
||||||
import { buildArchiveFields } from './modules/content/index.js';
|
import { buildArchiveFields } from './modules/content/index.js';
|
||||||
|
import { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js';
|
||||||
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||||
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js';
|
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js';
|
||||||
import { buildSystemPagesFields } from './modules/pages/index.js';
|
import { buildSystemPagesFields } from './modules/pages/index.js';
|
||||||
@@ -83,7 +85,16 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
|
|||||||
buildSiteIntegrations({
|
buildSiteIntegrations({
|
||||||
additionalFields: options.integrationsFields
|
additionalFields: options.integrationsFields
|
||||||
}),
|
}),
|
||||||
buildCookieSettings()
|
buildCookieSettings(),
|
||||||
|
buildNotifications()
|
||||||
|
];
|
||||||
|
// --- endpoints ---
|
||||||
|
// Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe
|
||||||
|
// message through the currently selected transport, so the panel's "send
|
||||||
|
// test" button can confirm delivery without leaving the admin UI.
|
||||||
|
config.endpoints = [
|
||||||
|
...config.endpoints ?? [],
|
||||||
|
testEmailEndpoint
|
||||||
];
|
];
|
||||||
// --- hooks: onInit ---
|
// --- hooks: onInit ---
|
||||||
const incomingOnInit = config.onInit;
|
const incomingOnInit = config.onInit;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,239 @@
|
|||||||
|
# Playbook wdrożenia — ipal-kit
|
||||||
|
|
||||||
|
Sztywna procedura dla pracownika albo AI (Antigravity). Mówi CO robić, W JAKIEJ
|
||||||
|
KOLEJNOŚCI, i CZYM SIĘ KIEROWAĆ. Zasady są twarde, przykłady realne — wzięte z
|
||||||
|
faktycznych błędów, które się zdarzyły. Odstępstwa tylko za świadomą decyzją.
|
||||||
|
|
||||||
|
Powiązane: [publishing.md](./publishing.md) (cykl publikacji), [getting-started.md](./getting-started.md)
|
||||||
|
(nowy projekt), ../ANTIGRAVITY-ZASADY-AGENT.md (zasady dla AI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ZŁOTE ZASADY
|
||||||
|
|
||||||
|
1. **Nic na sztywno.** Tekst, obraz, link, dane firmy → panel/baza, nie kod.
|
||||||
|
2. **Logika w pluginie, projekt podłącza.** Jeśli piszesz w projekcie coś, co
|
||||||
|
robi już plugin — zatrzymaj się, użyj pluginu.
|
||||||
|
3. **Next 16 = proxy.ts.** NIGDY middleware.ts. Jeśli istnieje — usuń.
|
||||||
|
4. **Weryfikuj każdy etap grepem.** Nie zakładaj, że zadziałało. Sprawdź.
|
||||||
|
5. **Napraw u źródła, nie łataj.** Bez `as any`, `@ts-ignore`, kopii logiki.
|
||||||
|
6. **Zmiana w pluginie nie działa, dopóki nie: build → publish → wciągnięcie.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ A — ŁAŃCUCH ZMIANY W PLUGINIE (najważniejsze)
|
||||||
|
|
||||||
|
Najczęstsze źródło frustracji tej sesji: „zmieniłem kod, a nie działa". Prawie
|
||||||
|
zawsze przyczyna: **przerwany łańcuch**. Zmiana w pluginie przechodzi przez
|
||||||
|
PIĘĆ etapów. Pominięcie któregokolwiek = stara wersja w projekcie.
|
||||||
|
|
||||||
|
```
|
||||||
|
źródła (src) → build (dist) → publish (rejestr) → wciągnięcie (node_modules) → restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sztywna procedura zmiany w pluginie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/payload-cms/ipal-kit
|
||||||
|
|
||||||
|
# 1. ŹRÓDŁA — nanieś zmianę, ZWERYFIKUJ że jest
|
||||||
|
grep -c "<symbol-zmiany>" src/<ścieżka> # MUSI być >0
|
||||||
|
|
||||||
|
# 2. BUILD — zbuduj, ZWERYFIKUJ że dist ma zmianę
|
||||||
|
pnpm build
|
||||||
|
grep -c "<symbol-zmiany>" dist/<ścieżka> # MUSI być >0
|
||||||
|
|
||||||
|
# 3. COMMIT (PRZED version — inaczej "working directory not clean")
|
||||||
|
git add -A && git commit -m "opis"
|
||||||
|
|
||||||
|
# 4. VERSION + PUBLISH
|
||||||
|
npm version patch # czyste repo wymagane
|
||||||
|
npm publish
|
||||||
|
|
||||||
|
# 5. PUSH
|
||||||
|
git push && git push --tags
|
||||||
|
|
||||||
|
# 6. PROJEKT — wciągnij, ZWERYFIKUJ że node_modules ma zmianę
|
||||||
|
cd ~/<projekt>
|
||||||
|
pnpm add @intecion/ipal-kit@<nowa-wersja>
|
||||||
|
grep -c "<symbol-zmiany>" node_modules/@intecion/ipal-kit/dist/<ścieżka> # MUSI być >0
|
||||||
|
|
||||||
|
# 7. RESTART dev (Payload buduje adaptery/config przy starcie!)
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### TRZY punkty kontrolne grep (nie pomijaj żadnego)
|
||||||
|
|
||||||
|
| Etap | Grep | Jeśli 0 |
|
||||||
|
|---|---|---|
|
||||||
|
| po edycji | `src/...` | zmiana nie zapisana / zły plik |
|
||||||
|
| po build | `dist/...` | build nie złapał / błąd typów |
|
||||||
|
| po pnpm add | `node_modules/...` | projekt ma starą wersję |
|
||||||
|
|
||||||
|
**Realny przykład (z tej sesji):** `buildSecurityHeaders is not a function`.
|
||||||
|
Przyczyna: moduł istniał w `src`, ale NIE był wyeksportowany w `src/index.ts`
|
||||||
|
→ `dist` go nie miał → import w projekcie = undefined. Grep `dist/index.js`
|
||||||
|
pokazał 0. Naprawa: dodać eksport, przejść łańcuch od nowa.
|
||||||
|
|
||||||
|
### Pułapki kolejności (realne błędy sesji)
|
||||||
|
|
||||||
|
- **`npm version` przed commitem** → "Git working directory not clean". ZAWSZE
|
||||||
|
commit przed version.
|
||||||
|
- **`npm publish` bez `pnpm build`** → publikujesz STARY dist. ZAWSZE build przed
|
||||||
|
publish, grep dist po buildzie.
|
||||||
|
- **`pnpm add` przy działającym dev** → proces ma stary adapter w pamięci.
|
||||||
|
Payload czyta email/config przy starcie. ZAWSZE restart po wciągnięciu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ B — GREP JAKO NARZĘDZIE (jak weryfikować dobrze)
|
||||||
|
|
||||||
|
Grep był w tej sesji głównym narzędziem diagnozy. Ale trzeba go używać mądrze.
|
||||||
|
|
||||||
|
### Reguła: grepuj TOKENY, nie całe frazy z kolejnością
|
||||||
|
|
||||||
|
**Realny błąd:** grep `"env.sender, name: senderName"` dał 0, choć kod był OK —
|
||||||
|
bo plik miał odwróconą kolejność kluczy (`name: senderName, address: env.sender`).
|
||||||
|
Obiekt JS ignoruje kolejność, ale grep nie.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ŹLE — zależny od kolejności/formatowania:
|
||||||
|
grep -c "env.sender, name: senderName" plik.ts # 0 mimo poprawnego kodu
|
||||||
|
|
||||||
|
# DOBRZE — pojedynczy token, odporny:
|
||||||
|
grep -c "senderName" plik.ts # 3 ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
Grepuj **nazwę symbolu** (funkcja, zmienna, eksport), nie całą linię z interpunkcją.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ C — DIAGNOSTYKA „KOD DOBRY, ZACHOWANIE ZŁE"
|
||||||
|
|
||||||
|
Gdy grep potwierdza kod, wersja nowa, a zachowanie stare — przejdź listę:
|
||||||
|
|
||||||
|
1. **Dev nie zrestartowany?** Payload buduje adaptery/config przy starcie.
|
||||||
|
Ctrl+C + `pnpm dev`. (Najczęstsza przyczyna.)
|
||||||
|
2. **Zmiana zapisana w panelu?** Endpointy czytają z BAZY, nie z pola na ekranie.
|
||||||
|
Kliknij Save.
|
||||||
|
3. **Zdublowana zależność?** `@payloadcms/ui` w node_modules pluginu = dwie
|
||||||
|
instancje = hooki bez kontekstu. Sprawdź:
|
||||||
|
`ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui`
|
||||||
|
Jest? → peerDependency problem (patrz Część D).
|
||||||
|
4. **Cache klienta?** Np. klient pocztowy pokazuje zapamiętaną nazwę nadawcy
|
||||||
|
mimo poprawnych nagłówków. Sprawdź surowe źródło (View Source), wyślij na
|
||||||
|
inny adres.
|
||||||
|
5. **Import map nieaktualny?** Custom komponenty Payload:
|
||||||
|
`npx payload generate:importmap`.
|
||||||
|
|
||||||
|
**Realny przykład:** MaskedField rzucał "Cannot destructure property 'config'".
|
||||||
|
Kod OK. Przyczyna: dublet `@payloadcms/ui` (plugin miał własną kopię) →
|
||||||
|
`useField` z jednej instancji nie widział kontekstu z drugiej. Naprawa w Część D.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ D — peerDependencies (dublety zależności)
|
||||||
|
|
||||||
|
**Zasada:** wszystko, co dostarcza PROJEKT, jest `peerDependency` w pluginie,
|
||||||
|
NIE `dependency`. Inaczej menedżer instaluje własną kopię dla pluginu → dublet
|
||||||
|
→ React/Payload context się rozjeżdża (dwie instancje nie widzą się nawzajem).
|
||||||
|
|
||||||
|
Peer (projekt dostarcza): `payload`, `@payloadcms/ui`, `@payloadcms/next`,
|
||||||
|
`@payloadcms/plugin-*`, `react`, `react-dom`, `next`.
|
||||||
|
|
||||||
|
**Realny błąd:** `@payloadcms/ui` był tylko w devDependencies (brak w peer) →
|
||||||
|
pnpm dołożył kopię pluginowi → MaskedField/TestEmailButton/CookieBanner
|
||||||
|
wszystkie się psuły (hooki bez kontekstu). Naprawa: dodać do peerDependencies,
|
||||||
|
opublikować, w projekcie `rm -rf node_modules/@intecion/ipal-kit && pnpm add`.
|
||||||
|
|
||||||
|
Weryfikacja braku dubletu:
|
||||||
|
```bash
|
||||||
|
ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui 2>/dev/null \
|
||||||
|
&& echo "DUBLET ✗" || echo "OK ✓"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ E — NOWY PROJEKT KLIENCKI (kolejność)
|
||||||
|
|
||||||
|
Pełne szczegóły: [getting-started.md](./getting-started.md). Tu skrót kolejności.
|
||||||
|
|
||||||
|
1. **Szkielet** Payload 3 + Next 16, pnpm, Node 22
|
||||||
|
2. **`.npmrc`** — `legacy-peer-deps=true` + rejestr `@intecion`
|
||||||
|
3. **`pnpm add @intecion/ipal-kit`** + zależności peer
|
||||||
|
4. **build script z `--webpack`** (Next 16 + Payload; Turbopack konfliktuje)
|
||||||
|
5. **i18n.config.ts** — jedno źródło locale
|
||||||
|
6. **payload.config.ts** — ipalKit({...}), `email: mailAdapter()`
|
||||||
|
7. **Kolekcje/globale** — wszystko localized/upload (nic na sztywno)
|
||||||
|
8. **lib/content.ts + lib/payload.ts** — helpery, jedno źródło getCachedPayload
|
||||||
|
9. **proxy.ts** (NIE middleware.ts) — routing locale, obsługa roota
|
||||||
|
10. **Bloki** — dane przez enhanceProps, nie import lib (cykl)
|
||||||
|
11. **buildSlugField** zamiast ręcznego slug
|
||||||
|
12. **getLocalizedSlugs** zamiast zaszytej mapy ścieżek
|
||||||
|
13. **buildSecurityHeaders** w next.config
|
||||||
|
14. **Test:** root `/` przekierowuje, formularz wysyła, panel działa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ F — EMAIL (SMTP vs Graph)
|
||||||
|
|
||||||
|
Pełne szczegóły: [email.md](./email.md). Decyzja transportu:
|
||||||
|
|
||||||
|
- **Klient na M365/Exchange** → Graph (SMTP AUTH na M365 często wyłączony)
|
||||||
|
- **Klient z własnym SMTP / Gmail** → SMTP
|
||||||
|
- **Przełącznik:** panel → Site Integrations → SMTP → Email Transport
|
||||||
|
- **Dyspozytor:** `email: mailAdapter()` czyta wybór przy każdej wysyłce
|
||||||
|
|
||||||
|
### Graph — checklist wdrożenia (Wasza strona, jednorazowo)
|
||||||
|
|
||||||
|
1. Azure: App registration → tenantId, clientId, clientSecret
|
||||||
|
2. Azure: Mail.Send APPLICATION permission + **Grant admin consent**
|
||||||
|
3. `.env` projektu: GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER
|
||||||
|
4. Panel: From Name (nazwa nadawcy), From Address (→ reply-to)
|
||||||
|
|
||||||
|
### Realne pułapki Graph (wszystkie zdarzyły się w sesji)
|
||||||
|
|
||||||
|
| Błąd | Przyczyna | Naprawa |
|
||||||
|
|---|---|---|
|
||||||
|
| `ErrorSendAsDenied` | `from` ≠ sender | from.address = GRAPH_SENDER, klient w replyTo |
|
||||||
|
| nazwa „Noreply" mimo panelu | Exchange nadpisuje / cache klienta | display name skrzynki / sprawdź nagłówki |
|
||||||
|
| `Insufficient privileges` | brak admin consent | Grant admin consent w Azure |
|
||||||
|
| `AADSTS1002012` | zły scope | scope = `.../.default`, nie Mail.Send |
|
||||||
|
|
||||||
|
**Zasada from/replyTo:** `from.address` ZAWSZE = GRAPH_SENDER (wspólna skrzynka,
|
||||||
|
zero Send-As). Nazwa (`from.name`) z panelu — różna per projekt. Adres klienta
|
||||||
|
→ replyTo (odpowiedzi trafiają do klienta).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ G — CO NALEŻY DO PLUGINU, A CO DO PROJEKTU
|
||||||
|
|
||||||
|
Powtarzalne pytanie. Reguła: **jeśli zależy od danych/domen konkretnego projektu
|
||||||
|
→ projekt. Jeśli identyczne wszędzie → plugin.**
|
||||||
|
|
||||||
|
| Rzecz | Gdzie | Dlaczego |
|
||||||
|
|---|---|---|
|
||||||
|
| i18n, SEO meta, forms, consent, blog | plugin | uniwersalne |
|
||||||
|
| Powiadomienia (teksty wyników) | plugin | uniwersalne, per język z panelu |
|
||||||
|
| Zgoda RODO (enforcement) | plugin | uniwersalne, server-side |
|
||||||
|
| Nagłówki bezpieczeństwa (HSTS...) | plugin | identyczne wszędzie |
|
||||||
|
| Email (SMTP + Graph) | plugin | uniwersalne, konfiguracja z panelu/env |
|
||||||
|
| **CSP** | **projekt** | zależy od domen projektu |
|
||||||
|
| **schema.org / JSON-LD** | **projekt** | zależy od danych firmy |
|
||||||
|
| **Breadcrumbs** | **projekt** | render z danych routingu projektu |
|
||||||
|
| **Dane rejestrowe firmy** | **projekt** | różne per typ firmy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ H — CHECKLIST PRZED „GOTOWE"
|
||||||
|
|
||||||
|
Nie mów „działa", dopóki:
|
||||||
|
|
||||||
|
- [ ] `pnpm build --webpack` przechodzi lokalnie (nie tylko dev)
|
||||||
|
- [ ] root `/` przekierowuje na locale (bez middleware.ts)
|
||||||
|
- [ ] formularz wysyła (test przez panel: Send test)
|
||||||
|
- [ ] panel: wszystkie teksty/obrazy edytowalne (nic na sztywno)
|
||||||
|
- [ ] brak dubletu @payloadcms/ui (Część D)
|
||||||
|
- [ ] grep potwierdza wersję pluginu w node_modules
|
||||||
|
- [ ] sekrety w .env (nie w repo), maskowane w panelu
|
||||||
|
- [ ] brak plików middleware.ts, brak zaszytej mapy slugów
|
||||||
+7
-2
@@ -1,5 +1,7 @@
|
|||||||
# IPAL — Dokumentacja modułów
|
# IPAL — Dokumentacja modułów
|
||||||
|
|
||||||
|
> **Zaczynasz wdrożenie?** Przeczytaj najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md) — sztywna procedura, kolejność, realne przykłady błędów.
|
||||||
|
|
||||||
**Instalacja pakietu** (token Gitea, rejestr vs repozytorium) → główny
|
**Instalacja pakietu** (token Gitea, rejestr vs repozytorium) → główny
|
||||||
[README](../README.md).
|
[README](../README.md).
|
||||||
**Nowy projekt krok po kroku** → [getting-started.md](./getting-started.md).
|
**Nowy projekt krok po kroku** → [getting-started.md](./getting-started.md).
|
||||||
@@ -106,17 +108,20 @@ export default buildConfig({
|
|||||||
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
||||||
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
||||||
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
|
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
|
||||||
|
| architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) |
|
||||||
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
||||||
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
||||||
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) |
|
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) |
|
||||||
| email | SMTP z panelu: adapter Payloada + sendEmail | [email.md](./email.md) |
|
| email | Wysyłka: SMTP z panelu lub Microsoft Graph (M365) | [email.md](./email.md) |
|
||||||
| forms | Form-builder + submitForm (Turnstile + zapis) | [forms.md](./forms.md) |
|
| forms | Form-builder + submitForm (Turnstile + zapis) | [forms.md](./forms.md) |
|
||||||
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
|
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
|
||||||
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
|
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
|
||||||
|
| notifications | Teksty wyników akcji (formularz) per język | [notifications.md](./notifications.md) |
|
||||||
|
| security | Nagłówki bezpieczeństwa HTTP (HSTS, X-Frame...) | [security.md](./security.md) |
|
||||||
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
||||||
|
|
||||||
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
|
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
|
||||||
Referencja wdrożenia frontu: [frontend-setup.md](./frontend-setup.md)
|
Referencja wdrożenia frontu: [getting-started.md](./getting-started.md)
|
||||||
Wydawanie nowych wersji wtyczki: [publishing.md](./publishing.md)
|
Wydawanie nowych wersji wtyczki: [publishing.md](./publishing.md)
|
||||||
Jak komendy łączą się z Gitea (dla instalujących): [gitea-commands.md](./gitea-commands.md)
|
Jak komendy łączą się z Gitea (dla instalujących): [gitea-commands.md](./gitea-commands.md)
|
||||||
Working with a project repo on Gitea (clone/pull/push): [gitea-workflow.md](./gitea-workflow.md) · [🇵🇱 PL](./gitea-workflow.pl.md)
|
Working with a project repo on Gitea (clone/pull/push): [gitea-workflow.md](./gitea-workflow.md) · [🇵🇱 PL](./gitea-workflow.pl.md)
|
||||||
|
|||||||
+37
-2
@@ -109,7 +109,7 @@ Dostępne tokeny (każdy ma odpowiednik `-dark` używany pod `dark:`):
|
|||||||
| `--ipal-hover` | `#f5f5f5` | hover przycisków drugorzędnych |
|
| `--ipal-hover` | `#f5f5f5` | hover przycisków drugorzędnych |
|
||||||
| `--ipal-radius` | `0.375rem` | zaokrąglenie przycisków |
|
| `--ipal-radius` | `0.375rem` | zaokrąglenie przycisków |
|
||||||
|
|
||||||
Wymaga `@source` skanującego pakiet (patrz frontend-setup.md) — inaczej Tailwind
|
Wymaga `@source` skanującego pakiet (patrz getting-started.md) — inaczej Tailwind
|
||||||
nie wygeneruje tych klas.
|
nie wygeneruje tych klas.
|
||||||
|
|
||||||
### Gdy tokeny nie wystarczą
|
### Gdy tokeny nie wystarczą
|
||||||
@@ -131,4 +131,39 @@ Sloty: `root`, `primaryButton`, `secondaryButton`. Podany className zastępuje
|
|||||||
domyślny (nie dokleja się).
|
domyślny (nie dokleja się).
|
||||||
|
|
||||||
Elementy mają też `data-ipal="banner"` i `data-ipal="cookie-button"` — stabilne
|
Elementy mają też `data-ipal="banner"` i `data-ipal="cookie-button"` — stabilne
|
||||||
uchwyty do CSS albo testów e2e.
|
uchwyty do CSS albo testów e2e.
|
||||||
|
|
||||||
|
## Locale jako cookie functional (wbudowane)
|
||||||
|
|
||||||
|
Plugin sam zarządza jedną cookie functional: **`NEXT_LOCALE`** (wybór języka).
|
||||||
|
Nie musisz nic konfigurować — działa out of the box:
|
||||||
|
|
||||||
|
- **Zapis za zgodą.** Middleware zapisuje `NEXT_LOCALE` tylko, gdy użytkownik
|
||||||
|
zaakceptował kategorię **functional**. Bez zgody język działa (negocjacja per
|
||||||
|
żądanie), ale nie jest utrwalany w cookie.
|
||||||
|
- **Sprzątanie po cofnięciu.** Gdy użytkownik cofnie zgodę na functional, hook
|
||||||
|
consent usuwa `NEXT_LOCALE` automatycznie. Odpowiada za to `DEFAULT_COOKIE_MAP`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const DEFAULT_COOKIE_MAP = {
|
||||||
|
functional: [LOCALE_COOKIE_NAME], // 'NEXT_LOCALE' — plugin zna własną cookie
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Twoje własne cookie functional/analytics
|
||||||
|
|
||||||
|
Jeśli ustawiasz własne cookie podlegające zgodzie, rozszerz mapę — hook wtedy
|
||||||
|
sprzątnie też Twoje po cofnięciu zgody:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useConsent({
|
||||||
|
functional: ['NEXT_LOCALE', 'moje-ustawienie'],
|
||||||
|
analytics: ['_ga', '_gid'],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Przekazana mapa zastępuje domyślną — pamiętaj dołączyć `NEXT_LOCALE`, jeśli
|
||||||
|
chcesz zachować sprzątanie locale (albo zaimportuj `LOCALE_COOKIE_NAME` i dodaj).
|
||||||
|
|
||||||
|
> Mechanizm zgody dla locale jest opisany też od strony i18n:
|
||||||
|
> [i18n.md](./i18n.md#cookie-locale-a-zgoda-rodo).
|
||||||
+107
-4
@@ -1,8 +1,10 @@
|
|||||||
# email
|
# email
|
||||||
|
|
||||||
Wysyłka maili przez SMTP z SiteIntegrations, w runtime (bez Payload email
|
Wysyłka maili z dwoma transportami do wyboru: **SMTP z panelu**
|
||||||
adaptera). Edytor zmienia SMTP w panelu — następny mail idzie z nowymi
|
(`panelSmtpAdapter`, uniwersalny) albo **Microsoft Graph** (`graphAdapter`,
|
||||||
ustawieniami, bez restartu.
|
przez Exchange/M365). Oba implementują ten sam interfejs `PayloadEmailAdapter`,
|
||||||
|
więc `payload.sendEmail` i maile z formularzy działają niezależnie od wyboru.
|
||||||
|
Klient/projekt wybiera transport w configu.
|
||||||
|
|
||||||
## Zależność
|
## Zależność
|
||||||
|
|
||||||
@@ -73,4 +75,105 @@ resetu hasła i weryfikacji konta. Adapter czyta konfigurację przy każdym
|
|||||||
wysłaniu, więc zmiana skrzynki w panelu działa bez restartu.
|
wysłaniu, więc zmiana skrzynki w panelu działa bez restartu.
|
||||||
|
|
||||||
Bez adaptera Payload używa mocka, który tylko loguje do konsoli — maile
|
Bez adaptera Payload używa mocka, który tylko loguje do konsoli — maile
|
||||||
form-buildera nie wyjdą.
|
form-buildera nie wyjdą.
|
||||||
|
|
||||||
|
## Maskowanie sekretów w panelu (MaskedField)
|
||||||
|
|
||||||
|
Wrażliwe pola w Site Integrations (smtpPassword, r2SecretAccessKey,
|
||||||
|
turnstileSecretKey) są maskowane w UI — pokazują `••••` zamiast plaintextu, z
|
||||||
|
przyciskiem Reveal/Hide. To maskowanie UI, NIE hashowanie ani szyfrowanie:
|
||||||
|
wartość w bazie jest plaintext (musi być odzyskiwalna do autentykacji SMTP/R2).
|
||||||
|
Chroni przed patrzeniem przez ramię i przypadkowym pokazaniem panelu.
|
||||||
|
|
||||||
|
Podpięte przez `admin.components.Field: '@intecion/ipal-kit/client#MaskedField'`.
|
||||||
|
Działa na dowolnym polu `text`. Po wpięciu w projekcie może być konieczne
|
||||||
|
`payload generate:importmap`, żeby panel rozpoznał komponent.
|
||||||
|
|
||||||
|
> Główną ochroną sekretów pozostaje `read: isAdmin` na globalu SiteIntegrations
|
||||||
|
> (anonim nie dostaje). Maskowanie to warstwa dodatkowa (shoulder-surfing), nie
|
||||||
|
> ochrona bazy — przy wycieku DB sekrety są czytelne.
|
||||||
|
|
||||||
|
|
||||||
|
## Adapter — Microsoft Graph (Exchange / M365)
|
||||||
|
|
||||||
|
Alternatywa dla SMTP: wysyłka przez Microsoft Graph API, przez skrzynkę w
|
||||||
|
Waszym (agencyjnym) tenancie M365. Wszystkie maile z formularzy wszystkich
|
||||||
|
projektów idą przez JEDNĄ skrzynkę nadawczą (np. `[email protected]`).
|
||||||
|
|
||||||
|
### Podział konfiguracji (celowy)
|
||||||
|
|
||||||
|
**Sekrety w `.env`** (agencyjne — Wasz Exchange, klient nie widzi):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GRAPH_TENANT_ID=...
|
||||||
|
GRAPH_CLIENT_ID=...
|
||||||
|
GRAPH_CLIENT_SECRET=...
|
||||||
|
GRAPH_SENDER=[email protected] # jedna skrzynka dla wszystkich projektów
|
||||||
|
```
|
||||||
|
|
||||||
|
**From-display w panelu** (per projekt): czyta istniejące `smtpFromAddress` /
|
||||||
|
`smtpFromName` z SiteIntegrations — bo „from" to ten sam koncept niezależnie od
|
||||||
|
transportu. Nie trzeba nowego pola.
|
||||||
|
|
||||||
|
### Wpięcie — wybór transportu
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// payload.config.ts
|
||||||
|
import { panelSmtpAdapter, graphAdapter } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
email: process.env.GRAPH_CLIENT_ID
|
||||||
|
? graphAdapter() // Graph, gdy sekrety w .env
|
||||||
|
: panelSmtpAdapter(), // SMTP z panelu (fallback)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup Azure / Exchange (jednorazowo, Wasza strona, POZA kodem)
|
||||||
|
|
||||||
|
1. **App registration** w Azure AD → `tenantId`, `clientId`
|
||||||
|
2. **Client secret** → `clientSecret`
|
||||||
|
3. **API Permissions** → Microsoft Graph → **Application** → `Mail.Send` →
|
||||||
|
**Grant admin consent** (bez tego: `Insufficient privileges`)
|
||||||
|
4. **Exchange Admin Center** → skrzynka `[email protected]` → Mailbox
|
||||||
|
Delegation → aplikacja do **"Send As"** (bez tego: `ErrorAccessDenied`)
|
||||||
|
|
||||||
|
### Szczegóły techniczne
|
||||||
|
|
||||||
|
- Auth: client credentials flow, scope `https://graph.microsoft.com/.default`
|
||||||
|
(NIE `Mail.Send` — Azure odrzuca, AADSTS1002012)
|
||||||
|
- Wysyłka: `POST /users/{sender}/sendMail` (NIE `/me` — app-only nie ma „me")
|
||||||
|
- Sukces: HTTP 202 (pusty body)
|
||||||
|
- Czysty REST (fetch), zero bibliotek Microsoft, zero nowych zależności
|
||||||
|
|
||||||
|
### PUŁAPKA — from vs Send-As
|
||||||
|
|
||||||
|
Jeśli `from` w panelu = cudza domena (np. `[email protected]`), a sender =
|
||||||
|
`[email protected]` — Exchange zablokuje, chyba że aplikacja ma Send-As na tę
|
||||||
|
domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres
|
||||||
|
klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze
|
||||||
|
domeny nie jest potrzebny.
|
||||||
|
|
||||||
|
## Przełącznik transportu — mailAdapter
|
||||||
|
|
||||||
|
`mailAdapter()` to dyspozytor: jeden adapter wpięty w config, wybiera transport
|
||||||
|
(SMTP/Graph) przy KAŻDEJ wysyłce, czytając ustawienie z panelu. Dzięki temu
|
||||||
|
przełącznik działa w panelu (Payload buduje adapter raz przy starcie, więc nie
|
||||||
|
da się podmieniać osobnych adapterów w runtime — dyspozytor deleguje wewnątrz).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// payload.config.ts — JEDEN adapter, wybór wewnątrz
|
||||||
|
import { mailAdapter } from '@intecion/ipal-kit'
|
||||||
|
email: mailAdapter()
|
||||||
|
```
|
||||||
|
|
||||||
|
Panel → Site Integrations → SMTP → **Email Transport** (SMTP / Microsoft Graph).
|
||||||
|
Dyspozytor czyta ten wybór per wysyłka. Guard: jeśli wybrano Graph, ale brak
|
||||||
|
sekretów w .env → log + fallback na SMTP (nie cicha awaria).
|
||||||
|
|
||||||
|
## Test wysyłki — przycisk w panelu
|
||||||
|
|
||||||
|
W tabie SMTP jest przycisk **Send test**: podaj adres, kliknij, wyślij testowy
|
||||||
|
mail przez AKTUALNY transport. Pokazuje wynik (✓/✗ z błędem). Endpoint
|
||||||
|
`POST /api/ipal/test-email` (admin-only). Zapisz zmiany przed testem — endpoint
|
||||||
|
czyta z bazy, nie z pola na ekranie.
|
||||||
|
|
||||||
|
> Bezcenne przy diagnozie Graph — od razu widzisz `ErrorSendAsDenied`,
|
||||||
|
> `Insufficient privileges` itp. zamiast zgadywać.
|
||||||
+56
-1
@@ -146,6 +146,7 @@ type SubmitFormResult =
|
|||||||
| { success: false; reason: 'turnstile' }
|
| { success: false; reason: 'turnstile' }
|
||||||
| { success: false; reason: 'validation'; field?: string; kind?: 'required' | 'too_long' | 'unknown_fields' }
|
| { success: false; reason: 'validation'; field?: string; kind?: 'required' | 'too_long' | 'unknown_fields' }
|
||||||
| { success: false; reason: 'not_found' }
|
| { success: false; reason: 'not_found' }
|
||||||
|
| { success: false; reason: 'consent'; field?: string }
|
||||||
| { success: false; reason: 'error' }
|
| { success: false; reason: 'error' }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -162,4 +163,58 @@ function errorMessage(r) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane.
|
Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane.
|
||||||
|
|
||||||
|
|
||||||
|
## Zgoda RODO (consent field)
|
||||||
|
|
||||||
|
Pole zgody RODO to checkbox o nazwie `consent` (konfigurowalna przez
|
||||||
|
`FormsOption.consentFieldName`). Plugin WYMUSZA jego zaznaczenie SERVER-SIDE —
|
||||||
|
niezależnie od tego, jak redaktor ustawił pole w panelu.
|
||||||
|
|
||||||
|
### Dlaczego server-side
|
||||||
|
|
||||||
|
Zgoda egzekwowana jest w `validateSubmission`, nie flagą w panelu. To jedyne
|
||||||
|
miejsce, którego redaktor nie osłabi (zapominając `required`) ani nie naruszy
|
||||||
|
(ustawiając `defaultValue: true` — pre-zaznaczenie, którego RODO zabrania), a
|
||||||
|
front nie obejdzie. Jeśli formularz ma pole `consent`, MUSI być zaznaczone,
|
||||||
|
inaczej `submitForm` zwraca `reason: 'consent'`.
|
||||||
|
|
||||||
|
### Jak użyć
|
||||||
|
|
||||||
|
1. Redaktor dodaje w panelu checkbox o nazwie `consent`, label „Akceptuję
|
||||||
|
politykę prywatności [link]" (link do polityki wpisuje w label — treść zgody
|
||||||
|
należy do panelu).
|
||||||
|
2. Plugin wymusza zaznaczenie. Niezaznaczony → `reason: 'consent'`.
|
||||||
|
3. Komunikat z modułu notifications (`notifications.form.consent`, per język).
|
||||||
|
|
||||||
|
Zmiana nazwy pola:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ipalKit({ forms: { consentFieldName: 'zgoda' } })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Komunikaty — resolveFormMessage (zalecane)
|
||||||
|
|
||||||
|
Zamiast ręcznego switcha po `reason`, użyj `resolveFormMessage` z modułu
|
||||||
|
notifications — mapuje kod na tekst z panelu, per język, z interpolacją `{field}`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { resolveFormMessage, getNotificationTexts } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
const notifications = await getNotificationTexts({ payload, locale })
|
||||||
|
// w FormRenderer:
|
||||||
|
if (!result.success) {
|
||||||
|
setError(resolveFormMessage(result, notifications.form))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To obsługuje WSZYSTKIE kody (w tym `consent`, `rate_limited`, `validation` z
|
||||||
|
`{field}`) tekstami z panelu. Ręczny switch (wyżej) zostaw tylko, jeśli nie
|
||||||
|
używasz modułu notifications. Szczegóły: [notifications.md](./notifications.md).
|
||||||
|
|
||||||
|
## PUŁAPKA — pola captchy
|
||||||
|
|
||||||
|
Turnstile wstrzykuje ukryte pole `cf-turnstile-response`. Plugin je toleruje
|
||||||
|
(nie odrzuca jako `unknown_fields`) — bo sam obsługuje Turnstile. Nie musisz go
|
||||||
|
filtrować w kliencie.
|
||||||
+204
-221
@@ -1,14 +1,18 @@
|
|||||||
# Nowy projekt — krok po kroku
|
# Setup projektu — od zera do wdrożenia
|
||||||
|
|
||||||
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest opisana w głównym
|
Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z
|
||||||
|
blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność
|
||||||
|
kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata).
|
||||||
|
|
||||||
|
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest w głównym
|
||||||
> [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest
|
> [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest
|
||||||
> już zainstalowany, i przeprowadza przez **konfigurację** projektu.
|
> zainstalowany, i przeprowadza przez konfigurację.
|
||||||
|
>
|
||||||
|
> **Zaczynasz wdrożenie produkcyjne?** Najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md)
|
||||||
|
> — zasady, procedura, pułapki.
|
||||||
|
|
||||||
Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i
|
Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy,
|
||||||
formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat
|
importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16.
|
||||||
bazy, importMap, kolejność wpięcia).
|
|
||||||
|
|
||||||
Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,21 +20,22 @@ Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx create-payload-app@latest moj-projekt
|
npx create-payload-app@latest moj-projekt
|
||||||
# → Blank, SQLite
|
# → Blank, SQLite (dev) / Postgres (prod)
|
||||||
cd moj-projekt
|
cd moj-projekt
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Plugin i zależności
|
## 2. Plugin i zależności
|
||||||
|
|
||||||
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md) (rejestr Gitea
|
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md). Dodaj
|
||||||
albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności
|
zależności współdzielone z Payloadem, których plugin nie zaciąga sam:
|
||||||
współdzielone z Payloadem, których plugin nie zaciąga sam:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm add @payloadcms/plugin-seo@3.84.1 @payloadcms/plugin-form-builder@3.84.1 \
|
pnpm add @payloadcms/plugin-seo @payloadcms/plugin-form-builder \
|
||||||
nodemailer lucide-react slugify server-only
|
nodemailer lucide-react slugify server-only
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Spójność wersji @payloadcms/* (KRYTYCZNE)
|
||||||
|
|
||||||
Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej
|
Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej
|
||||||
zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo
|
zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo
|
||||||
projekt się wywala. Wymuś w `package.json`:
|
projekt się wywala. Wymuś w `package.json`:
|
||||||
@@ -38,13 +43,13 @@ projekt się wywala. Wymuś w `package.json`:
|
|||||||
```json
|
```json
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"payload": "3.84.1",
|
"payload": "3.88.0",
|
||||||
"@payloadcms/ui": "3.84.1",
|
"@payloadcms/ui": "3.88.0",
|
||||||
"@payloadcms/next": "3.84.1",
|
"@payloadcms/next": "3.88.0",
|
||||||
"@payloadcms/db-sqlite": "3.84.1",
|
"@payloadcms/db-postgres": "3.88.0",
|
||||||
"@payloadcms/richtext-lexical": "3.84.1",
|
"@payloadcms/richtext-lexical": "3.88.0",
|
||||||
"@payloadcms/plugin-seo": "3.84.1",
|
"@payloadcms/plugin-seo": "3.88.0",
|
||||||
"@payloadcms/plugin-form-builder": "3.84.1"
|
"@payloadcms/plugin-form-builder": "3.88.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -53,11 +58,17 @@ projekt się wywala. Wymuś w `package.json`:
|
|||||||
rm -rf node_modules pnpm-lock.yaml && pnpm install
|
rm -rf node_modules pnpm-lock.yaml && pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Build script z --webpack (Next 16)
|
||||||
|
|
||||||
|
Next 16 domyślnie Turbopack, który konfliktuje z withPayload. W `package.json`:
|
||||||
|
```json
|
||||||
|
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack"
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Konfiguracja locale — jedno źródło
|
## 3. Konfiguracja locale — jedno źródło
|
||||||
|
|
||||||
Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc
|
Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie
|
||||||
nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu
|
może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie:
|
||||||
miejscach:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/i18n.config.ts
|
// src/i18n.config.ts
|
||||||
@@ -67,25 +78,24 @@ export const i18nConfig = {
|
|||||||
{ code: 'pl', label: 'Polski' },
|
{ code: 'pl', label: 'Polski' },
|
||||||
{ code: 'en', label: 'English' },
|
{ code: 'en', label: 'English' },
|
||||||
],
|
],
|
||||||
} as const
|
} as const // as const — inaczej TS nie uzna locales za niepustą tuple
|
||||||
```
|
```
|
||||||
|
|
||||||
`as const` jest konieczne — bez niego TS nie uzna `locales` za niepustą listę.
|
Importuj w: `payload.config` (ipalKit({ i18n: i18nConfig })) i `proxy.ts`.
|
||||||
|
|
||||||
## 4. payload.config.ts
|
## 4. payload.config.ts
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit'
|
import { ipalKit, mailAdapter } from '@intecion/ipal-kit'
|
||||||
import { i18nConfig } from '@/i18n.config'
|
import { i18nConfig } from '@/i18n.config'
|
||||||
import { Pages } from '@/collections/Pages'
|
import { Pages } from '@/collections/Pages'
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
// …reszta z template'u
|
|
||||||
collections: [Users, Media, Pages],
|
collections: [Users, Media, Pages],
|
||||||
|
|
||||||
// SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu.
|
// Dyspozytor email: czyta transport (SMTP/Graph) z panelu przy każdej wysyłce.
|
||||||
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
|
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
|
||||||
email: panelSmtpAdapter(),
|
email: mailAdapter(),
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
ipalKit({
|
ipalKit({
|
||||||
@@ -113,11 +123,11 @@ export const Pages: CollectionConfig = {
|
|||||||
access: { read: () => true },
|
access: { read: () => true },
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'title', type: 'text', required: true, localized: true },
|
{ name: 'title', type: 'text', required: true, localized: true },
|
||||||
buildSlugField({ from: 'title' }),
|
buildSlugField({ from: 'title' }), // NIGDY ręczny slug — plugin to ma
|
||||||
{
|
{
|
||||||
name: 'layout',
|
name: 'layout',
|
||||||
type: 'blocks',
|
type: 'blocks',
|
||||||
blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala
|
blocks: [ContentBlock], // NIGDY pusta lista — Payload crashuje
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -158,15 +168,28 @@ import type { BlockComponentMap } from '@intecion/ipal-kit/rsc'
|
|||||||
import { ContentBlockComponent } from '@/blocks/Content/Component'
|
import { ContentBlockComponent } from '@/blocks/Content/Component'
|
||||||
|
|
||||||
export const blockRegistry: BlockComponentMap = {
|
export const blockRegistry: BlockComponentMap = {
|
||||||
content: ContentBlockComponent,
|
content: ContentBlockComponent, // klucz = slug bloku
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Klucz w rejestrze = `slug` bloku.
|
**Puste `blocks: []` crashuje** (traverseFields) — zawsze co najmniej jeden blok.
|
||||||
|
|
||||||
## 7. Tailwind
|
### enhanceProps — wstrzykiwanie danych server-side do bloków
|
||||||
|
|
||||||
Blank template go nie ma, a komponenty pluginu (banner cookies) są w Tailwindzie.
|
Bloki NIE importują `lib/*` (cykl importów). Wartości server-side (turnstileSiteKey,
|
||||||
|
odbiorca formularza) wstrzykuje się przez enhanceProps — bez wiedzy pluginu:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const enhanceProps = ({ block }) => {
|
||||||
|
if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo }
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Tailwind (WYMÓG)
|
||||||
|
|
||||||
|
Blank template go nie ma, a komponenty pluginu (banner cookies, Turnstile) są w
|
||||||
|
czystym Tailwindzie.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm add tailwindcss @tailwindcss/postcss
|
pnpm add tailwindcss @tailwindcss/postcss
|
||||||
@@ -183,16 +206,30 @@ export default { plugins: { '@tailwindcss/postcss': {} } }
|
|||||||
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
|
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
|
||||||
```
|
```
|
||||||
|
|
||||||
`@source` jest **konieczny** — Tailwind nie skanuje `node_modules`, więc bez
|
**`@source` jest KONIECZNY** — Tailwind nie skanuje `node_modules`, więc bez
|
||||||
niego klasy komponentów pluginu nie powstaną i banner wyrenderuje się goły.
|
niego klasy komponentów pluginu nie powstaną (banner wyrenderuje się goły).
|
||||||
Ścieżka jest relatywna do pliku CSS.
|
Ścieżka relatywna do pliku CSS.
|
||||||
|
|
||||||
## 8. Proxy (dawniej middleware)
|
### Przestylowanie pod klienta
|
||||||
|
|
||||||
> **Next 16:** konwencja `middleware.ts` jest przestarzała — nazwa pliku to teraz
|
Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom
|
||||||
> `proxy.ts`, a funkcja `proxy` zamiast `middleware`. Logika pluginu bez zmian:
|
properties (fallbacki wbudowane):
|
||||||
> `createLocaleMiddleware` działa tak samo. Migracja jednej komendy:
|
```css
|
||||||
> `npx @next/codemod@canary middleware-to-proxy .`
|
:root {
|
||||||
|
--ipal-primary: #16a34a;
|
||||||
|
--ipal-radius: 1rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Pełna lista tokenów + opcja classNames: [consent.md](./consent.md).
|
||||||
|
|
||||||
|
## 8. Proxy (routing locale) — NIGDY middleware.ts
|
||||||
|
|
||||||
|
> **Next 16 używa `proxy.ts`, NIE `middleware.ts`.** Plik `proxy.ts`, funkcja
|
||||||
|
> `proxy`. `middleware.ts` jest przestarzały — jeśli istnieje, USUŃ go. Nigdy
|
||||||
|
> obu naraz. Migracja starego: `npx @next/codemod@canary middleware-to-proxy .`
|
||||||
|
>
|
||||||
|
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
|
||||||
|
> subpath eksportu, NIE nazwa pliku. Nie myl ich.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/proxy.ts
|
// src/proxy.ts
|
||||||
@@ -205,104 +242,89 @@ const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
|
|||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const result = localeMiddleware(request)
|
const result = localeMiddleware(request)
|
||||||
if (result.type === 'next') return NextResponse.next()
|
|
||||||
|
|
||||||
const response = NextResponse.redirect(result.location)
|
// Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie
|
||||||
// cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined
|
// języka), TYLKO gdy jest zgoda na functional.
|
||||||
if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value)
|
const response =
|
||||||
|
result.type === 'next'
|
||||||
|
? NextResponse.next()
|
||||||
|
: NextResponse.redirect(result.location)
|
||||||
|
|
||||||
|
if (result.cookie) {
|
||||||
|
response.cookies.set(result.cookie.name, result.cookie.value)
|
||||||
|
}
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje
|
// Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów.
|
||||||
// importów. Importowana stała zostanie zignorowana, proxy złapie /admin
|
// Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500.
|
||||||
// i /_next, i wszystko zwróci 500.
|
// Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki.
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
|
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
|
### Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa)
|
||||||
> subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa się
|
|
||||||
> `middleware.ts` czy `proxy.ts`.
|
|
||||||
|
|
||||||
## 9. Warstwa dostępu do danych
|
Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów.
|
||||||
|
Slugi są w bazie (pole `slug` localized):
|
||||||
Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()`
|
|
||||||
sprawia, że nie pytają bazy dwa razy o to samo.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/lib/payload.ts
|
import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit'
|
||||||
import { cache } from 'react'
|
|
||||||
import { getPayload } from 'payload'
|
|
||||||
import config from '@/payload.config'
|
|
||||||
|
|
||||||
export const getCachedPayload = cache(async () => getPayload({ config: await config }))
|
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
|
||||||
|
const slugs = getLocalizedSlugs({ slugField: doc.slug, config: i18nConfig })
|
||||||
export const getSettings = cache(async (locale: string) =>
|
switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig }) // → '/en/about'
|
||||||
(await getCachedPayload()).findGlobal({
|
|
||||||
slug: 'site-settings',
|
|
||||||
locale: locale as 'pl' | 'en',
|
|
||||||
depth: 2,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
## 9. Warstwa dostępu do danych — lib/ (jedno źródło)
|
||||||
// src/lib/locales.ts
|
|
||||||
import { cache } from 'react'
|
|
||||||
import config from '@/payload.config'
|
|
||||||
|
|
||||||
export const getConfiguredLocales = cache(async (): Promise<string[]> => {
|
```ts
|
||||||
const payloadConfig = await config
|
// src/lib/content.ts — JEDYNE źródło helperów pluginu
|
||||||
return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : []
|
import { createContentHelpers } from '@intecion/ipal-kit'
|
||||||
|
import payloadConfig from '@/payload.config'
|
||||||
|
import { i18nConfig } from '@/i18n.config'
|
||||||
|
|
||||||
|
export const {
|
||||||
|
getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries, robots,
|
||||||
|
} = createContentHelpers({
|
||||||
|
config: payloadConfig, // PAYLOAD config (nie i18n!)
|
||||||
|
content: { collections: [] },
|
||||||
|
i18n: i18nConfig, // i18n OSOBNO
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/lib/pages.ts
|
// src/lib/payload.ts — funkcje projektu, typowane
|
||||||
import { cache } from 'react'
|
import { cache } from 'react'
|
||||||
import type { Page, SiteSetting } from '@/payload-types'
|
import { getSiteSettings } from '@intecion/ipal-kit'
|
||||||
import { getCachedPayload, getSettings } from './payload'
|
import { getCachedPayload } from './content' // z content, nie osobny getPayload
|
||||||
|
import type { SiteSetting } from '@/payload-types'
|
||||||
|
|
||||||
export const resolvePage = cache(
|
export const getSettings = cache(async (locale: string) =>
|
||||||
async (locale: string, slugPath: string | null): Promise<Page | null> => {
|
getSiteSettings<SiteSetting>(await getCachedPayload(), { locale: locale as never, depth: 2 }),
|
||||||
if (!slugPath) {
|
|
||||||
// Strona główna z System Pages — edytor może ją zmienić bez zmiany kodu.
|
|
||||||
const settings = (await getSettings(locale)) as SiteSetting
|
|
||||||
const homepage = settings.homepage
|
|
||||||
return homepage && typeof homepage === 'object' ? homepage : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await getCachedPayload()
|
|
||||||
const result = await payload.find({
|
|
||||||
collection: 'pages',
|
|
||||||
where: { slug: { equals: slugPath } },
|
|
||||||
locale: locale as 'pl' | 'en',
|
|
||||||
depth: 2,
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
return result.docs[0] ?? null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma
|
||||||
|
> `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd.
|
||||||
|
|
||||||
## 10. Trasy
|
## 10. Trasy
|
||||||
|
|
||||||
Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje
|
Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje
|
||||||
layout locale, bo `<html lang>` musi znać język, a `(frontend)` jest ponad
|
layout locale (bo `<html lang>` musi znać język).
|
||||||
segmentem `[locale]`. Każdy trafia na ścieżkę z locale — middleware przekierowuje.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/app/(frontend)/
|
src/app/(frontend)/
|
||||||
styles.css
|
styles.css
|
||||||
[locale]/
|
[locale]/
|
||||||
layout.tsx
|
layout.tsx # walidacja locale + ConsentProvider + Analytics
|
||||||
[[...slug]]/
|
[[...slug]]/
|
||||||
page.tsx
|
page.tsx # render bloków
|
||||||
```
|
```
|
||||||
|
|
||||||
`[[...slug]]` — **podwójne** nawiasy. Pojedyncze `[slug]` dają string zamiast
|
**`[[...slug]]` — PODWÓJNE nawiasy** (opcjonalny catch-all). Pojedyncze `[slug]`
|
||||||
tablicy (`slug.join is not a function`) i nie łapią samego `/pl`.
|
dają string (`slug.join is not a function`) i nie łapią samego `/pl`.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// src/app/(frontend)/[locale]/layout.tsx
|
// src/app/(frontend)/[locale]/layout.tsx
|
||||||
@@ -310,8 +332,8 @@ import { notFound } from 'next/navigation'
|
|||||||
import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit'
|
import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit'
|
||||||
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client'
|
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client'
|
||||||
import { i18nConfig } from '@/i18n.config'
|
import { i18nConfig } from '@/i18n.config'
|
||||||
import { getCachedPayload, getSettings } from '@/lib/payload'
|
import { getCachedPayload, getConfiguredLocales } from '@/lib/content'
|
||||||
import { getConfiguredLocales } from '@/lib/locales'
|
import { getSettings } from '@/lib/payload'
|
||||||
import '../styles.css'
|
import '../styles.css'
|
||||||
|
|
||||||
export default async function LocaleLayout({ children, params }) {
|
export default async function LocaleLayout({ children, params }) {
|
||||||
@@ -325,13 +347,9 @@ export default async function LocaleLayout({ children, params }) {
|
|||||||
|
|
||||||
const [texts, analytics] = await Promise.all([
|
const [texts, analytics] = await Promise.all([
|
||||||
getConsentTexts({
|
getConsentTexts({
|
||||||
config: i18nConfig,
|
config: i18nConfig, locale, payload,
|
||||||
locale,
|
privacyPolicy: privacyPage && typeof privacyPage === 'object'
|
||||||
payload,
|
? { page: privacyPage, label: 'Polityka prywatności' } : undefined,
|
||||||
privacyPolicy:
|
|
||||||
privacyPage && typeof privacyPage === 'object'
|
|
||||||
? { page: privacyPage, label: 'Polityka prywatności' }
|
|
||||||
: undefined,
|
|
||||||
}),
|
}),
|
||||||
getAnalyticsConfig(payload),
|
getAnalyticsConfig(payload),
|
||||||
])
|
])
|
||||||
@@ -343,7 +361,7 @@ export default async function LocaleLayout({ children, params }) {
|
|||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
<CookieButton />
|
<CookieButton />
|
||||||
<Analytics {...analytics} />
|
<Analytics {...analytics} /> {/* WEWNĄTRZ ConsentProvider */}
|
||||||
</ConsentProvider>
|
</ConsentProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -364,8 +382,7 @@ import { RenderBlocks } from '@intecion/ipal-kit/rsc'
|
|||||||
import { createPageMetadata } from '@intecion/ipal-kit'
|
import { createPageMetadata } from '@intecion/ipal-kit'
|
||||||
import { i18nConfig } from '@/i18n.config'
|
import { i18nConfig } from '@/i18n.config'
|
||||||
import { blockRegistry } from '@/blocks/registry'
|
import { blockRegistry } from '@/blocks/registry'
|
||||||
import { getCachedPayload } from '@/lib/payload'
|
import { getCachedPayload, resolveRoute } from '@/lib/content'
|
||||||
import { resolvePage } from '@/lib/pages'
|
|
||||||
|
|
||||||
const pageMetadata = createPageMetadata({
|
const pageMetadata = createPageMetadata({
|
||||||
config: i18nConfig,
|
config: i18nConfig,
|
||||||
@@ -377,104 +394,92 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
|||||||
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params }) {
|
export default async function Page({ params, searchParams }) {
|
||||||
const { locale, slug } = await params
|
const { locale, slug } = await params
|
||||||
const page = await resolvePage(locale, slug?.length ? slug.join('/') : null)
|
const { page } = await searchParams
|
||||||
if (!page) notFound()
|
const route = await resolveRoute(locale, slug ?? [], page) // 3 args
|
||||||
|
if (!route) notFound()
|
||||||
return <RenderBlocks blocks={page.layout as never} components={blockRegistry} />
|
return <RenderBlocks blocks={route.doc.layout as never} components={blockRegistry} />
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 11. Środowisko
|
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
|
||||||
|
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
|
||||||
|
- `depth: 2` → relacje w blokach (form) się populują
|
||||||
|
|
||||||
```bash
|
## 11. Metadata / SEO (szczegóły)
|
||||||
# .env
|
|
||||||
DATABASE_URL=file:./moj-projekt.db
|
`createPageMetadata` obsługuje hreflang. Kluczowe: resolveDocument pobiera
|
||||||
PAYLOAD_SECRET=<losowy-ciąg>
|
dokument z **`locale: 'all'`** — wtedy `slug` jest mapą locale→wartość, z której
|
||||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
budują się hreflang alternates. Zwykły fetch (jeden locale) → tylko string,
|
||||||
```
|
hreflang nie powstanie.
|
||||||
|
|
||||||
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne.
|
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne.
|
||||||
|
|
||||||
## 12. Generowanie i start
|
## 12. Nagłówki bezpieczeństwa
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||||
|
const securityHeaders = buildSecurityHeaders({
|
||||||
|
hsts: process.env.NODE_ENV === 'production', // off w dev (http)
|
||||||
|
additional: [ /* CSP projektu — zna swoje domeny */ ],
|
||||||
|
})
|
||||||
|
// async headers() { return [{ source: '/:path*', headers: securityHeaders }] }
|
||||||
|
```
|
||||||
|
Szczegóły: [security.md](./security.md).
|
||||||
|
|
||||||
|
## 13. Środowisko
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
DATABASE_URI=<postgres albo file:./dev.db>
|
||||||
|
PAYLOAD_SECRET=<losowy-ciąg>
|
||||||
|
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||||
|
# Email przez Graph (opcjonalnie — sekrety agencyjne):
|
||||||
|
# GRAPH_TENANT_ID=... GRAPH_CLIENT_ID=... GRAPH_CLIENT_SECRET=... GRAPH_SENDER=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Generowanie i start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm generate:types
|
pnpm generate:types
|
||||||
pnpm payload generate:importmap # pola SEO to komponenty admina
|
pnpm payload generate:importmap # pola SEO + custom komponenty (MaskedField...)
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate:importmap` powtarzaj po każdej zmianie, która dokłada komponenty
|
`generate:importmap` powtarzaj po każdej zmianie dokładającej komponenty admina.
|
||||||
admina.
|
|
||||||
|
|
||||||
## 13. Konfiguracja w panelu
|
## 15. Konfiguracja w panelu
|
||||||
|
|
||||||
`http://localhost:3000/admin`
|
`http://localhost:3000/admin`
|
||||||
|
|
||||||
1. **Utwórz pierwszego użytkownika** (dostanie rolę admin).
|
1. **Utwórz pierwszego użytkownika** (rola admin).
|
||||||
2. **Site Settings → General** — nazwa witryny, kolejność i separator tytułu.
|
2. **Site Settings → General** — nazwa witryny, tytuł.
|
||||||
3. **Pages** — utwórz stronę główną. Wypełnij tytuł **w każdym locale**
|
3. **Pages** — strona główna. Tytuł **w każdym locale** (slug per język; pusty
|
||||||
(przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza
|
slug EN = 404 na `/en/…`).
|
||||||
404 na `/en/…`.
|
4. **Site Settings → System Pages** — wskaż Homepage (bez tego `/pl` → 404).
|
||||||
4. **Site Settings → System Pages** — wskaż Homepage. Bez tego `/pl` da 404.
|
5. **Cookie Settings** — treść bannera per język.
|
||||||
5. **Cookie Settings** — treść bannera (bez tego lecą angielskie domyślne).
|
6. **Notifications** — teksty wyników formularza per język (opcjonalne, ma fallback).
|
||||||
|
7. **Site Integrations → SMTP** — transport (SMTP/Graph), From Name, From Address.
|
||||||
|
|
||||||
Wejdź na `/` — powinno przekierować na `/pl` i pokazać stronę.
|
Wejdź na `/` — powinno przekierować na `/pl`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rzeczy opcjonalne
|
## Opcjonalne
|
||||||
|
|
||||||
### Formularz z Turnstile
|
### Formularz z Turnstile
|
||||||
|
Wymaga bloku formularza (patrz [forms.md](./forms.md)) + Site Integrations →
|
||||||
|
Turnstile (klucze testowe Cloudflare: site `1x00000000000000000000AA`, secret
|
||||||
|
`1x0000000000000000000000000000000AA`). Maile wysyła form-builder przez
|
||||||
|
mailAdapter — nie pisze się ich w kodzie. Zgoda RODO: checkbox o nazwie `consent`.
|
||||||
|
|
||||||
Wymaga bloku formularza w projekcie (patrz forms.md) oraz:
|
### Blog / archiwum
|
||||||
|
Pełny opis: [content.md](./content.md). Kolekcja + content.config.ts +
|
||||||
- **Site Integrations → Turnstile** — site key i secret. Klucze testowe
|
przypisanie strony-archiwum w System Pages.
|
||||||
Cloudflare (zawsze przechodzą): site `1x00000000000000000000AA`, secret
|
|
||||||
`1x0000000000000000000000000000000AA`.
|
|
||||||
- **Site Integrations → SMTP** — host, port, user, hasło, adres nadawcy.
|
|
||||||
- **Forms → dany formularz → Emails** — odbiorca, temat, treść (`{{*:table}}`
|
|
||||||
wypisze wszystkie pola tabelką). Maile wysyła form-builder przez
|
|
||||||
`panelSmtpAdapter` — nie pisze się ich w kodzie.
|
|
||||||
|
|
||||||
|
|
||||||
### Blog / archiwum (kolekcja pod stroną-archiwum)
|
|
||||||
|
|
||||||
Pełny opis: content.md. W skrócie:
|
|
||||||
|
|
||||||
1. **Kolekcja** `src/collections/Posts.ts` — tytuł (localized), `buildSlugField`,
|
|
||||||
pola, bloki. Dodaj ją do `collections` w payload.config.
|
|
||||||
|
|
||||||
2. **content.config.ts** obok i18n.config.ts:
|
|
||||||
```ts
|
|
||||||
import type { ContentOption } from '@intecion/ipal-kit'
|
|
||||||
export const contentConfig: ContentOption = {
|
|
||||||
collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **payload.config** — `content: contentConfig`, plus `posts` w `seo.collections`.
|
|
||||||
|
|
||||||
4. **Front** — `createContentHelpers` w `src/lib/content.ts`, `resolveRoute`
|
|
||||||
w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList).
|
|
||||||
|
|
||||||
5. **Baza + typy** — nowa kolekcja to nowy schemat:
|
|
||||||
```bash
|
|
||||||
rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **W panelu** — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok
|
|
||||||
listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy.
|
|
||||||
|
|
||||||
Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny
|
|
||||||
typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config.
|
|
||||||
|
|
||||||
### Sitemapa i robots.txt
|
|
||||||
|
|
||||||
`createContentHelpers` oddaje gotowe handlery — dodaj `i18n` i `baseUrl` do jego
|
|
||||||
argumentów (patrz seo.md), potem dwa pliki po jednej linii:
|
|
||||||
|
|
||||||
|
### Sitemapa i robots
|
||||||
```ts
|
```ts
|
||||||
// app/sitemap.ts
|
// app/sitemap.ts
|
||||||
export { sitemap as default } from '@/lib/content'
|
export { sitemap as default } from '@/lib/content'
|
||||||
@@ -482,44 +487,22 @@ export { sitemap as default } from '@/lib/content'
|
|||||||
export { robots as default } from '@/lib/content'
|
export { robots as default } from '@/lib/content'
|
||||||
```
|
```
|
||||||
|
|
||||||
Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex.
|
|
||||||
|
|
||||||
### Analytics
|
|
||||||
|
|
||||||
**Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują
|
|
||||||
się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie
|
|
||||||
zaakceptuje kategorii Analytics.
|
|
||||||
|
|
||||||
### Przestylowanie pod klienta
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* styles.css */
|
|
||||||
:root {
|
|
||||||
--ipal-primary: #16a34a;
|
|
||||||
--ipal-radius: 1rem;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Pełna lista tokenów: consent.md.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kiedy coś nie działa
|
## Kiedy coś nie działa
|
||||||
|
|
||||||
| Objaw | Przyczyna |
|
| Objaw | Przyczyna |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Pusty tab SEO / brak kolekcji Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
|
| Pusty tab SEO / brak Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
|
||||||
| `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
|
| `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
|
||||||
| Banner bez stylów | brak `@source` na `node_modules/@intecion/ipal-kit` albo brak Tailwinda |
|
| `Cannot destructure property 'config'` (custom pole) | dublet `@payloadcms/ui` — peerDependency (playbook D) |
|
||||||
| `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline |
|
| Banner bez stylów | brak `@source` na node_modules albo brak Tailwinda |
|
||||||
| `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` |
|
| `/admin` i `/_next` → 500 | matcher w proxy nie jest inline |
|
||||||
|
| `slug.join is not a function` | `[slug]` zamiast `[[...slug]]` |
|
||||||
| `/pl` → 404 | Homepage nieustawiony w System Pages |
|
| `/pl` → 404 | Homepage nieustawiony w System Pages |
|
||||||
| `/en/cokolwiek` → 404, `/pl/cokolwiek` działa | pusty tytuł (a więc i slug) w locale EN |
|
| `/en/*` → 404, `/pl/*` działa | pusty tytuł/slug w locale EN |
|
||||||
| `Missing <html> and <body>` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma |
|
| `Missing <html> and <body>` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma |
|
||||||
| `SQLITE_ERROR: index … already exists` | zmiana schematu — usuń `*.db *.db-shm *.db-wal` |
|
| Zmiany w pluginie nie widać | `rm -rf .next`; sprawdź czy wciągnięto wersję (grep node_modules) |
|
||||||
| Zmiany w pluginie nie widać | Turbopack cache — `rm -rf .next` |
|
| Maile nie wychodzą | brak `email: mailAdapter()` albo pusty SMTP/Graph |
|
||||||
| Maile nie wychodzą | brak `email: panelSmtpAdapter()` w configu albo pusty SMTP w panelu |
|
| istnieje `middleware.ts` | USUŃ — Next 16 to `proxy.ts` |
|
||||||
| GTM ładuje się, brak `_ga` | pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 |
|
| zaszyta mapa `localizedRoutes` | antywzorzec — `getLocalizedSlugs` z bazy |
|
||||||
| `/pl/artykuly` → 404 | strona nieprzypisana jako archiwum w System Pages |
|
|
||||||
| brak pola „archive page" w panelu | brak `content` w configu albo `generate:importmap` po dodaniu |
|
|
||||||
| wpis 404 mimo że istnieje | slug pusty w tym locale — wypełnij tytuł w danym języku |
|
|
||||||
+33
-1
@@ -107,4 +107,36 @@ Zachowanie:
|
|||||||
locale z: cookie → Accept-Language → default
|
locale z: cookie → Accept-Language → default
|
||||||
- wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`)
|
- wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`)
|
||||||
|
|
||||||
`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne.
|
`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne.
|
||||||
|
|
||||||
|
## Cookie locale a zgoda (RODO)
|
||||||
|
|
||||||
|
Wybór języka zapisywany jest w cookie **`NEXT_LOCALE`** (konwencja Next.js —
|
||||||
|
kompatybilna z innymi bibliotekami i18n, które czytają aktywny locale). Ale
|
||||||
|
zapis podlega zgodzie: to cookie kategorii **functional**, więc:
|
||||||
|
|
||||||
|
- **Zapis TYLKO za zgodą.** Middleware zapisuje `NEXT_LOCALE` jedynie, gdy
|
||||||
|
użytkownik zgodził się na kategorię functional (`mayPersistLocale` sprawdza
|
||||||
|
zgodę). Bez zgody język działa per żądanie (negocjacja z Accept-Language),
|
||||||
|
ale nie jest utrwalany.
|
||||||
|
- **Sprzątanie po cofnięciu zgody.** Gdy użytkownik cofnie zgodę na functional,
|
||||||
|
cookie `NEXT_LOCALE` jest usuwane automatycznie (consent zna tę cookie przez
|
||||||
|
`DEFAULT_COOKIE_MAP` — patrz [consent.md](./consent.md)).
|
||||||
|
|
||||||
|
Nazwa cookie to jedna stała `LOCALE_COOKIE_NAME` (`modules/i18n/negotiateLocale`),
|
||||||
|
propagująca do middleware i sprzątania consent. Można nadpisać w
|
||||||
|
`createLocaleMiddleware({ cookieName })`, ale domyślnie `NEXT_LOCALE` jest
|
||||||
|
zalecane (interop).
|
||||||
|
|
||||||
|
### Kolejność negocjacji locale
|
||||||
|
|
||||||
|
1. Cookie `NEXT_LOCALE` (jeśli jest — czyli był wybór za zgodą)
|
||||||
|
2. Nagłówek `Accept-Language` (preferencje przeglądarki)
|
||||||
|
3. `defaultLocale` z konfiguracji
|
||||||
|
|
||||||
|
Wejście na `/` → negocjacja → redirect na `/pl` (albo wynik negocjacji).
|
||||||
|
Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą).
|
||||||
|
|
||||||
|
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
|
||||||
|
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
|
||||||
|
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# notifications
|
||||||
|
|
||||||
|
Teksty powiadomień (wyniki akcji) konfigurowane w panelu, per język, z
|
||||||
|
fallbackiem. Na dziś obsługuje komunikaty wyników formularza (`submitForm`),
|
||||||
|
z miejscem na przyszłe konteksty. Global **Notifications**, budowany zawsze.
|
||||||
|
|
||||||
|
## Zasada
|
||||||
|
|
||||||
|
Plugin daje KOD wyniku (`submitForm` zwraca `reason`), nie tekst. Ten moduł
|
||||||
|
mapuje kod → tekst z panelu (localized), z fallbackiem angielskim per pole.
|
||||||
|
Front dostaje gotowy string i styluje go jak chce (toast, inline, banner).
|
||||||
|
Dzięki temu żaden komunikat nie jest zaszyty w kodzie — wszystko przez panel.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Brak opcji — global **Notifications** jest zawsze budowany. Edytor zarządza
|
||||||
|
tekstami w panelu (karta Notifications), grupowane per kontekst. Grupa `form`:
|
||||||
|
`success`, `error`, `rateLimited`, `turnstile`, `validation`, `consent`,
|
||||||
|
`notFound`. Każde pole puste → fallback (NOTIFICATION_FALLBACK).
|
||||||
|
|
||||||
|
## Helper — getNotificationTexts
|
||||||
|
|
||||||
|
Pobiera teksty z globala per język, fallback per pole. Analog `getConsentTexts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { getNotificationTexts } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
const notifications = await getNotificationTexts({ payload, locale })
|
||||||
|
// notifications.form.error, notifications.form.success, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mapowanie wyniku — resolveFormMessage
|
||||||
|
|
||||||
|
Most między `submitForm` a UI: bierze wynik i teksty, zwraca jeden komunikat.
|
||||||
|
Interpoluje `{field}` w walidacji. NIGDY nie pokazuje surowego wyjątku
|
||||||
|
(`error` → generyczny tekst, nie treść błędu backendu).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { resolveFormMessage } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
const result = await submitFormAction(...)
|
||||||
|
if (!result.success) {
|
||||||
|
setError(resolveFormMessage(result, notifications.form))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To zastępuje sztywne `Błąd: ${result.reason}` — teraz przyjazny tekst z panelu,
|
||||||
|
per język.
|
||||||
|
|
||||||
|
## Interpolacja {field}
|
||||||
|
|
||||||
|
Tekst `validation` może zawierać `{field}` — podstawia się nazwa pola z błędem:
|
||||||
|
|
||||||
|
```
|
||||||
|
Panel: "Sprawdź pole {field} i spróbuj ponownie."
|
||||||
|
Wynik: "Sprawdź pole email i spróbuj ponownie."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rozszerzanie o nowe konteksty
|
||||||
|
|
||||||
|
Grupa `form` to pierwszy kontekst. Kolejne (`newsletter`, `system`) dodaje się
|
||||||
|
tak samo — nowa grupa w `globals/Notifications/fields.ts` + pole w typach +
|
||||||
|
fallback. `getNotificationTexts` resolwuje, co istnieje.
|
||||||
|
|
||||||
|
## Dostęp
|
||||||
|
|
||||||
|
Global ma `read: () => true` — teksty są publiczne (pokazywane użytkownikom
|
||||||
|
końcowym), więc front czyta je bez sesji. Inaczej niż SiteIntegrations
|
||||||
|
(`read: isAdmin` — tam sekrety).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# security
|
||||||
|
|
||||||
|
Generyczne nagłówki bezpieczeństwa HTTP (HSTS, X-Frame-Options, nosniff,
|
||||||
|
Referrer-Policy, Permissions-Policy) jako funkcja do `next.config`. CSP CELOWO
|
||||||
|
pominięte — zależy od domen projektu, zostaje w projekcie.
|
||||||
|
|
||||||
|
## Zasada
|
||||||
|
|
||||||
|
Nagłówki, które są IDENTYCZNE między projektami, plugin dostarcza raz. CSP
|
||||||
|
(Content-Security-Policy) wymaga znajomości domen konkretnego projektu (skąd
|
||||||
|
ładują się skrypty, obrazy, fonty, analytics), więc nie może być generyczne —
|
||||||
|
zostaje w projekcie, dodawane przez `additional`.
|
||||||
|
|
||||||
|
## Użycie — next.config.ts
|
||||||
|
|
||||||
|
Nagłówki wpina się w `next.config`, NIE w proxy — bo muszą pokryć CAŁĄ
|
||||||
|
aplikację (też `/admin`, statyki), a proxy pomija te trasy.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
import { withPayload } from '@payloadcms/next/withPayload'
|
||||||
|
import type { NextConfig } from 'next'
|
||||||
|
import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
const securityHeaders = buildSecurityHeaders({
|
||||||
|
hsts: process.env.NODE_ENV === 'production', // WAŻNE: off w dev (http)
|
||||||
|
additional: [
|
||||||
|
// CSP projektu — zna swoje domeny:
|
||||||
|
// { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
async headers() {
|
||||||
|
return [{ source: '/:path*', headers: securityHeaders }]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withPayload(nextConfig)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Opcje
|
||||||
|
|
||||||
|
| Opcja | Domyślnie | Rola |
|
||||||
|
|---|---|---|
|
||||||
|
| `hsts` | `true` | Strict-Transport-Security (wymuś HTTPS) |
|
||||||
|
| `hstsMaxAge` | `63072000` (2 lata) | max-age HSTS w sekundach |
|
||||||
|
| `hstsIncludeSubDomains` | `true` | HSTS na subdomeny |
|
||||||
|
| `hstsPreload` | `false` | preload (tylko jeśli zgłaszasz do listy) |
|
||||||
|
| `frameOptions` | `'DENY'` | X-Frame-Options (anty-clickjacking) |
|
||||||
|
| `referrerPolicy` | `'strict-origin-when-cross-origin'` | Referrer-Policy |
|
||||||
|
| `permissionsPolicy` | blokuje camera/mic/geolocation | Permissions-Policy |
|
||||||
|
| `additional` | `[]` | dodatkowe nagłówki (np. CSP); same-key nadpisuje |
|
||||||
|
|
||||||
|
## PUŁAPKA — HSTS w dev
|
||||||
|
|
||||||
|
HSTS nad HTTP na localhost może zablokować przeglądarkę na HTTPS dla localhost.
|
||||||
|
ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
|
||||||
|
|
||||||
|
## Nadpisywanie i CSP
|
||||||
|
|
||||||
|
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
||||||
|
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
||||||
|
plugin go nie generuje, bo zależy od projektu.
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
Pole slug generowane automatycznie z tytułu, per locale.
|
Pole slug generowane automatycznie z tytułu, per locale.
|
||||||
|
|
||||||
|
> **Plugin JUŻ to ma — nie pisz własnego auto-sluga.** Częsty błąd: projekt
|
||||||
|
> dodaje ręczne pole `{ name: 'slug', type: 'text' }` i każe redaktorowi wpisywać
|
||||||
|
> slug, albo pisze własny hook normalizujący. Nie trzeba — `buildSlugField`
|
||||||
|
> robi to lepiej: auto-generacja gdy puste, nie nadpisuje ręcznego, per-locale,
|
||||||
|
> diakrytyki PL→ASCII. Jeśli w kolekcji masz ręczny slug, ZAMIEŃ go na
|
||||||
|
> `buildSlugField({ from: 'title' })`.
|
||||||
|
|
||||||
## Użycie (kolekcja klienta)
|
## Użycie (kolekcja klienta)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
+18
-14
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.0.5",
|
"version": "1.0.17",
|
||||||
"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": {
|
||||||
@@ -38,7 +38,8 @@
|
|||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"docs"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||||
@@ -65,22 +66,25 @@
|
|||||||
"slugify": "^1.6.6"
|
"slugify": "^1.6.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@payloadcms/plugin-form-builder": "^3.84.1",
|
"@payloadcms/next": "^3.88.0",
|
||||||
"@payloadcms/plugin-seo": "^3.84.1",
|
"@payloadcms/plugin-form-builder": "^3.88.0",
|
||||||
|
"@payloadcms/plugin-seo": "^3.88.0",
|
||||||
|
"@payloadcms/ui": "^3.88.0",
|
||||||
"next": ">=15",
|
"next": ">=15",
|
||||||
"payload": "^3.84.1",
|
"payload": "^3.88.0",
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@payloadcms/db-postgres": "3.84.1",
|
"@payloadcms/db-postgres": "3.88.0",
|
||||||
"@payloadcms/db-sqlite": "3.84.1",
|
"@payloadcms/db-sqlite": "3.88.0",
|
||||||
"@payloadcms/eslint-config": "3.28.0",
|
"@payloadcms/eslint-config": "3.28.0",
|
||||||
"@payloadcms/next": "3.84.1",
|
"@payloadcms/next": "3.88.0",
|
||||||
"@payloadcms/plugin-form-builder": "3.84.1",
|
"@payloadcms/plugin-form-builder": "3.88.0",
|
||||||
"@payloadcms/plugin-seo": "3.84.1",
|
"@payloadcms/plugin-seo": "3.88.0",
|
||||||
"@payloadcms/richtext-lexical": "3.84.1",
|
"@payloadcms/richtext-lexical": "3.88.0",
|
||||||
"@payloadcms/ui": "3.84.1",
|
"@payloadcms/ui": "3.88.0",
|
||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.58.2",
|
||||||
"@swc-node/register": "1.10.9",
|
"@swc-node/register": "1.10.9",
|
||||||
"@swc/cli": "0.6.0",
|
"@swc/cli": "0.6.0",
|
||||||
@@ -96,7 +100,7 @@
|
|||||||
"mongodb-memory-server": "10.1.4",
|
"mongodb-memory-server": "10.1.4",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"payload": "3.84.1",
|
"payload": "3.88.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"qs-esm": "8.0.1",
|
"qs-esm": "8.0.1",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
|
|||||||
Generated
+122
-416
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'
|
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'
|
||||||
|
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.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
|
||||||
@@ -18,3 +19,6 @@ export {
|
|||||||
export type { CookieBannerClassNames } from '../modules/consent/client.js'
|
export type { CookieBannerClassNames } from '../modules/consent/client.js'
|
||||||
export { Turnstile } from '../modules/turnstile/client.js'
|
export { Turnstile } from '../modules/turnstile/client.js'
|
||||||
export type { TurnstileProps } from '../modules/turnstile/client.js'
|
export type { TurnstileProps } from '../modules/turnstile/client.js'
|
||||||
|
|
||||||
|
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'
|
||||||
|
export type { FormNotificationTexts } from '../modules/notifications/types.js'
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { TextFieldClientComponent } from 'payload'
|
import type { TextFieldClientComponent } from 'payload'
|
||||||
|
|
||||||
import { useField } from '@payloadcms/ui'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
export const MaskedField: TextFieldClientComponent = (props: any) => {
|
||||||
const { setValue, value } = useField<string>({ path })
|
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}
|
||||||
|
const [internalValue, setInternalValue] = useState(propValue ?? '')
|
||||||
const [revealed, setRevealed] = useState(false)
|
const [revealed, setRevealed] = useState(false)
|
||||||
const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)
|
const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newVal = e.target.value
|
||||||
|
setInternalValue(newVal)
|
||||||
|
if (typeof propSetValue === 'function') {
|
||||||
|
propSetValue(newVal)
|
||||||
|
}
|
||||||
|
if (typeof propOnChange === 'function') {
|
||||||
|
propOnChange(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = propValue !== undefined ? propValue : internalValue
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="field-type text">
|
<div className="field-type text">
|
||||||
<label className="field-label">{label}</label>
|
{label && <label className="field-label">{label}</label>}
|
||||||
<div style={{ display: 'flex', gap: '.5rem' }}>
|
<div style={{ display: 'flex', gap: '.5rem' }}>
|
||||||
<input
|
<input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={handleChange}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
type={revealed ? 'text' : 'password'}
|
type={revealed ? 'text' : 'password'}
|
||||||
value={value ?? ''}
|
value={currentValue ?? ''}
|
||||||
/>
|
/>
|
||||||
<button onClick={() => setRevealed((r) => !r)} type="button">
|
<button onClick={() => setRevealed((r) => !r)} type="button">
|
||||||
{revealed ? 'Hide' : 'Reveal'}
|
{revealed ? 'Hide' : 'Reveal'}
|
||||||
@@ -27,4 +39,5 @@ export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MaskedField
|
export default MaskedField
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin UI: a small "send test email" tool for the SiteIntegrations email tab.
|
||||||
|
* Enter an address, click Send, and it POSTs to /api/ipal/test-email, which
|
||||||
|
* sends through the currently-selected transport (SMTP or Graph). Shows the
|
||||||
|
* result inline so you can confirm delivery — or read the exact error — without
|
||||||
|
* leaving the panel.
|
||||||
|
*
|
||||||
|
* Assigned via a `ui` field's admin.components.Field.
|
||||||
|
*/
|
||||||
|
export const TestEmailButton = () => {
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [status, setStatus] = useState<
|
||||||
|
| { kind: 'error'; msg: string }
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'ok'; msg: string }
|
||||||
|
| { kind: 'sending' }
|
||||||
|
>({ kind: 'idle' })
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!to.trim()) {
|
||||||
|
setStatus({ kind: 'error', msg: 'Enter a recipient address.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus({ kind: 'sending' })
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ipal/test-email', {
|
||||||
|
body: JSON.stringify({ to: to.trim() }),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok) {
|
||||||
|
setStatus({ kind: 'ok', msg: data.message ?? 'Test email sent.' })
|
||||||
|
} else {
|
||||||
|
setStatus({ kind: 'error', msg: data.error ?? 'Send failed.' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: 'error', msg: 'Request failed. Is the server running?' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="field-type" style={{ marginTop: '1rem' }}>
|
||||||
|
<label className="field-label">Send a test email</label>
|
||||||
|
<p style={{ fontSize: '.85rem', marginTop: 0, opacity: 0.7 }}>
|
||||||
|
Sends through the transport selected above. Save your changes first.
|
||||||
|
</p>
|
||||||
|
<div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>
|
||||||
|
<input
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
style={{ flex: 1, minWidth: '220px' }}
|
||||||
|
type="email"
|
||||||
|
value={to}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn--style-secondary"
|
||||||
|
disabled={status.kind === 'sending'}
|
||||||
|
onClick={send}
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{status.kind === 'sending' ? 'Sending…' : 'Send test'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{status.kind === 'ok' && (
|
||||||
|
<p style={{ color: 'var(--theme-success-500, green)', marginTop: '.5rem' }}>
|
||||||
|
✓ {status.msg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.kind === 'error' && (
|
||||||
|
<p style={{ color: 'var(--theme-error-500, crimson)', marginTop: '.5rem' }}>
|
||||||
|
✗ {status.msg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TestEmailButton
|
||||||
@@ -8,8 +8,30 @@ import type { Field } from 'payload'
|
|||||||
* panel while remaining inaccessible to anonymous API requests.
|
* panel while remaining inaccessible to anonymous API requests.
|
||||||
*/
|
*/
|
||||||
export const smtpFields: Field[] = [
|
export const smtpFields: Field[] = [
|
||||||
|
{
|
||||||
|
name: 'emailTransport',
|
||||||
|
type: 'select',
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'How outbound email is sent. "Microsoft Graph" is only available when configured by the administrator (Intecion).',
|
||||||
|
// The Graph option only makes sense when agency credentials exist in env.
|
||||||
|
// We can't read process.env in the admin UI directly, so a client project
|
||||||
|
// that hasn't set up Graph should filter this option via integrationsFields
|
||||||
|
// override, or simply leave it on 'smtp'. The adapter enforces the real
|
||||||
|
// availability at send time regardless of what's selected here.
|
||||||
|
},
|
||||||
|
defaultValue: 'smtp',
|
||||||
|
options: [
|
||||||
|
{ label: 'SMTP', value: 'smtp' },
|
||||||
|
{ label: 'Microsoft Graph (Exchange)', value: 'graph' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'row',
|
type: 'row',
|
||||||
|
admin: {
|
||||||
|
// Hide SMTP fields when Graph is selected — they're not used then.
|
||||||
|
condition: (_, siblingData) => siblingData?.emailTransport !== 'graph',
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'smtpHost',
|
name: 'smtpHost',
|
||||||
@@ -56,4 +78,13 @@ export const smtpFields: Field[] = [
|
|||||||
description: 'Default "from" display name.',
|
description: 'Default "from" display name.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'emailTest',
|
||||||
|
type: 'ui',
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: '@intecion/ipal-kit/client#TestEmailButton',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export {
|
|||||||
resolveRoute,
|
resolveRoute,
|
||||||
} from './modules/content/index.js'
|
} from './modules/content/index.js'
|
||||||
export type { ArchiveEntries } from './modules/content/index.js'
|
export type { ArchiveEntries } from './modules/content/index.js'
|
||||||
|
export { graphAdapter } from './modules/email/graphAdapter.js'
|
||||||
|
export type { GraphAdapterArgs } from './modules/email/graphAdapter.js'
|
||||||
|
export { mailAdapter } from './modules/email/mailAdapter.js'
|
||||||
|
export type { MailAdapterArgs } from './modules/email/mailAdapter.js'
|
||||||
// Imported straight from the file, NOT from ./modules/email/index.js — that
|
// Imported straight from the file, NOT from ./modules/email/index.js — that
|
||||||
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
|
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
|
||||||
// Payload loads the config (or runs generate:importmap) as a plain Node script.
|
// Payload loads the config (or runs generate:importmap) as a plain Node script.
|
||||||
@@ -81,6 +85,8 @@ export {
|
|||||||
SITE_INTEGRATIONS_SLUG,
|
SITE_INTEGRATIONS_SLUG,
|
||||||
SITE_SETTINGS_SLUG,
|
SITE_SETTINGS_SLUG,
|
||||||
} from './modules/payload/index.js'
|
} from './modules/payload/index.js'
|
||||||
|
export { buildSecurityHeaders } from './modules/security/index.js'
|
||||||
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
||||||
@@ -93,5 +99,18 @@ export {
|
|||||||
injectAutoFillMeta,
|
injectAutoFillMeta,
|
||||||
} from './modules/seo/index.js'
|
} from './modules/seo/index.js'
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js'
|
export { buildSlugField, toSlug } from './modules/slug/index.js'
|
||||||
|
export {
|
||||||
|
NOTIFICATION_FALLBACK,
|
||||||
|
getNotificationTexts,
|
||||||
|
resolveFormMessage,
|
||||||
|
} from './modules/notifications/index.js'
|
||||||
|
export type {
|
||||||
|
FormNotificationTexts,
|
||||||
|
NotificationsData,
|
||||||
|
NotificationTexts,
|
||||||
|
} from './modules/notifications/index.js'
|
||||||
|
|
||||||
export { ipalKit } from './plugin.js'
|
export { ipalKit } from './plugin.js'
|
||||||
export type { IpalOptions } from './types.js'
|
export type { IpalOptions } from './types.js'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export type { SecurityHeader, BuildSecurityHeadersArgs } from './modules/security/index.js' -c buildSecurityHeaders src/index.ts
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'
|
||||||
|
|
||||||
|
import { getSiteIntegrations } from '../payload/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From/To settings the adapter reads from SiteIntegrations (panel). The Graph
|
||||||
|
* CREDENTIALS themselves are NOT here — they're agency secrets in env vars
|
||||||
|
* (this is *our* Exchange, shared across projects), read below from process.env.
|
||||||
|
* The panel only controls the display-from and where submissions land.
|
||||||
|
*/
|
||||||
|
type GraphIntegrations = {
|
||||||
|
/**
|
||||||
|
* Display From — reused from the existing SMTP fields, because the sender
|
||||||
|
* label is the same concept regardless of transport (SMTP or Graph). No new
|
||||||
|
* panel field needed; whatever the editor set as the from-address applies.
|
||||||
|
*/
|
||||||
|
smtpFromAddress?: null | string
|
||||||
|
smtpFromName?: null | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GraphAdapterArgs = {
|
||||||
|
fallbackFromAddress?: string
|
||||||
|
fallbackFromName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphEnv = {
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
sender: string
|
||||||
|
tenantId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads + validates the agency Graph credentials from env. */
|
||||||
|
function readGraphEnv(): GraphEnv | null {
|
||||||
|
const tenantId = process.env.GRAPH_TENANT_ID
|
||||||
|
const clientId = process.env.GRAPH_CLIENT_ID
|
||||||
|
const clientSecret = process.env.GRAPH_CLIENT_SECRET
|
||||||
|
const sender = process.env.GRAPH_SENDER
|
||||||
|
if (!tenantId || !clientId || !clientSecret || !sender) {return null}
|
||||||
|
return { clientId, clientSecret, sender, tenantId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches an app-only access token via the OAuth2 client-credentials flow.
|
||||||
|
* Scope MUST be '.../.default' — passing 'Mail.Send' directly is rejected
|
||||||
|
* (AADSTS1002012). Tokens last ~1h; we fetch per send for simplicity and to
|
||||||
|
* avoid holding state in a possibly multi-instance deployment. If you send at
|
||||||
|
* high volume, cache by expiry.
|
||||||
|
*/
|
||||||
|
async function getAccessToken(env: GraphEnv): Promise<string> {
|
||||||
|
const url = `https://login.microsoftonline.com/${env.tenantId}/oauth2/v2.0/token`
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: env.clientId,
|
||||||
|
client_secret: env.clientSecret,
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
scope: 'https://graph.microsoft.com/.default',
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
body,
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text()
|
||||||
|
throw new Error(`Graph token request failed (${res.status}): ${detail}`)
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { access_token?: string }
|
||||||
|
if (!data.access_token) {throw new Error('Graph token response had no access_token')}
|
||||||
|
return data.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes Payload's to/cc (string | string[] | Address[]) into Graph recipients. */
|
||||||
|
function toRecipients(value: SendEmailOptions['to']): { emailAddress: { address: string } }[] {
|
||||||
|
if (!value) {return []}
|
||||||
|
const list = Array.isArray(value) ? value : [value]
|
||||||
|
return list
|
||||||
|
.map((v) => (typeof v === 'string' ? v : (v as { address?: string }).address))
|
||||||
|
.filter((a): a is string => typeof a === 'string' && a.length > 0)
|
||||||
|
.map((address) => ({ emailAddress: { address } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload email adapter that sends through Microsoft Graph (our Exchange),
|
||||||
|
* using app-only client-credentials auth. Drop-in alternative to
|
||||||
|
* panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail
|
||||||
|
* and the form-builder's submission emails work unchanged.
|
||||||
|
*
|
||||||
|
* Split of configuration (deliberate):
|
||||||
|
* - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.
|
||||||
|
* The client never sees or sets them — it's our Exchange, one mailbox
|
||||||
|
* (GRAPH_SENDER, e.g. forms@intecion.pl) for every project.
|
||||||
|
* - From-display + recipient = per-project, from the panel (SiteIntegrations),
|
||||||
|
* so an editor controls how the mail is labelled and where it lands.
|
||||||
|
*
|
||||||
|
* Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()
|
||||||
|
*
|
||||||
|
* Azure setup (one-time, our side): App registration → Mail.Send APPLICATION
|
||||||
|
* permission → admin consent → in Exchange, grant the app "Send As" on the
|
||||||
|
* shared mailbox GRAPH_SENDER.
|
||||||
|
*/
|
||||||
|
export const graphAdapter =
|
||||||
|
(args: GraphAdapterArgs = {}): PayloadEmailAdapter =>
|
||||||
|
({ payload }) => ({
|
||||||
|
name: 'ipal-graph',
|
||||||
|
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
|
||||||
|
defaultFromName: args.fallbackFromName ?? 'Website',
|
||||||
|
|
||||||
|
sendEmail: async (message: SendEmailOptions) => {
|
||||||
|
const env = readGraphEnv()
|
||||||
|
if (!env) {
|
||||||
|
payload.logger.error(
|
||||||
|
'[ipal] Email not sent: Graph is not configured. Set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER.',
|
||||||
|
)
|
||||||
|
return { error: 'Graph is not configured (missing env vars).', sent: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display name on the From, WITHOUT triggering Send-As.
|
||||||
|
//
|
||||||
|
// The trick: we may set a `from` as long as its ADDRESS stays the sender
|
||||||
|
// mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
|
||||||
|
// demands Send-As when the from ADDRESS differs from the mailbox, so a
|
||||||
|
// same-address / custom-name From is allowed and gives each project its
|
||||||
|
// own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
|
||||||
|
//
|
||||||
|
// The panel's from-address becomes Reply-To (so replies reach the client),
|
||||||
|
// and the panel's from-name becomes the sender display name.
|
||||||
|
const panel = await getSiteIntegrations<GraphIntegrations>(payload)
|
||||||
|
const replyToAddress = panel.smtpFromAddress || undefined
|
||||||
|
const senderName = panel.smtpFromName || undefined
|
||||||
|
|
||||||
|
const to = toRecipients(message.to)
|
||||||
|
if (to.length === 0) {
|
||||||
|
payload.logger.error('[ipal] Email not sent: no valid recipient.')
|
||||||
|
return { error: 'No valid recipient.', sent: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph accepts either HTML or Text; Payload gives us html and/or text.
|
||||||
|
const isHtml = typeof message.html === 'string' && message.html.length > 0
|
||||||
|
const content = isHtml ? String(message.html) : String(message.text ?? '')
|
||||||
|
|
||||||
|
// Reply-To: prefer whatever the caller set; otherwise the panel address.
|
||||||
|
const replyTo = message.replyTo
|
||||||
|
? toRecipients(message.replyTo as SendEmailOptions['to'])
|
||||||
|
: replyToAddress
|
||||||
|
? [{ emailAddress: { address: replyToAddress } }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const graphMessage: Record<string, unknown> = {
|
||||||
|
body: { content, contentType: isHtml ? 'HTML' : 'Text' },
|
||||||
|
subject: message.subject ?? '',
|
||||||
|
toRecipients: to,
|
||||||
|
...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}),
|
||||||
|
...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}),
|
||||||
|
// From with the sender's OWN address (no Send-As) plus an optional
|
||||||
|
// display name from the panel. Omit entirely when no name is set —
|
||||||
|
// Graph then uses the mailbox's default name.
|
||||||
|
...(senderName
|
||||||
|
? { from: { emailAddress: { name: senderName, address: env.sender } } }
|
||||||
|
: {}),
|
||||||
|
...(replyTo.length > 0 ? { replyTo } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(env)
|
||||||
|
// App-only: MUST target /users/{sender}, never /me.
|
||||||
|
const res = await fetch(
|
||||||
|
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(env.sender)}/sendMail`,
|
||||||
|
{
|
||||||
|
body: JSON.stringify({ message: graphMessage, saveToSentItems: false }),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// sendMail returns 202 Accepted with an empty body on success.
|
||||||
|
if (res.status === 202) {
|
||||||
|
return { sent: true }
|
||||||
|
}
|
||||||
|
const detail = await res.text()
|
||||||
|
payload.logger.error(`[ipal] Graph sendMail failed (${res.status}): ${detail}`)
|
||||||
|
return { error: `Graph sendMail failed (${res.status}).`, sent: false }
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
payload.logger.error(`[ipal] Graph send error: ${msg}`)
|
||||||
|
return { error: 'Graph send error.', sent: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
export { graphAdapter } from './graphAdapter.js'
|
||||||
|
export type { GraphAdapterArgs } from './graphAdapter.js'
|
||||||
|
export { mailAdapter } from './mailAdapter.js'
|
||||||
|
export type { MailAdapterArgs } from './mailAdapter.js'
|
||||||
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
|
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
|
||||||
// so this must never be imported from a client component.
|
// so this must never be imported from a client component.
|
||||||
export { sendEmail } from './sendEmail.js'
|
export { sendEmail } from './sendEmail.js'
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'
|
||||||
|
|
||||||
|
import { getSiteIntegrations } from '../payload/index.js'
|
||||||
|
import { graphAdapter, type GraphAdapterArgs } from './graphAdapter.js'
|
||||||
|
import { panelSmtpAdapter, type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js'
|
||||||
|
|
||||||
|
type TransportIntegrations = {
|
||||||
|
/** 'smtp' | 'graph' — chosen by the editor in SiteIntegrations. */
|
||||||
|
emailTransport?: 'graph' | 'smtp' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MailAdapterArgs = {
|
||||||
|
fallbackFromAddress?: string
|
||||||
|
fallbackFromName?: string
|
||||||
|
graph?: GraphAdapterArgs
|
||||||
|
smtp?: PanelSmtpAdapterArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the agency Graph credentials are present in the environment. */
|
||||||
|
function graphAvailable(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
process.env.GRAPH_TENANT_ID &&
|
||||||
|
process.env.GRAPH_CLIENT_ID &&
|
||||||
|
process.env.GRAPH_CLIENT_SECRET &&
|
||||||
|
process.env.GRAPH_SENDER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatcher email adapter: wired into the config ONCE, but picks the transport
|
||||||
|
* (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.
|
||||||
|
* This is what makes the choice switchable in the panel — Payload builds the
|
||||||
|
* email adapter at boot and can't swap it at runtime, so instead of choosing
|
||||||
|
* between two adapters at boot we install one that delegates on every send.
|
||||||
|
*
|
||||||
|
* Availability guard: Graph only runs if its agency credentials exist in env
|
||||||
|
* (this is *our* Exchange). If the panel says 'graph' but env isn't set up,
|
||||||
|
* we DON'T silently fail — we log clearly and fall back to SMTP, so a client
|
||||||
|
* flipping the switch without the backing config still gets mail out (over SMTP)
|
||||||
|
* rather than silent nothing. If neither is usable, the send reports an error.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // payload.config.ts
|
||||||
|
* import { mailAdapter } from '@intecion/ipal-kit'
|
||||||
|
* email: mailAdapter()
|
||||||
|
*/
|
||||||
|
export const mailAdapter =
|
||||||
|
(args: MailAdapterArgs = {}): PayloadEmailAdapter =>
|
||||||
|
(deps) => {
|
||||||
|
// Build both delegates once; each still resolves its own config per send.
|
||||||
|
const smtp = panelSmtpAdapter({
|
||||||
|
fallbackFromAddress: args.fallbackFromAddress,
|
||||||
|
fallbackFromName: args.fallbackFromName,
|
||||||
|
...args.smtp,
|
||||||
|
})(deps)
|
||||||
|
const graph = graphAdapter({
|
||||||
|
fallbackFromAddress: args.fallbackFromAddress,
|
||||||
|
fallbackFromName: args.fallbackFromName,
|
||||||
|
...args.graph,
|
||||||
|
})(deps)
|
||||||
|
|
||||||
|
const { payload } = deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'ipal-mail-dispatcher',
|
||||||
|
defaultFromAddress: smtp.defaultFromAddress,
|
||||||
|
defaultFromName: smtp.defaultFromName,
|
||||||
|
|
||||||
|
sendEmail: async (message: SendEmailOptions) => {
|
||||||
|
const settings = await getSiteIntegrations<TransportIntegrations>(payload)
|
||||||
|
const choice = settings.emailTransport ?? 'smtp'
|
||||||
|
|
||||||
|
if (choice === 'graph') {
|
||||||
|
if (graphAvailable()) {
|
||||||
|
return graph.sendEmail(message)
|
||||||
|
}
|
||||||
|
// Panel asked for Graph but the agency creds aren't configured for
|
||||||
|
// this project. Fall back to SMTP rather than silently dropping mail.
|
||||||
|
payload.logger.warn(
|
||||||
|
'[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.',
|
||||||
|
)
|
||||||
|
return smtp.sendEmail(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return smtp.sendEmail(message)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Endpoint, PayloadRequest } from 'payload'
|
||||||
|
|
||||||
|
import { addDataAndFileToRequest } from 'payload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom endpoint: send a test email to a given address through whatever
|
||||||
|
* transport is currently active (SMTP or Graph — mailAdapter reads the panel
|
||||||
|
* setting per send, so the test exercises the REAL path a form email would
|
||||||
|
* take). Mounted at POST /api/ipal/test-email.
|
||||||
|
*
|
||||||
|
* Admin-only: uses payload.sendEmail (server-side), and requires an
|
||||||
|
* authenticated admin user — a test-send button must never be open to the
|
||||||
|
* public (it would be an open relay / spam vector).
|
||||||
|
*
|
||||||
|
* Returns the adapter's own result so the panel can show exactly what happened,
|
||||||
|
* including the transport-specific error (SMTP auth failure, Graph 401, etc.).
|
||||||
|
*/
|
||||||
|
export const testEmailEndpoint: Endpoint = {
|
||||||
|
handler: async (req: PayloadRequest) => {
|
||||||
|
// Auth: only signed-in admins may trigger a send.
|
||||||
|
if (!req.user) {
|
||||||
|
return Response.json({ error: 'Unauthorized', ok: false }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await addDataAndFileToRequest(req)
|
||||||
|
const to = (req.data?.to as string | undefined)?.trim()
|
||||||
|
|
||||||
|
if (!to || !/^[^@\s]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(to)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Provide a valid recipient address.', ok: false },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await req.payload.sendEmail({
|
||||||
|
html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',
|
||||||
|
subject: 'ipal-kit — test email',
|
||||||
|
text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',
|
||||||
|
to,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Payload's sendEmail resolves with the adapter's result. Our adapters
|
||||||
|
// return { sent: boolean, error?: string }; nodemailer returns info with
|
||||||
|
// messageId. Normalize to a simple ok/message for the panel.
|
||||||
|
const sent =
|
||||||
|
info && typeof info === 'object' && 'sent' in info
|
||||||
|
? (info as { sent?: boolean }).sent !== false
|
||||||
|
: true
|
||||||
|
|
||||||
|
if (!sent) {
|
||||||
|
const error = (info as { error?: string })?.error ?? 'Send failed (see server logs).'
|
||||||
|
return Response.json({ error, ok: false }, { status: 502 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ message: `Test email sent to ${to}.`, ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
req.payload.logger.error(`[ipal] Test email failed: ${message}`)
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Send failed. Check transport settings and server logs.', ok: false },
|
||||||
|
{ status: 502 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
method: 'post',
|
||||||
|
path: '/ipal/test-email',
|
||||||
|
}
|
||||||
@@ -3,7 +3,14 @@ import type { I18nConfig } from './types.js'
|
|||||||
import { getLocaleCodes, isValidLocale } from './helpers.js'
|
import { getLocaleCodes, isValidLocale } from './helpers.js'
|
||||||
|
|
||||||
/** Cookie name the template uses to persist a visitor's locale choice. */
|
/** Cookie name the template uses to persist a visitor's locale choice. */
|
||||||
export const LOCALE_COOKIE_NAME = 'ipal-locale'
|
/**
|
||||||
|
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||||
|
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||||
|
* other libraries that read the active locale (instead of a plugin-specific
|
||||||
|
* name). Written only under functional consent; cleared when that consent is
|
||||||
|
* withdrawn (see consent cookieMap).
|
||||||
|
*/
|
||||||
|
export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE'
|
||||||
|
|
||||||
type NegotiateLocaleArgs = {
|
type NegotiateLocaleArgs = {
|
||||||
/** Raw Accept-Language header value */
|
/** Raw Accept-Language header value */
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { buildSecurityHeaders } from './buildSecurityHeaders.js'
|
||||||
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'
|
||||||
+12
-1
@@ -3,10 +3,12 @@ import type { Config, Plugin } from 'payload'
|
|||||||
import type { IpalOptions } from './types.js'
|
import type { IpalOptions } from './types.js'
|
||||||
|
|
||||||
import { buildCookieSettings } from './globals/CookieSettings/index.js'
|
import { buildCookieSettings } from './globals/CookieSettings/index.js'
|
||||||
|
import { buildNotifications } from './globals/Notifications/index.js'
|
||||||
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'
|
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'
|
||||||
import { buildSiteSettings } from './globals/SiteSettings/index.js'
|
import { buildSiteSettings } from './globals/SiteSettings/index.js'
|
||||||
import { injectRoles } from './modules/access/index.js'
|
import { injectRoles } from './modules/access/index.js'
|
||||||
import { buildArchiveFields } from './modules/content/index.js'
|
import { buildArchiveFields } from './modules/content/index.js'
|
||||||
|
import { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js'
|
||||||
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'
|
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'
|
||||||
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'
|
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'
|
||||||
import { buildSystemPagesFields } from './modules/pages/index.js'
|
import { buildSystemPagesFields } from './modules/pages/index.js'
|
||||||
@@ -94,12 +96,21 @@ export const ipalKit = (options: IpalOptions): Plugin => {
|
|||||||
}),
|
}),
|
||||||
buildSiteIntegrations({ additionalFields: options.integrationsFields }),
|
buildSiteIntegrations({ additionalFields: options.integrationsFields }),
|
||||||
buildCookieSettings(),
|
buildCookieSettings(),
|
||||||
|
buildNotifications(),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// --- endpoints ---
|
||||||
|
// Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe
|
||||||
|
// message through the currently selected transport, so the panel's "send
|
||||||
|
// test" button can confirm delivery without leaving the admin UI.
|
||||||
|
config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint]
|
||||||
|
|
||||||
// --- hooks: onInit ---
|
// --- hooks: onInit ---
|
||||||
const incomingOnInit = config.onInit
|
const incomingOnInit = config.onInit
|
||||||
config.onInit = async (payload) => {
|
config.onInit = async (payload) => {
|
||||||
if (incomingOnInit) {await incomingOnInit(payload)}
|
if (incomingOnInit) {
|
||||||
|
await incomingOnInit(payload)
|
||||||
|
}
|
||||||
payload.logger.info('[ipal] Plugin initialized.')
|
payload.logger.info('[ipal] Plugin initialized.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user