Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b9e1ceda |
Vendored
-3
@@ -1,5 +1,4 @@
|
|||||||
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
|
||||||
@@ -12,5 +11,3 @@ 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,6 +1,4 @@
|
|||||||
'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
|
||||||
@@ -10,6 +8,5 @@ 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 { 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"}
|
{"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"}
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
import type { Field } from 'payload';
|
|
||||||
/**
|
|
||||||
* Fields for the Notifications global — localized user-facing texts for action
|
|
||||||
* results (form submission outcomes, and future contexts). Every text is
|
|
||||||
* localized: true so each language has its own value. Empty fields fall back to
|
|
||||||
* built-in English defaults (see modules/notifications/defaults).
|
|
||||||
*
|
|
||||||
* Grouped per context. `form` holds the outcomes of submitForm; more groups
|
|
||||||
* (e.g. `newsletter`, `system`) can be added the same way without touching
|
|
||||||
* consumers — getNotificationTexts resolves whatever exists, falling back
|
|
||||||
* per field.
|
|
||||||
*/
|
|
||||||
export declare const notificationsFields: Field[];
|
|
||||||
Vendored
-82
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
@@ -1 +0,0 @@
|
|||||||
{"version":3,"sources":["../../../src/globals/Notifications/fields.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Fields for the Notifications global — localized user-facing texts for action\n * results (form submission outcomes, and future contexts). Every text is\n * localized: true so each language has its own value. Empty fields fall back to\n * built-in English defaults (see modules/notifications/defaults).\n *\n * Grouped per context. `form` holds the outcomes of submitForm; more groups\n * (e.g. `newsletter`, `system`) can be added the same way without touching\n * consumers — getNotificationTexts resolves whatever exists, falling back\n * per field.\n */\nexport const notificationsFields: Field[] = [\n {\n name: 'form',\n type: 'group',\n admin: {\n description:\n 'Messages shown after a form is submitted. Leave a field empty to use the built-in default.',\n },\n fields: [\n {\n name: 'success',\n type: 'text',\n admin: { placeholder: 'Thank you — your message has been sent.' },\n localized: true,\n },\n {\n name: 'error',\n type: 'text',\n admin: { placeholder: 'Something went wrong. Please try again later.' },\n localized: true,\n },\n {\n name: 'rateLimited',\n type: 'text',\n admin: { placeholder: 'Too many attempts. Please wait a moment and try again.' },\n localized: true,\n },\n {\n name: 'turnstile',\n type: 'text',\n admin: { placeholder: 'Captcha verification failed. Please try again.' },\n localized: true,\n },\n {\n name: 'validation',\n type: 'text',\n admin: {\n description:\n 'Shown on a validation error. Use {field} to insert the offending field name.',\n placeholder: 'Please check the {field} field and try again.',\n },\n localized: true,\n },\n {\n name: 'consent',\n type: 'text',\n admin: {\n description: 'Shown when the GDPR consent checkbox is left unchecked.',\n placeholder: 'Please accept the privacy policy to continue.',\n },\n localized: true,\n },\n {\n name: 'notFound',\n type: 'text',\n admin: { placeholder: 'This form is no longer available.' },\n localized: true,\n },\n ],\n label: 'Form messages',\n },\n]\n"],"names":["notificationsFields","name","type","admin","description","fields","placeholder","localized","label"],"mappings":"AAEA;;;;;;;;;;CAUC,GACD,OAAO,MAAMA,sBAA+B;IAC1C;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAC,QAAQ;YACN;gBACEJ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAA0C;gBAChEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAgD;gBACtEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAyD;gBAC/EC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAiD;gBACvEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBACLC,aACE;oBACFE,aAAa;gBACf;gBACAC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBACLC,aAAa;oBACbE,aAAa;gBACf;gBACAC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAoC;gBAC1DC,WAAW;YACb;SACD;QACDC,OAAO;IACT;CACD,CAAA"}
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
import type { GlobalConfig } from 'payload';
|
|
||||||
/**
|
|
||||||
* Builds the Notifications global — localized action-result texts. Readable by
|
|
||||||
* any authenticated panel user; server-side helpers read it with overrideAccess
|
|
||||||
* so the frontend can resolve texts without a session.
|
|
||||||
*/
|
|
||||||
export declare function buildNotifications(): GlobalConfig;
|
|
||||||
Vendored
-17
@@ -1,17 +0,0 @@
|
|||||||
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
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import type { TextFieldClientComponent } from 'payload';
|
|
||||||
export declare const MaskedField: TextFieldClientComponent;
|
|
||||||
export default MaskedField;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
'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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
'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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
+1
-36
@@ -5,30 +5,8 @@
|
|||||||
* 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',
|
||||||
@@ -59,11 +37,7 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,15 +53,6 @@
|
|||||||
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 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"}
|
{"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"}
|
||||||
+1
-5
@@ -31,11 +31,7 @@
|
|||||||
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 // 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"}
|
{"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"}
|
||||||
+1
-5
@@ -17,11 +17,7 @@
|
|||||||
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 // 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"}
|
{"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"}
|
||||||
Vendored
-8
@@ -7,10 +7,6 @@ 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';
|
||||||
@@ -24,14 +20,10 @@ 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,8 +2,6 @@ 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.
|
||||||
@@ -14,11 +12,9 @@ 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
@@ -1,25 +0,0 @@
|
|||||||
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
-181
@@ -1,181 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// The panel's from-address is used as Reply-To, NOT as the message From.
|
|
||||||
//
|
|
||||||
// Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a
|
|
||||||
// `from` that differs from that mailbox, Exchange demands "Send As"
|
|
||||||
// permission on it and rejects with ErrorSendAsDenied otherwise. So we
|
|
||||||
// never override `from` — Graph stamps the mail as GRAPH_SENDER (the
|
|
||||||
// mailbox we legitimately own) — and route replies to the panel address
|
|
||||||
// via Reply-To. Recipients see the mail from forms@… but replying reaches
|
|
||||||
// the real destination. No Send-As needed.
|
|
||||||
const panel = await getSiteIntegrations(payload);
|
|
||||||
const replyToAddress = panel.smtpFromAddress || undefined;
|
|
||||||
const replyToName = 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,
|
|
||||||
...replyToName ? {
|
|
||||||
name: replyToName
|
|
||||||
} : {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
] : [];
|
|
||||||
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)
|
|
||||||
} : {},
|
|
||||||
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As.
|
|
||||||
...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,6 +1,2 @@
|
|||||||
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,5 +1,3 @@
|
|||||||
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":["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"}
|
{"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"}
|
||||||
Vendored
-28
@@ -1,28 +0,0 @@
|
|||||||
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
@@ -1,58 +0,0 @@
|
|||||||
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
@@ -1 +0,0 @@
|
|||||||
{"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
@@ -1,15 +0,0 @@
|
|||||||
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
@@ -1,74 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"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
+2
-9
@@ -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({ consentFieldName, data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) {
|
*/ export async function submitForm({ 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, consentFieldName);
|
const validation = await validateSubmission(payload, formId, data);
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
if (validation.reason === 'not_found') {
|
if (validation.reason === 'not_found') {
|
||||||
return {
|
return {
|
||||||
@@ -51,13 +51,6 @@ 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 /**\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"}
|
{"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"}
|
||||||
+2
-22
@@ -12,14 +12,6 @@
|
|||||||
'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.
|
||||||
@@ -32,7 +24,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, consentFieldName = DEFAULT_CONSENT_FIELD) {
|
*/ export async function validateSubmission(payload, formId, data) {
|
||||||
let form;
|
let form;
|
||||||
try {
|
try {
|
||||||
form = await payload.findByID({
|
form = await payload.findByID({
|
||||||
@@ -55,19 +47,7 @@
|
|||||||
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;
|
||||||
// GDPR consent gate: a field matching the consent name must be truthy
|
if (field.required && isBlank) {
|
||||||
// (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
-7
@@ -1,7 +0,0 @@
|
|||||||
import type { NotificationTexts } from './types.js';
|
|
||||||
/**
|
|
||||||
* Built-in English fallbacks, used per field when the Notifications global
|
|
||||||
* leaves a text empty. Same philosophy as consent FALLBACK: the site works out
|
|
||||||
* of the box, editors override per locale as needed.
|
|
||||||
*/
|
|
||||||
export declare const NOTIFICATION_FALLBACK: NotificationTexts;
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { BasePayload } from 'payload';
|
|
||||||
import type { NotificationTexts } from './types.js';
|
|
||||||
type GetNotificationTextsArgs = {
|
|
||||||
/** Active locale — selects the language variant of each text. */
|
|
||||||
locale?: string;
|
|
||||||
payload: BasePayload;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Resolves notification texts from the Notifications global, falling back to
|
|
||||||
* English defaults per field. Mirrors getConsentTexts: one read, per-field
|
|
||||||
* fallback, locale-aware. The frontend maps a submitForm result code to the
|
|
||||||
* matching text and styles it however it likes (toast, inline, banner).
|
|
||||||
*/
|
|
||||||
export declare function getNotificationTexts({ locale, payload, }: GetNotificationTextsArgs): Promise<NotificationTexts>;
|
|
||||||
export {};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/notifications/getNotificationTexts.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { NotificationsData, NotificationTexts } from './types.js'\n\nimport { getGlobal } from '../payload/index.js'\nimport { NOTIFICATION_FALLBACK } from './defaults.js'\n\ntype GetNotificationTextsArgs = {\n /** Active locale — selects the language variant of each text. */\n locale?: string\n payload: BasePayload\n}\n\n/**\n * Resolves notification texts from the Notifications global, falling back to\n * English defaults per field. Mirrors getConsentTexts: one read, per-field\n * fallback, locale-aware. The frontend maps a submitForm result code to the\n * matching text and styles it however it likes (toast, inline, banner).\n */\nexport async function getNotificationTexts({\n locale,\n payload,\n}: GetNotificationTextsArgs): Promise<NotificationTexts> {\n const g = await getGlobal<NotificationsData>(payload, 'notifications', { locale })\n\n const f = g.form ?? {}\n const fb = NOTIFICATION_FALLBACK.form\n\n return {\n form: {\n consent: f.consent || fb.consent,\n error: f.error || fb.error,\n notFound: f.notFound || fb.notFound,\n rateLimited: f.rateLimited || fb.rateLimited,\n success: f.success || fb.success,\n turnstile: f.turnstile || fb.turnstile,\n validation: f.validation || fb.validation,\n },\n }\n}\n"],"names":["getGlobal","NOTIFICATION_FALLBACK","getNotificationTexts","locale","payload","g","f","form","fb","consent","error","notFound","rateLimited","success","turnstile","validation"],"mappings":"AAIA,SAASA,SAAS,QAAQ,sBAAqB;AAC/C,SAASC,qBAAqB,QAAQ,gBAAe;AAQrD;;;;;CAKC,GACD,OAAO,eAAeC,qBAAqB,EACzCC,MAAM,EACNC,OAAO,EACkB;IACzB,MAAMC,IAAI,MAAML,UAA6BI,SAAS,iBAAiB;QAAED;IAAO;IAEhF,MAAMG,IAAID,EAAEE,IAAI,IAAI,CAAC;IACrB,MAAMC,KAAKP,sBAAsBM,IAAI;IAErC,OAAO;QACLA,MAAM;YACJE,SAASH,EAAEG,OAAO,IAAID,GAAGC,OAAO;YAChCC,OAAOJ,EAAEI,KAAK,IAAIF,GAAGE,KAAK;YAC1BC,UAAUL,EAAEK,QAAQ,IAAIH,GAAGG,QAAQ;YACnCC,aAAaN,EAAEM,WAAW,IAAIJ,GAAGI,WAAW;YAC5CC,SAASP,EAAEO,OAAO,IAAIL,GAAGK,OAAO;YAChCC,WAAWR,EAAEQ,SAAS,IAAIN,GAAGM,SAAS;YACtCC,YAAYT,EAAES,UAAU,IAAIP,GAAGO,UAAU;QAC3C;IACF;AACF"}
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
export { NOTIFICATION_FALLBACK } from './defaults.js';
|
|
||||||
export { getNotificationTexts } from './getNotificationTexts.js';
|
|
||||||
export { resolveFormMessage } from './resolveFormMessage.js';
|
|
||||||
export type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js';
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
export { NOTIFICATION_FALLBACK } from './defaults.js';
|
|
||||||
export { getNotificationTexts } from './getNotificationTexts.js';
|
|
||||||
export { resolveFormMessage } from './resolveFormMessage.js';
|
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { SubmitFormResult } from '../forms/index.js';
|
|
||||||
import type { FormNotificationTexts } from './types.js';
|
|
||||||
/**
|
|
||||||
* Maps a submitForm result to the user-facing message, interpolating {field}
|
|
||||||
* for validation errors. This is the bridge the frontend uses: it gets a result
|
|
||||||
* code from submitForm and the resolved texts from getNotificationTexts, and
|
|
||||||
* this turns them into one string to display. Keeping the mapping here means the
|
|
||||||
* frontend never hard-codes messages or knows about result codes.
|
|
||||||
*
|
|
||||||
* Never surfaces raw backend/exception detail — 'error' maps to a friendly
|
|
||||||
* generic message, not the thrown error's text (which could leak internals).
|
|
||||||
*/
|
|
||||||
export declare function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Maps a submitForm result to the user-facing message, interpolating {field}
|
|
||||||
* for validation errors. This is the bridge the frontend uses: it gets a result
|
|
||||||
* code from submitForm and the resolved texts from getNotificationTexts, and
|
|
||||||
* this turns them into one string to display. Keeping the mapping here means the
|
|
||||||
* frontend never hard-codes messages or knows about result codes.
|
|
||||||
*
|
|
||||||
* Never surfaces raw backend/exception detail — 'error' maps to a friendly
|
|
||||||
* generic message, not the thrown error's text (which could leak internals).
|
|
||||||
*/ export function resolveFormMessage(result, texts) {
|
|
||||||
if (result.success) {
|
|
||||||
return texts.success;
|
|
||||||
}
|
|
||||||
switch(result.reason){
|
|
||||||
case 'consent':
|
|
||||||
return texts.consent;
|
|
||||||
case 'not_found':
|
|
||||||
return texts.notFound;
|
|
||||||
case 'rate_limited':
|
|
||||||
return texts.rateLimited;
|
|
||||||
case 'turnstile':
|
|
||||||
return texts.turnstile;
|
|
||||||
case 'validation':
|
|
||||||
{
|
|
||||||
// Interpolate {field} with the offending field name when present.
|
|
||||||
const field = 'field' in result && result.field ? result.field : '';
|
|
||||||
return texts.validation.replace('{field}', field);
|
|
||||||
}
|
|
||||||
case 'error':
|
|
||||||
default:
|
|
||||||
return texts.error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//# sourceMappingURL=resolveFormMessage.js.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/notifications/resolveFormMessage.ts"],"sourcesContent":["import type { SubmitFormResult } from '../forms/index.js'\nimport type { FormNotificationTexts } from './types.js'\n\n/**\n * Maps a submitForm result to the user-facing message, interpolating {field}\n * for validation errors. This is the bridge the frontend uses: it gets a result\n * code from submitForm and the resolved texts from getNotificationTexts, and\n * this turns them into one string to display. Keeping the mapping here means the\n * frontend never hard-codes messages or knows about result codes.\n *\n * Never surfaces raw backend/exception detail — 'error' maps to a friendly\n * generic message, not the thrown error's text (which could leak internals).\n */\nexport function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string {\n if (result.success) {return texts.success}\n\n switch (result.reason) {\n case 'consent':\n return texts.consent\n case 'not_found':\n return texts.notFound\n case 'rate_limited':\n return texts.rateLimited\n case 'turnstile':\n return texts.turnstile\n case 'validation': {\n // Interpolate {field} with the offending field name when present.\n const field = 'field' in result && result.field ? result.field : ''\n return texts.validation.replace('{field}', field)\n }\n case 'error':\n default:\n return texts.error\n }\n}\n"],"names":["resolveFormMessage","result","texts","success","reason","consent","notFound","rateLimited","turnstile","field","validation","replace","error"],"mappings":"AAGA;;;;;;;;;CASC,GACD,OAAO,SAASA,mBAAmBC,MAAwB,EAAEC,KAA4B;IACvF,IAAID,OAAOE,OAAO,EAAE;QAAC,OAAOD,MAAMC,OAAO;IAAA;IAEzC,OAAQF,OAAOG,MAAM;QACnB,KAAK;YACH,OAAOF,MAAMG,OAAO;QACtB,KAAK;YACH,OAAOH,MAAMI,QAAQ;QACvB,KAAK;YACH,OAAOJ,MAAMK,WAAW;QAC1B,KAAK;YACH,OAAOL,MAAMM,SAAS;QACxB,KAAK;YAAc;gBACjB,kEAAkE;gBAClE,MAAMC,QAAQ,WAAWR,UAAUA,OAAOQ,KAAK,GAAGR,OAAOQ,KAAK,GAAG;gBACjE,OAAOP,MAAMQ,UAAU,CAACC,OAAO,CAAC,WAAWF;YAC7C;QACA,KAAK;QACL;YACE,OAAOP,MAAMU,KAAK;IACtB;AACF"}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Resolved notification texts, ready for the frontend. Grouped per context;
|
|
||||||
* `form` maps submitForm result codes to user-facing messages.
|
|
||||||
*/
|
|
||||||
export type FormNotificationTexts = {
|
|
||||||
success: string;
|
|
||||||
error: string;
|
|
||||||
rateLimited: string;
|
|
||||||
turnstile: string;
|
|
||||||
/** May contain the {field} placeholder — resolve with resolveValidationText. */
|
|
||||||
validation: string;
|
|
||||||
/** Shown when a required GDPR consent checkbox was left unchecked. */
|
|
||||||
consent: string;
|
|
||||||
notFound: string;
|
|
||||||
};
|
|
||||||
export type NotificationTexts = {
|
|
||||||
form: FormNotificationTexts;
|
|
||||||
};
|
|
||||||
/** Raw shape read from the Notifications global (all fields optional). */
|
|
||||||
export type NotificationsData = {
|
|
||||||
form?: Partial<FormNotificationTexts>;
|
|
||||||
};
|
|
||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
@@ -1 +0,0 @@
|
|||||||
{"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
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
@@ -1,2 +0,0 @@
|
|||||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js';
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"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
+1
-12
@@ -1,10 +1,8 @@
|
|||||||
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';
|
||||||
@@ -85,16 +83,7 @@ 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
+1
-3
@@ -109,12 +109,10 @@ export default buildConfig({
|
|||||||
| 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 | Wysyłka: SMTP z panelu lub Microsoft Graph (M365) | [email.md](./email.md) |
|
| email | SMTP z panelu: adapter Payloada + sendEmail | [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)
|
||||||
|
|||||||
+3
-79
@@ -1,10 +1,8 @@
|
|||||||
# email
|
# email
|
||||||
|
|
||||||
Wysyłka maili z dwoma transportami do wyboru: **SMTP z panelu**
|
Wysyłka maili przez SMTP z SiteIntegrations, w runtime (bez Payload email
|
||||||
(`panelSmtpAdapter`, uniwersalny) albo **Microsoft Graph** (`graphAdapter`,
|
adaptera). Edytor zmienia SMTP w panelu — następny mail idzie z nowymi
|
||||||
przez Exchange/M365). Oba implementują ten sam interfejs `PayloadEmailAdapter`,
|
ustawieniami, bez restartu.
|
||||||
więc `payload.sendEmail` i maile z formularzy działają niezależnie od wyboru.
|
|
||||||
Klient/projekt wybiera transport w configu.
|
|
||||||
|
|
||||||
## Zależność
|
## Zależność
|
||||||
|
|
||||||
@@ -76,77 +74,3 @@ 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=forms@intecion.pl # 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.
|
|
||||||
@@ -146,7 +146,6 @@ 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' }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -164,57 +163,3 @@ 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.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 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,13 +2,6 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
+13
-16
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.0.13",
|
"version": "1.0.4",
|
||||||
"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": {
|
||||||
@@ -65,25 +65,22 @@
|
|||||||
"slugify": "^1.6.6"
|
"slugify": "^1.6.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@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/ui": "^3.88.0",
|
|
||||||
"next": ">=15",
|
"next": ">=15",
|
||||||
"payload": "^3.88.0",
|
"payload": "^3.84.1",
|
||||||
"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.88.0",
|
"@payloadcms/db-postgres": "3.84.1",
|
||||||
"@payloadcms/db-sqlite": "3.88.0",
|
"@payloadcms/db-sqlite": "3.84.1",
|
||||||
"@payloadcms/eslint-config": "3.28.0",
|
"@payloadcms/eslint-config": "3.28.0",
|
||||||
"@payloadcms/next": "3.88.0",
|
"@payloadcms/next": "3.84.1",
|
||||||
"@payloadcms/plugin-form-builder": "3.88.0",
|
"@payloadcms/plugin-form-builder": "3.84.1",
|
||||||
"@payloadcms/plugin-seo": "3.88.0",
|
"@payloadcms/plugin-seo": "3.84.1",
|
||||||
"@payloadcms/richtext-lexical": "3.88.0",
|
"@payloadcms/richtext-lexical": "3.84.1",
|
||||||
"@payloadcms/ui": "3.88.0",
|
"@payloadcms/ui": "3.84.1",
|
||||||
"@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",
|
||||||
@@ -99,7 +96,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.88.0",
|
"payload": "3.84.1",
|
||||||
"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
+416
-122
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
'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
|
||||||
@@ -19,6 +18,3 @@ 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,36 +1,24 @@
|
|||||||
'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 = (props: any) => {
|
export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
||||||
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}
|
const { setValue, value } = useField<string>({ path })
|
||||||
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 && <label className="field-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={handleChange}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
type={revealed ? 'text' : 'password'}
|
type={revealed ? 'text' : 'password'}
|
||||||
value={currentValue ?? ''}
|
value={value ?? ''}
|
||||||
/>
|
/>
|
||||||
<button onClick={() => setRevealed((r) => !r)} type="button">
|
<button onClick={() => setRevealed((r) => !r)} type="button">
|
||||||
{revealed ? 'Hide' : 'Reveal'}
|
{revealed ? 'Hide' : 'Reveal'}
|
||||||
@@ -39,5 +27,4 @@ export const MaskedField: TextFieldClientComponent = (props: any) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MaskedField
|
export default MaskedField
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
'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,30 +8,8 @@ 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',
|
||||||
@@ -78,13 +56,4 @@ 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,10 +44,6 @@ 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.
|
||||||
@@ -85,8 +81,6 @@ 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'
|
||||||
@@ -99,18 +93,5 @@ 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'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type { SecurityHeader, BuildSecurityHeadersArgs } from './modules/security/index.js' -c buildSecurityHeaders src/index.ts
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The panel's from-address is used as Reply-To, NOT as the message From.
|
|
||||||
//
|
|
||||||
// Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a
|
|
||||||
// `from` that differs from that mailbox, Exchange demands "Send As"
|
|
||||||
// permission on it and rejects with ErrorSendAsDenied otherwise. So we
|
|
||||||
// never override `from` — Graph stamps the mail as GRAPH_SENDER (the
|
|
||||||
// mailbox we legitimately own) — and route replies to the panel address
|
|
||||||
// via Reply-To. Recipients see the mail from forms@… but replying reaches
|
|
||||||
// the real destination. No Send-As needed.
|
|
||||||
const panel = await getSiteIntegrations<GraphIntegrations>(payload)
|
|
||||||
const replyToAddress = panel.smtpFromAddress || undefined
|
|
||||||
const replyToName = 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,
|
|
||||||
...(replyToName ? { name: replyToName } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
|
|
||||||
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) } : {}),
|
|
||||||
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As.
|
|
||||||
...(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,7 +1,3 @@
|
|||||||
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'
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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',
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js'
|
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'
|
|
||||||
+1
-12
@@ -3,12 +3,10 @@ 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'
|
||||||
@@ -96,21 +94,12 @@ 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) {
|
if (incomingOnInit) {await incomingOnInit(payload)}
|
||||||
await incomingOnInit(payload)
|
|
||||||
}
|
|
||||||
payload.logger.info('[ipal] Plugin initialized.')
|
payload.logger.info('[ipal] Plugin initialized.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user