Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce17654f8 | ||
|
|
419207ac12 | ||
|
|
e30ac71044 | ||
|
|
781e348ded | ||
|
|
e16a18e488 | ||
|
|
7cd3cbaae5 | ||
|
|
60f07fddc9 | ||
|
|
068415849f | ||
|
|
e39e2a361a | ||
|
|
9d3e749c38 | ||
|
|
b7c6cb4d81 | ||
|
|
c41b75e364 | ||
|
|
41630bb8c5 | ||
|
|
b9430e7ab6 | ||
|
|
f1f808d079 | ||
|
|
a8a632e1b0 | ||
|
|
a695ec7319 | ||
|
|
c6730335ef | ||
|
|
247b849759 | ||
|
|
0dee178ded | ||
|
|
9359f26788 | ||
|
|
5630765215 | ||
|
|
ed4a78b109 | ||
|
|
9acb22e2f9 | ||
|
|
6a8361d710 | ||
|
|
502ffee31f | ||
|
|
5a322230ae | ||
|
|
f919c288b2 | ||
|
|
21b948a4f8 | ||
|
|
d04271f942 | ||
|
|
4f08e9ea4d | ||
|
|
67347f1e34 | ||
|
|
75f2b6400d | ||
|
|
26aec1c5af | ||
|
|
0fe7ca0575 | ||
|
|
d745a764fe | ||
|
|
6c273118a2 | ||
|
|
a010a7368a | ||
|
|
08bd00f01f | ||
|
|
cef7f46718 | ||
|
|
ac48f1e9d1 | ||
|
|
3d8bc9019f | ||
|
|
cd65c2799f | ||
|
|
282be290cd | ||
|
|
080f41c7d8 | ||
|
|
37e417e057 | ||
|
|
05b3dc8cb1 | ||
|
|
81fa409513 | ||
|
|
90068c7952 | ||
|
|
2215766940 |
Vendored
+4
@@ -1,3 +1,5 @@
|
||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
||||
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
|
||||
export { Analytics } from '../modules/analytics/client.js';
|
||||
/**
|
||||
* Entry point: ipal-kit/client
|
||||
@@ -10,3 +12,5 @@ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentCont
|
||||
export type { CookieBannerClassNames } from '../modules/consent/client.js';
|
||||
export { Turnstile } from '../modules/turnstile/client.js';
|
||||
export type { TurnstileProps } from '../modules/turnstile/client.js';
|
||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
||||
export type { FormNotificationTexts } from '../modules/notifications/types.js';
|
||||
|
||||
Vendored
+3
@@ -1,4 +1,6 @@
|
||||
'use client';
|
||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
||||
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
|
||||
export { Analytics } from '../modules/analytics/client.js';
|
||||
/**
|
||||
* Entry point: ipal-kit/client
|
||||
@@ -8,5 +10,6 @@ export { Analytics } from '../modules/analytics/client.js';
|
||||
* client-only code.
|
||||
*/ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js';
|
||||
export { Turnstile } from '../modules/turnstile/client.js';
|
||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
||||
|
||||
//# sourceMappingURL=client.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"}
|
||||
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile","resolveFormMessage"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC;AAG1D,SAASC,kBAAkB,QAAQ,iDAAgD"}
|
||||
Vendored
+1
@@ -7,3 +7,4 @@
|
||||
*/
|
||||
export { RenderBlocks } from '../modules/blocks/index.js';
|
||||
export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js';
|
||||
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
|
||||
|
||||
Vendored
+1
@@ -5,5 +5,6 @@
|
||||
* lives here rather than in the main package entry to keep React out of the
|
||||
* server-config bundle.
|
||||
*/ export { RenderBlocks } from '../modules/blocks/index.js';
|
||||
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
|
||||
|
||||
//# sourceMappingURL=rsc.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\n"],"names":["RenderBlocks"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B"}
|
||||
{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\nexport { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'\n"],"names":["RenderBlocks","MediaPreconnect"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B;AAOzD,SAASC,eAAe,QAAQ,wCAAuC"}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { Field } from 'payload';
|
||||
/**
|
||||
* Fields for the Notifications global — localized user-facing texts for action
|
||||
* results (form submission outcomes, and future contexts). Every text is
|
||||
* localized: true so each language has its own value. Empty fields fall back to
|
||||
* built-in English defaults (see modules/notifications/defaults).
|
||||
*
|
||||
* Grouped per context. `form` holds the outcomes of submitForm; more groups
|
||||
* (e.g. `newsletter`, `system`) can be added the same way without touching
|
||||
* consumers — getNotificationTexts resolves whatever exists, falling back
|
||||
* per field.
|
||||
*/
|
||||
export declare const notificationsFields: Field[];
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Fields for the Notifications global — localized user-facing texts for action
|
||||
* results (form submission outcomes, and future contexts). Every text is
|
||||
* localized: true so each language has its own value. Empty fields fall back to
|
||||
* built-in English defaults (see modules/notifications/defaults).
|
||||
*
|
||||
* Grouped per context. `form` holds the outcomes of submitForm; more groups
|
||||
* (e.g. `newsletter`, `system`) can be added the same way without touching
|
||||
* consumers — getNotificationTexts resolves whatever exists, falling back
|
||||
* per field.
|
||||
*/ export const notificationsFields = [
|
||||
{
|
||||
name: 'form',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Messages shown after a form is submitted. Leave a field empty to use the built-in default.'
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'success',
|
||||
type: 'text',
|
||||
admin: {
|
||||
placeholder: 'Thank you — your message has been sent.'
|
||||
},
|
||||
localized: true
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
type: 'text',
|
||||
admin: {
|
||||
placeholder: 'Something went wrong. Please try again later.'
|
||||
},
|
||||
localized: true
|
||||
},
|
||||
{
|
||||
name: 'rateLimited',
|
||||
type: 'text',
|
||||
admin: {
|
||||
placeholder: 'Too many attempts. Please wait a moment and try again.'
|
||||
},
|
||||
localized: true
|
||||
},
|
||||
{
|
||||
name: 'turnstile',
|
||||
type: 'text',
|
||||
admin: {
|
||||
placeholder: 'Captcha verification failed. Please try again.'
|
||||
},
|
||||
localized: true
|
||||
},
|
||||
{
|
||||
name: 'validation',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Shown on a validation error. Use {field} to insert the offending field name.',
|
||||
placeholder: 'Please check the {field} field and try again.'
|
||||
},
|
||||
localized: true
|
||||
},
|
||||
{
|
||||
name: 'consent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Shown when the GDPR consent checkbox is left unchecked.',
|
||||
placeholder: 'Please accept the privacy policy to continue.'
|
||||
},
|
||||
localized: true
|
||||
},
|
||||
{
|
||||
name: 'notFound',
|
||||
type: 'text',
|
||||
admin: {
|
||||
placeholder: 'This form is no longer available.'
|
||||
},
|
||||
localized: true
|
||||
}
|
||||
],
|
||||
label: 'Form messages'
|
||||
}
|
||||
];
|
||||
|
||||
//# sourceMappingURL=fields.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/globals/Notifications/fields.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Fields for the Notifications global — localized user-facing texts for action\n * results (form submission outcomes, and future contexts). Every text is\n * localized: true so each language has its own value. Empty fields fall back to\n * built-in English defaults (see modules/notifications/defaults).\n *\n * Grouped per context. `form` holds the outcomes of submitForm; more groups\n * (e.g. `newsletter`, `system`) can be added the same way without touching\n * consumers — getNotificationTexts resolves whatever exists, falling back\n * per field.\n */\nexport const notificationsFields: Field[] = [\n {\n name: 'form',\n type: 'group',\n admin: {\n description:\n 'Messages shown after a form is submitted. Leave a field empty to use the built-in default.',\n },\n fields: [\n {\n name: 'success',\n type: 'text',\n admin: { placeholder: 'Thank you — your message has been sent.' },\n localized: true,\n },\n {\n name: 'error',\n type: 'text',\n admin: { placeholder: 'Something went wrong. Please try again later.' },\n localized: true,\n },\n {\n name: 'rateLimited',\n type: 'text',\n admin: { placeholder: 'Too many attempts. Please wait a moment and try again.' },\n localized: true,\n },\n {\n name: 'turnstile',\n type: 'text',\n admin: { placeholder: 'Captcha verification failed. Please try again.' },\n localized: true,\n },\n {\n name: 'validation',\n type: 'text',\n admin: {\n description:\n 'Shown on a validation error. Use {field} to insert the offending field name.',\n placeholder: 'Please check the {field} field and try again.',\n },\n localized: true,\n },\n {\n name: 'consent',\n type: 'text',\n admin: {\n description: 'Shown when the GDPR consent checkbox is left unchecked.',\n placeholder: 'Please accept the privacy policy to continue.',\n },\n localized: true,\n },\n {\n name: 'notFound',\n type: 'text',\n admin: { placeholder: 'This form is no longer available.' },\n localized: true,\n },\n ],\n label: 'Form messages',\n },\n]\n"],"names":["notificationsFields","name","type","admin","description","fields","placeholder","localized","label"],"mappings":"AAEA;;;;;;;;;;CAUC,GACD,OAAO,MAAMA,sBAA+B;IAC1C;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAC,QAAQ;YACN;gBACEJ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAA0C;gBAChEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAgD;gBACtEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAyD;gBAC/EC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAiD;gBACvEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBACLC,aACE;oBACFE,aAAa;gBACf;gBACAC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBACLC,aAAa;oBACbE,aAAa;gBACf;gBACAC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAoC;gBAC1DC,WAAW;YACb;SACD;QACDC,OAAO;IACT;CACD,CAAA"}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { GlobalConfig } from 'payload';
|
||||
/**
|
||||
* Builds the Notifications global — localized action-result texts. Readable by
|
||||
* any authenticated panel user; server-side helpers read it with overrideAccess
|
||||
* so the frontend can resolve texts without a session.
|
||||
*/
|
||||
export declare function buildNotifications(): GlobalConfig;
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
import { notificationsFields } from './fields.js';
|
||||
/**
|
||||
* Builds the Notifications global — localized action-result texts. Readable by
|
||||
* any authenticated panel user; server-side helpers read it with overrideAccess
|
||||
* so the frontend can resolve texts without a session.
|
||||
*/ export function buildNotifications() {
|
||||
return {
|
||||
slug: 'notifications',
|
||||
label: 'Notifications',
|
||||
access: {
|
||||
read: ()=>true
|
||||
},
|
||||
fields: notificationsFields
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/globals/Notifications/index.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\nimport { notificationsFields } from './fields.js'\n\n/**\n * Builds the Notifications global — localized action-result texts. Readable by\n * any authenticated panel user; server-side helpers read it with overrideAccess\n * so the frontend can resolve texts without a session.\n */\nexport function buildNotifications(): GlobalConfig {\n return {\n slug: 'notifications',\n label: 'Notifications',\n access: {\n read: () => true, // texts are public-facing (shown to end users)\n },\n fields: notificationsFields,\n }\n}\n"],"names":["notificationsFields","buildNotifications","slug","label","access","read","fields"],"mappings":"AACA,SAASA,mBAAmB,QAAQ,cAAa;AAEjD;;;;CAIC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,MAAM;QACNC,OAAO;QACPC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,QAAQN;IACV;AACF"}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { TextFieldClientComponent } from 'payload';
|
||||
export declare const MaskedField: TextFieldClientComponent;
|
||||
export default MaskedField;
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
export const MaskedField = (props)=>{
|
||||
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {};
|
||||
const [internalValue, setInternalValue] = useState(propValue ?? '');
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const label = typeof field?.label === 'string' ? field.label : field?.name ?? path;
|
||||
const handleChange = (e)=>{
|
||||
const newVal = e.target.value;
|
||||
setInternalValue(newVal);
|
||||
if (typeof propSetValue === 'function') {
|
||||
propSetValue(newVal);
|
||||
}
|
||||
if (typeof propOnChange === 'function') {
|
||||
propOnChange(e);
|
||||
}
|
||||
};
|
||||
const currentValue = propValue !== undefined ? propValue : internalValue;
|
||||
return /*#__PURE__*/ _jsxs("div", {
|
||||
className: "field-type text",
|
||||
children: [
|
||||
label && /*#__PURE__*/ _jsx("label", {
|
||||
className: "field-label",
|
||||
children: label
|
||||
}),
|
||||
/*#__PURE__*/ _jsxs("div", {
|
||||
style: {
|
||||
display: 'flex',
|
||||
gap: '.5rem'
|
||||
},
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("input", {
|
||||
autoComplete: "off",
|
||||
onChange: handleChange,
|
||||
style: {
|
||||
flex: 1
|
||||
},
|
||||
type: revealed ? 'text' : 'password',
|
||||
value: currentValue ?? ''
|
||||
}),
|
||||
/*#__PURE__*/ _jsx("button", {
|
||||
onClick: ()=>setRevealed((r)=>!r),
|
||||
type: "button",
|
||||
children: revealed ? 'Hide' : 'Reveal'
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
};
|
||||
export default MaskedField;
|
||||
|
||||
//# sourceMappingURL=MaskedField.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = (props: any) => {\n const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}\n const [internalValue, setInternalValue] = useState(propValue ?? '')\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const newVal = e.target.value\n setInternalValue(newVal)\n if (typeof propSetValue === 'function') {\n propSetValue(newVal)\n }\n if (typeof propOnChange === 'function') {\n propOnChange(e)\n }\n }\n\n const currentValue = propValue !== undefined ? propValue : internalValue\n\n return (\n <div className=\"field-type text\">\n {label && <label className=\"field-label\">{label}</label>}\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={handleChange}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={currentValue ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\n\nexport default MaskedField\n"],"names":["useState","MaskedField","props","field","path","value","propValue","setValue","propSetValue","onChange","propOnChange","internalValue","setInternalValue","revealed","setRevealed","label","name","handleChange","e","newVal","target","currentValue","undefined","div","className","style","display","gap","input","autoComplete","flex","type","button","onClick","r"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAACC;IACpD,MAAM,EAAEC,KAAK,EAAEC,IAAI,EAAEC,OAAOC,SAAS,EAAEC,UAAUC,YAAY,EAAEC,UAAUC,YAAY,EAAE,GAAGR,SAAS,CAAC;IACpG,MAAM,CAACS,eAAeC,iBAAiB,GAAGZ,SAASM,aAAa;IAChE,MAAM,CAACO,UAAUC,YAAY,GAAGd,SAAS;IACzC,MAAMe,QAAQ,OAAOZ,OAAOY,UAAU,WAAWZ,MAAMY,KAAK,GAAIZ,OAAOa,QAAQZ;IAE/E,MAAMa,eAAe,CAACC;QACpB,MAAMC,SAASD,EAAEE,MAAM,CAACf,KAAK;QAC7BO,iBAAiBO;QACjB,IAAI,OAAOX,iBAAiB,YAAY;YACtCA,aAAaW;QACf;QACA,IAAI,OAAOT,iBAAiB,YAAY;YACtCA,aAAaQ;QACf;IACF;IAEA,MAAMG,eAAef,cAAcgB,YAAYhB,YAAYK;IAE3D,qBACE,MAACY;QAAIC,WAAU;;YACZT,uBAAS,KAACA;gBAAMS,WAAU;0BAAeT;;0BAC1C,MAACQ;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbpB,UAAUQ;wBACVQ,OAAO;4BAAEK,MAAM;wBAAE;wBACjBC,MAAMlB,WAAW,SAAS;wBAC1BR,OAAOgB,gBAAgB;;kCAEzB,KAACW;wBAAOC,SAAS,IAAMnB,YAAY,CAACoB,IAAM,CAACA;wBAAIH,MAAK;kCACjDlB,WAAW,SAAS;;;;;;AAK/B,EAAC;AAED,eAAeZ,YAAW"}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Admin UI: a small "send test email" tool for the SiteIntegrations email tab.
|
||||
* Enter an address, click Send, and it POSTs to /api/ipal/test-email, which
|
||||
* sends through the currently-selected transport (SMTP or Graph). Shows the
|
||||
* result inline so you can confirm delivery — or read the exact error — without
|
||||
* leaving the panel.
|
||||
*
|
||||
* Assigned via a `ui` field's admin.components.Field.
|
||||
*/
|
||||
export declare const TestEmailButton: () => import("react/jsx-runtime").JSX.Element;
|
||||
export default TestEmailButton;
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
/**
|
||||
* Admin UI: a small "send test email" tool for the SiteIntegrations email tab.
|
||||
* Enter an address, click Send, and it POSTs to /api/ipal/test-email, which
|
||||
* sends through the currently-selected transport (SMTP or Graph). Shows the
|
||||
* result inline so you can confirm delivery — or read the exact error — without
|
||||
* leaving the panel.
|
||||
*
|
||||
* Assigned via a `ui` field's admin.components.Field.
|
||||
*/ export const TestEmailButton = ()=>{
|
||||
const [to, setTo] = useState('');
|
||||
const [status, setStatus] = useState({
|
||||
kind: 'idle'
|
||||
});
|
||||
const send = async ()=>{
|
||||
if (!to.trim()) {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
msg: 'Enter a recipient address.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStatus({
|
||||
kind: 'sending'
|
||||
});
|
||||
try {
|
||||
const res = await fetch('/api/ipal/test-email', {
|
||||
body: JSON.stringify({
|
||||
to: to.trim()
|
||||
}),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setStatus({
|
||||
kind: 'ok',
|
||||
msg: data.message ?? 'Test email sent.'
|
||||
});
|
||||
} else {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
msg: data.error ?? 'Send failed.'
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
msg: 'Request failed. Is the server running?'
|
||||
});
|
||||
}
|
||||
};
|
||||
return /*#__PURE__*/ _jsxs("div", {
|
||||
className: "field-type",
|
||||
style: {
|
||||
marginTop: '1rem'
|
||||
},
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("label", {
|
||||
className: "field-label",
|
||||
children: "Send a test email"
|
||||
}),
|
||||
/*#__PURE__*/ _jsx("p", {
|
||||
style: {
|
||||
fontSize: '.85rem',
|
||||
marginTop: 0,
|
||||
opacity: 0.7
|
||||
},
|
||||
children: "Sends through the transport selected above. Save your changes first."
|
||||
}),
|
||||
/*#__PURE__*/ _jsxs("div", {
|
||||
style: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '.5rem'
|
||||
},
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("input", {
|
||||
onChange: (e)=>setTo(e.target.value),
|
||||
placeholder: "[email protected]",
|
||||
style: {
|
||||
flex: 1,
|
||||
minWidth: '220px'
|
||||
},
|
||||
type: "email",
|
||||
value: to
|
||||
}),
|
||||
/*#__PURE__*/ _jsx("button", {
|
||||
className: "btn btn--style-secondary",
|
||||
disabled: status.kind === 'sending',
|
||||
onClick: send,
|
||||
style: {
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
type: "button",
|
||||
children: status.kind === 'sending' ? 'Sending…' : 'Send test'
|
||||
})
|
||||
]
|
||||
}),
|
||||
status.kind === 'ok' && /*#__PURE__*/ _jsxs("p", {
|
||||
style: {
|
||||
color: 'var(--theme-success-500, green)',
|
||||
marginTop: '.5rem'
|
||||
},
|
||||
children: [
|
||||
"✓ ",
|
||||
status.msg
|
||||
]
|
||||
}),
|
||||
status.kind === 'error' && /*#__PURE__*/ _jsxs("p", {
|
||||
style: {
|
||||
color: 'var(--theme-error-500, crimson)',
|
||||
marginTop: '.5rem'
|
||||
},
|
||||
children: [
|
||||
"✗ ",
|
||||
status.msg
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
};
|
||||
export default TestEmailButton;
|
||||
|
||||
//# sourceMappingURL=TestEmailButton.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/TestEmailButton.tsx"],"sourcesContent":["'use client'\n\nimport { useState } from 'react'\n\n/**\n * Admin UI: a small \"send test email\" tool for the SiteIntegrations email tab.\n * Enter an address, click Send, and it POSTs to /api/ipal/test-email, which\n * sends through the currently-selected transport (SMTP or Graph). Shows the\n * result inline so you can confirm delivery — or read the exact error — without\n * leaving the panel.\n *\n * Assigned via a `ui` field's admin.components.Field.\n */\nexport const TestEmailButton = () => {\n const [to, setTo] = useState('')\n const [status, setStatus] = useState<\n | { kind: 'error'; msg: string }\n | { kind: 'idle' }\n | { kind: 'ok'; msg: string }\n | { kind: 'sending' }\n >({ kind: 'idle' })\n\n const send = async () => {\n if (!to.trim()) {\n setStatus({ kind: 'error', msg: 'Enter a recipient address.' })\n return\n }\n setStatus({ kind: 'sending' })\n try {\n const res = await fetch('/api/ipal/test-email', {\n body: JSON.stringify({ to: to.trim() }),\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n method: 'POST',\n })\n const data = await res.json()\n if (data.ok) {\n setStatus({ kind: 'ok', msg: data.message ?? 'Test email sent.' })\n } else {\n setStatus({ kind: 'error', msg: data.error ?? 'Send failed.' })\n }\n } catch {\n setStatus({ kind: 'error', msg: 'Request failed. Is the server running?' })\n }\n }\n\n return (\n <div className=\"field-type\" style={{ marginTop: '1rem' }}>\n <label className=\"field-label\">Send a test email</label>\n <p style={{ fontSize: '.85rem', marginTop: 0, opacity: 0.7 }}>\n Sends through the transport selected above. Save your changes first.\n </p>\n <div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>\n <input\n onChange={(e) => setTo(e.target.value)}\n placeholder=\"[email protected]\"\n style={{ flex: 1, minWidth: '220px' }}\n type=\"email\"\n value={to}\n />\n <button\n className=\"btn btn--style-secondary\"\n disabled={status.kind === 'sending'}\n onClick={send}\n style={{ whiteSpace: 'nowrap' }}\n type=\"button\"\n >\n {status.kind === 'sending' ? 'Sending…' : 'Send test'}\n </button>\n </div>\n {status.kind === 'ok' && (\n <p style={{ color: 'var(--theme-success-500, green)', marginTop: '.5rem' }}>\n ✓ {status.msg}\n </p>\n )}\n {status.kind === 'error' && (\n <p style={{ color: 'var(--theme-error-500, crimson)', marginTop: '.5rem' }}>\n ✗ {status.msg}\n </p>\n )}\n </div>\n )\n}\n\nexport default TestEmailButton\n"],"names":["useState","TestEmailButton","to","setTo","status","setStatus","kind","send","trim","msg","res","fetch","body","JSON","stringify","credentials","headers","method","data","json","ok","message","error","div","className","style","marginTop","label","p","fontSize","opacity","alignItems","display","flexWrap","gap","input","onChange","e","target","value","placeholder","flex","minWidth","type","button","disabled","onClick","whiteSpace","color"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC;;;;;;;;CAQC,GACD,OAAO,MAAMC,kBAAkB;IAC7B,MAAM,CAACC,IAAIC,MAAM,GAAGH,SAAS;IAC7B,MAAM,CAACI,QAAQC,UAAU,GAAGL,SAK1B;QAAEM,MAAM;IAAO;IAEjB,MAAMC,OAAO;QACX,IAAI,CAACL,GAAGM,IAAI,IAAI;YACdH,UAAU;gBAAEC,MAAM;gBAASG,KAAK;YAA6B;YAC7D;QACF;QACAJ,UAAU;YAAEC,MAAM;QAAU;QAC5B,IAAI;YACF,MAAMI,MAAM,MAAMC,MAAM,wBAAwB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBAAEZ,IAAIA,GAAGM,IAAI;gBAAG;gBACrCO,aAAa;gBACbC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,QAAQ;YACV;YACA,MAAMC,OAAO,MAAMR,IAAIS,IAAI;YAC3B,IAAID,KAAKE,EAAE,EAAE;gBACXf,UAAU;oBAAEC,MAAM;oBAAMG,KAAKS,KAAKG,OAAO,IAAI;gBAAmB;YAClE,OAAO;gBACLhB,UAAU;oBAAEC,MAAM;oBAASG,KAAKS,KAAKI,KAAK,IAAI;gBAAe;YAC/D;QACF,EAAE,OAAM;YACNjB,UAAU;gBAAEC,MAAM;gBAASG,KAAK;YAAyC;QAC3E;IACF;IAEA,qBACE,MAACc;QAAIC,WAAU;QAAaC,OAAO;YAAEC,WAAW;QAAO;;0BACrD,KAACC;gBAAMH,WAAU;0BAAc;;0BAC/B,KAACI;gBAAEH,OAAO;oBAAEI,UAAU;oBAAUH,WAAW;oBAAGI,SAAS;gBAAI;0BAAG;;0BAG9D,MAACP;gBAAIE,OAAO;oBAAEM,YAAY;oBAAUC,SAAS;oBAAQC,UAAU;oBAAQC,KAAK;gBAAQ;;kCAClF,KAACC;wBACCC,UAAU,CAACC,IAAMlC,MAAMkC,EAAEC,MAAM,CAACC,KAAK;wBACrCC,aAAY;wBACZf,OAAO;4BAAEgB,MAAM;4BAAGC,UAAU;wBAAQ;wBACpCC,MAAK;wBACLJ,OAAOrC;;kCAET,KAAC0C;wBACCpB,WAAU;wBACVqB,UAAUzC,OAAOE,IAAI,KAAK;wBAC1BwC,SAASvC;wBACTkB,OAAO;4BAAEsB,YAAY;wBAAS;wBAC9BJ,MAAK;kCAEJvC,OAAOE,IAAI,KAAK,YAAY,aAAa;;;;YAG7CF,OAAOE,IAAI,KAAK,sBACf,MAACsB;gBAAEH,OAAO;oBAAEuB,OAAO;oBAAmCtB,WAAW;gBAAQ;;oBAAG;oBACvEtB,OAAOK,GAAG;;;YAGhBL,OAAOE,IAAI,KAAK,yBACf,MAACsB;gBAAEH,OAAO;oBAAEuB,OAAO;oBAAmCtB,WAAW;gBAAQ;;oBAAG;oBACvEtB,OAAOK,GAAG;;;;;AAKvB,EAAC;AAED,eAAeR,gBAAe"}
|
||||
+36
-1
@@ -5,8 +5,30 @@
|
||||
* user), so all fields — including the password — stay editable in the admin
|
||||
* panel while remaining inaccessible to anonymous API requests.
|
||||
*/ 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',
|
||||
admin: {
|
||||
// Hide SMTP fields when Graph is selected — they're not used then.
|
||||
condition: (_, siblingData)=>siblingData?.emailTransport !== 'graph'
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'smtpHost',
|
||||
@@ -37,7 +59,11 @@
|
||||
name: 'smtpPassword',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'SMTP account password.'
|
||||
description: 'SMTP account password.',
|
||||
// Masked in the UI (••••) — stored plaintext, readable for SMTP auth.
|
||||
components: {
|
||||
Field: '@intecion/ipal-kit/client#MaskedField'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -53,6 +79,15 @@
|
||||
admin: {
|
||||
description: 'Default "from" display name.'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'emailTest',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@intecion/ipal-kit/client#TestEmailButton'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n type: 'row',\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n]\n"],"names":["smtpFields","type","fields","name","admin","placeholder","width","defaultValue","description"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,QAAQ;YACN;gBACEC,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEC,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEH,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEE,OAAO;gBAAM;gBACtBC,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;CACD,CAAA"}
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n name: 'emailTransport',\n type: 'select',\n admin: {\n description:\n 'How outbound email is sent. \"Microsoft Graph\" is only available when configured by the administrator (Intecion).',\n // The Graph option only makes sense when agency credentials exist in env.\n // We can't read process.env in the admin UI directly, so a client project\n // that hasn't set up Graph should filter this option via integrationsFields\n // override, or simply leave it on 'smtp'. The adapter enforces the real\n // availability at send time regardless of what's selected here.\n },\n defaultValue: 'smtp',\n options: [\n { label: 'SMTP', value: 'smtp' },\n { label: 'Microsoft Graph (Exchange)', value: 'graph' },\n ],\n },\n {\n type: 'row',\n admin: {\n // Hide SMTP fields when Graph is selected — they're not used then.\n condition: (_, siblingData) => siblingData?.emailTransport !== 'graph',\n },\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n // Masked in the UI (••••) — stored plaintext, readable for SMTP auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n {\n name: 'emailTest',\n type: 'ui',\n admin: {\n components: {\n Field: '@intecion/ipal-kit/client#TestEmailButton',\n },\n },\n },\n]\n"],"names":["smtpFields","name","type","admin","description","defaultValue","options","label","value","condition","_","siblingData","emailTransport","fields","placeholder","width","components","Field"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QAMJ;QACAC,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAQC,OAAO;YAAO;YAC/B;gBAAED,OAAO;gBAA8BC,OAAO;YAAQ;SACvD;IACH;IACA;QACEN,MAAM;QACNC,OAAO;YACL,mEAAmE;YACnEM,WAAW,CAACC,GAAGC,cAAgBA,aAAaC,mBAAmB;QACjE;QACAC,QAAQ;YACN;gBACEZ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEW,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEY,OAAO;gBAAM;gBACtBV,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,sEAAsE;YACtEY,YAAY;gBACVC,OAAO;YACT;QACF;IACF;IACA;QACEhB,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLa,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"}
|
||||
+5
-1
@@ -31,7 +31,11 @@
|
||||
name: 'r2SecretAccessKey',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 secret access key.'
|
||||
description: 'R2 secret access key.',
|
||||
// Masked in the UI (••••) — stored plaintext, readable for R2 auth.
|
||||
components: {
|
||||
Field: '@intecion/ipal-kit/client#MaskedField'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/storage.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare R2 storage credentials.\n * Reserved for future use — media offloading to R2.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so the access keys stay editable in the admin panel while remaining\n * inaccessible to anonymous API requests.\n */\nexport const storageFields: Field[] = [\n {\n name: 'r2Bucket',\n type: 'text',\n admin: {\n description: 'R2 bucket name.',\n },\n },\n {\n name: 'r2Endpoint',\n type: 'text',\n admin: {\n description: 'R2 S3-compatible endpoint URL.',\n },\n },\n {\n name: 'r2AccessKeyId',\n type: 'text',\n admin: {\n description: 'R2 access key ID.',\n },\n },\n {\n name: 'r2SecretAccessKey',\n type: 'text',\n admin: {\n description: 'R2 secret access key.',\n },\n },\n]\n"],"names":["storageFields","name","type","admin","description"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;CACD,CAAA"}
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/storage.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare R2 storage credentials.\n * Reserved for future use — media offloading to R2.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so the access keys stay editable in the admin panel while remaining\n * inaccessible to anonymous API requests.\n */\nexport const storageFields: Field[] = [\n {\n name: 'r2Bucket',\n type: 'text',\n admin: {\n description: 'R2 bucket name.',\n },\n },\n {\n name: 'r2Endpoint',\n type: 'text',\n admin: {\n description: 'R2 S3-compatible endpoint URL.',\n },\n },\n {\n name: 'r2AccessKeyId',\n type: 'text',\n admin: {\n description: 'R2 access key ID.',\n },\n },\n {\n name: 'r2SecretAccessKey',\n type: 'text',\n admin: {\n description: 'R2 secret access key.',\n // Masked in the UI (••••) — stored plaintext, readable for R2 auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n]\n"],"names":["storageFields","name","type","admin","description","components","Field"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,oEAAoE;YACpEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"}
|
||||
+5
-1
@@ -17,7 +17,11 @@
|
||||
name: 'turnstileSecretKey',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Secret key used for server-side verification.'
|
||||
description: 'Secret key used for server-side verification.',
|
||||
// Masked in the UI (••••) — stored plaintext, readable for verification.
|
||||
components: {
|
||||
Field: '@intecion/ipal-kit/client#MaskedField'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/turnstile.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare Turnstile credentials.\n *\n * siteKey is public (rendered in the widget); secretKey is used for\n * server-side verification. Both are protected at the global level\n * (SiteIntegrations requires an authenticated user) rather than per-field,\n * so they remain editable in the admin panel.\n */\nexport const turnstileFields: Field[] = [\n {\n name: 'turnstileSiteKey',\n type: 'text',\n admin: {\n description: 'Public site key rendered in the Turnstile widget.',\n },\n },\n {\n name: 'turnstileSecretKey',\n type: 'text',\n admin: {\n description: 'Secret key used for server-side verification.',\n },\n },\n]\n"],"names":["turnstileFields","name","type","admin","description"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,kBAA2B;IACtC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;CACD,CAAA"}
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/turnstile.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare Turnstile credentials.\n *\n * siteKey is public (rendered in the widget); secretKey is used for\n * server-side verification. Both are protected at the global level\n * (SiteIntegrations requires an authenticated user) rather than per-field,\n * so they remain editable in the admin panel.\n */\nexport const turnstileFields: Field[] = [\n {\n name: 'turnstileSiteKey',\n type: 'text',\n admin: {\n description: 'Public site key rendered in the Turnstile widget.',\n },\n },\n {\n name: 'turnstileSecretKey',\n type: 'text',\n admin: {\n description: 'Secret key used for server-side verification.',\n // Masked in the UI (••••) — stored plaintext, readable for verification.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n]\n"],"names":["turnstileFields","name","type","admin","description","components","Field"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,kBAA2B;IACtC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,yEAAyE;YACzEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"}
|
||||
+4
@@ -14,6 +14,10 @@ type BuildSiteIntegrationsArgs = {
|
||||
* impossible to enter.)
|
||||
*
|
||||
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
|
||||
*
|
||||
* Note: R2 storage credentials are NOT here — storage is infrastructure and
|
||||
* binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),
|
||||
* consumed by buildR2Storage. See docs/storage.md.
|
||||
*/
|
||||
export declare function buildSiteIntegrations({ additionalFields, }?: BuildSiteIntegrationsArgs): GlobalConfig;
|
||||
export {};
|
||||
|
||||
+4
-5
@@ -1,7 +1,6 @@
|
||||
import { isAdmin } from '../../modules/access/index.js';
|
||||
import { analyticsFields } from './fields/analytics.js';
|
||||
import { smtpFields } from './fields/smtp.js';
|
||||
import { storageFields } from './fields/storage.js';
|
||||
import { turnstileFields } from './fields/turnstile.js';
|
||||
/**
|
||||
* Builds the SiteIntegrations global.
|
||||
@@ -14,6 +13,10 @@ import { turnstileFields } from './fields/turnstile.js';
|
||||
* impossible to enter.)
|
||||
*
|
||||
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
|
||||
*
|
||||
* Note: R2 storage credentials are NOT here — storage is infrastructure and
|
||||
* binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),
|
||||
* consumed by buildR2Storage. See docs/storage.md.
|
||||
*/ export function buildSiteIntegrations({ additionalFields } = {}) {
|
||||
return {
|
||||
slug: 'site-integrations',
|
||||
@@ -42,10 +45,6 @@ import { turnstileFields } from './fields/turnstile.js';
|
||||
fields: smtpFields,
|
||||
label: 'SMTP'
|
||||
},
|
||||
{
|
||||
fields: storageFields,
|
||||
label: 'Storage'
|
||||
},
|
||||
...additionalFields?.length ? [
|
||||
{
|
||||
fields: additionalFields,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../src/globals/SiteIntegrations/index.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload'\n\nimport { isAdmin } from '../../modules/access/index.js'\nimport { analyticsFields } from './fields/analytics.js'\nimport { smtpFields } from './fields/smtp.js'\nimport { storageFields } from './fields/storage.js'\nimport { turnstileFields } from './fields/turnstile.js'\n\ntype BuildSiteIntegrationsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[]\n}\n\n/**\n * Builds the SiteIntegrations global.\n *\n * Holds third-party service credentials. Access is enforced at the global\n * level — the whole global requires an authenticated user — so secrets stay\n * out of anonymous API responses while remaining editable in the admin panel\n * and readable via the server-side Local API. (Field-level read:false was\n * avoided because it also hides fields from the admin UI, making them\n * impossible to enter.)\n *\n * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).\n */\nexport function buildSiteIntegrations({\n additionalFields,\n}: BuildSiteIntegrationsArgs = {}): GlobalConfig {\n return {\n slug: 'site-integrations',\n access: {\n // Admin-only — secrets live here. Anonymous and non-admin users get\n // nothing through the API; admins read/edit in the panel and via Local API.\n read: ({ req: { user } }) => isAdmin(user),\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n group: 'Settings',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n { fields: analyticsFields, label: 'Analytics' },\n { fields: turnstileFields, label: 'Turnstile' },\n { fields: smtpFields, label: 'SMTP' },\n { fields: storageFields, label: 'Storage' },\n ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Integrations',\n }\n}\n"],"names":["isAdmin","analyticsFields","smtpFields","storageFields","turnstileFields","buildSiteIntegrations","additionalFields","slug","access","read","req","user","update","admin","group","fields","type","tabs","label","length"],"mappings":"AAEA,SAASA,OAAO,QAAQ,gCAA+B;AACvD,SAASC,eAAe,QAAQ,wBAAuB;AACvD,SAASC,UAAU,QAAQ,mBAAkB;AAC7C,SAASC,aAAa,QAAQ,sBAAqB;AACnD,SAASC,eAAe,QAAQ,wBAAuB;AAOvD;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,sBAAsB,EACpCC,gBAAgB,EACU,GAAG,CAAC,CAAC;IAC/B,OAAO;QACLC,MAAM;QACNC,QAAQ;YACN,oEAAoE;YACpE,4EAA4E;YAC5EC,MAAM,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKX,QAAQW;YACrCC,QAAQ,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKX,QAAQW;QACzC;QACAE,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBAAEF,QAAQd;wBAAiBiB,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQX;wBAAiBc,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQb;wBAAYgB,OAAO;oBAAO;oBACpC;wBAAEH,QAAQZ;wBAAee,OAAO;oBAAU;uBACtCZ,kBAAkBa,SAAS;wBAAC;4BAAEJ,QAAQT;4BAAkBY,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"}
|
||||
{"version":3,"sources":["../../../src/globals/SiteIntegrations/index.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload'\n\nimport { isAdmin } from '../../modules/access/index.js'\nimport { analyticsFields } from './fields/analytics.js'\nimport { smtpFields } from './fields/smtp.js'\nimport { turnstileFields } from './fields/turnstile.js'\n\ntype BuildSiteIntegrationsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[]\n}\n\n/**\n * Builds the SiteIntegrations global.\n *\n * Holds third-party service credentials. Access is enforced at the global\n * level — the whole global requires an authenticated user — so secrets stay\n * out of anonymous API responses while remaining editable in the admin panel\n * and readable via the server-side Local API. (Field-level read:false was\n * avoided because it also hides fields from the admin UI, making them\n * impossible to enter.)\n *\n * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).\n *\n * Note: R2 storage credentials are NOT here — storage is infrastructure and\n * binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),\n * consumed by buildR2Storage. See docs/storage.md.\n */\nexport function buildSiteIntegrations({\n additionalFields,\n}: BuildSiteIntegrationsArgs = {}): GlobalConfig {\n return {\n slug: 'site-integrations',\n access: {\n // Admin-only — secrets live here. Anonymous and non-admin users get\n // nothing through the API; admins read/edit in the panel and via Local API.\n read: ({ req: { user } }) => isAdmin(user),\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n group: 'Settings',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n { fields: analyticsFields, label: 'Analytics' },\n { fields: turnstileFields, label: 'Turnstile' },\n { fields: smtpFields, label: 'SMTP' },\n ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Integrations',\n }\n}\n"],"names":["isAdmin","analyticsFields","smtpFields","turnstileFields","buildSiteIntegrations","additionalFields","slug","access","read","req","user","update","admin","group","fields","type","tabs","label","length"],"mappings":"AAEA,SAASA,OAAO,QAAQ,gCAA+B;AACvD,SAASC,eAAe,QAAQ,wBAAuB;AACvD,SAASC,UAAU,QAAQ,mBAAkB;AAC7C,SAASC,eAAe,QAAQ,wBAAuB;AAOvD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,sBAAsB,EACpCC,gBAAgB,EACU,GAAG,CAAC,CAAC;IAC/B,OAAO;QACLC,MAAM;QACNC,QAAQ;YACN,oEAAoE;YACpE,4EAA4E;YAC5EC,MAAM,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKV,QAAQU;YACrCC,QAAQ,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKV,QAAQU;QACzC;QACAE,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBAAEF,QAAQb;wBAAiBgB,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQX;wBAAiBc,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQZ;wBAAYe,OAAO;oBAAO;uBAChCZ,kBAAkBa,SAAS;wBAAC;4BAAEJ,QAAQT;4BAAkBY,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { Field } from 'payload';
|
||||
/**
|
||||
* General site identity fields.
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
||||
*/
|
||||
export declare const generalFields: Field[];
|
||||
|
||||
+11
-3
@@ -1,6 +1,7 @@
|
||||
import { validateFaviconField } from '../../../modules/seo/index.js';
|
||||
/**
|
||||
* General site identity fields.
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
||||
*/ export const generalFields = [
|
||||
{
|
||||
name: 'siteName',
|
||||
@@ -63,7 +64,7 @@
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Primary site logo.'
|
||||
description: 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).'
|
||||
},
|
||||
relationTo: 'media'
|
||||
},
|
||||
@@ -79,7 +80,14 @@
|
||||
name: 'favicon',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.'
|
||||
description: 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.'
|
||||
},
|
||||
hooks: {
|
||||
// Warns the editor at save time if the favicon is too small (<48×48) or
|
||||
// not square — Google won't display such favicons in search results.
|
||||
beforeValidate: [
|
||||
validateFaviconField
|
||||
]
|
||||
},
|
||||
relationTo: 'media'
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description: 'Primary site logo.',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.',\n },\n relationTo: 'media',\n },\n]\n"],"names":["generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo"],"mappings":"AAEA;;;CAGC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;CACD,CAAA"}
|
||||
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { validateFaviconField } from '../../../modules/seo/index.js'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description:\n 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description:\n 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',\n },\n hooks: {\n // Warns the editor at save time if the favicon is too small (<48×48) or\n // not square — Google won't display such favicons in search results.\n beforeValidate: [validateFaviconField],\n },\n relationTo: 'media',\n },\n]\n"],"names":["validateFaviconField","generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo","hooks","beforeValidate"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,gCAA+B;AAEpE;;;CAGC,GACD,OAAO,MAAMC,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAQ,OAAO;YACL,wEAAwE;YACxE,qEAAqE;YACrEC,gBAAgB;gBAACd;aAAqB;QACxC;QACAY,YAAY;IACd;CACD,CAAA"}
|
||||
Vendored
+12
@@ -7,6 +7,10 @@ export type { ConsentCategory, ConsentState, ConsentTexts } from './modules/cons
|
||||
export type { ContentCollectionOption, ContentOption, ResolvedRoute, } 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 { 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 type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';
|
||||
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||
@@ -16,14 +20,22 @@ export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18
|
||||
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath, } from './modules/i18n/index.js';
|
||||
export type { LocaleMiddlewareResult } from './modules/i18n/index.js';
|
||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage, } from './modules/notifications/index.js';
|
||||
export type { FormNotificationTexts, NotificationsData, NotificationTexts, } from './modules/notifications/index.js';
|
||||
export type { PagesOption, SystemPageRole } 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 { 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 { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } 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 type { AutoFillMapping, RobotsRules, SitemapEntry } 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 { buildR2Storage } from './modules/storage/index.js';
|
||||
export { ipalKit } from './plugin.js';
|
||||
export type { IpalOptions } from './types.js';
|
||||
|
||||
Vendored
+12
@@ -2,6 +2,8 @@ export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSe
|
||||
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 { 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
|
||||
// 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.
|
||||
@@ -10,11 +12,21 @@ export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||
export { createContentHelpers } from './modules/frontend/index.js';
|
||||
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js';
|
||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
||||
// Media — filename normalization hook for upload collections (Media).
|
||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from './modules/notifications/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 { buildSecurityHeaders } from './modules/security/index.js';
|
||||
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } 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 { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
||||
export { buildR2Storage } from './modules/storage/index.js';
|
||||
export { ipalKit } from './plugin.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
import type { PayloadEmailAdapter } from 'payload';
|
||||
export type GraphAdapterArgs = {
|
||||
fallbackFromAddress?: string;
|
||||
fallbackFromName?: string;
|
||||
};
|
||||
/**
|
||||
* Payload email adapter that sends through Microsoft Graph (our Exchange),
|
||||
* using app-only client-credentials auth. Drop-in alternative to
|
||||
* panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail
|
||||
* and the form-builder's submission emails work unchanged.
|
||||
*
|
||||
* Split of configuration (deliberate):
|
||||
* - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.
|
||||
* The client never sees or sets them — it's our Exchange, one mailbox
|
||||
* (GRAPH_SENDER, e.g. [email protected]) for every project.
|
||||
* - From-display + recipient = per-project, from the panel (SiteIntegrations),
|
||||
* so an editor controls how the mail is labelled and where it lands.
|
||||
*
|
||||
* Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()
|
||||
*
|
||||
* Azure setup (one-time, our side): App registration → Mail.Send APPLICATION
|
||||
* permission → admin consent → in Exchange, grant the app "Send As" on the
|
||||
* shared mailbox GRAPH_SENDER.
|
||||
*/
|
||||
export declare const graphAdapter: (args?: GraphAdapterArgs) => PayloadEmailAdapter;
|
||||
Vendored
+189
@@ -0,0 +1,189 @@
|
||||
import { getSiteIntegrations } from '../payload/index.js';
|
||||
/** Reads + validates the agency Graph credentials from env. */ function readGraphEnv() {
|
||||
const tenantId = process.env.GRAPH_TENANT_ID;
|
||||
const clientId = process.env.GRAPH_CLIENT_ID;
|
||||
const clientSecret = process.env.GRAPH_CLIENT_SECRET;
|
||||
const sender = process.env.GRAPH_SENDER;
|
||||
if (!tenantId || !clientId || !clientSecret || !sender) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
sender,
|
||||
tenantId
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Fetches an app-only access token via the OAuth2 client-credentials flow.
|
||||
* Scope MUST be '.../.default' — passing 'Mail.Send' directly is rejected
|
||||
* (AADSTS1002012). Tokens last ~1h; we fetch per send for simplicity and to
|
||||
* avoid holding state in a possibly multi-instance deployment. If you send at
|
||||
* high volume, cache by expiry.
|
||||
*/ async function getAccessToken(env) {
|
||||
const url = `https://login.microsoftonline.com/${env.tenantId}/oauth2/v2.0/token`;
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'https://graph.microsoft.com/.default'
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
body,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Graph token request failed (${res.status}): ${detail}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.access_token) {
|
||||
throw new Error('Graph token response had no access_token');
|
||||
}
|
||||
return data.access_token;
|
||||
}
|
||||
/** Normalizes Payload's to/cc (string | string[] | Address[]) into Graph recipients. */ function toRecipients(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(value) ? value : [
|
||||
value
|
||||
];
|
||||
return list.map((v)=>typeof v === 'string' ? v : v.address).filter((a)=>typeof a === 'string' && a.length > 0).map((address)=>({
|
||||
emailAddress: {
|
||||
address
|
||||
}
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Payload email adapter that sends through Microsoft Graph (our Exchange),
|
||||
* using app-only client-credentials auth. Drop-in alternative to
|
||||
* panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail
|
||||
* and the form-builder's submission emails work unchanged.
|
||||
*
|
||||
* Split of configuration (deliberate):
|
||||
* - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.
|
||||
* The client never sees or sets them — it's our Exchange, one mailbox
|
||||
* (GRAPH_SENDER, e.g. [email protected]) for every project.
|
||||
* - From-display + recipient = per-project, from the panel (SiteIntegrations),
|
||||
* so an editor controls how the mail is labelled and where it lands.
|
||||
*
|
||||
* Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()
|
||||
*
|
||||
* Azure setup (one-time, our side): App registration → Mail.Send APPLICATION
|
||||
* permission → admin consent → in Exchange, grant the app "Send As" on the
|
||||
* shared mailbox GRAPH_SENDER.
|
||||
*/ export const graphAdapter = (args = {})=>({ payload })=>({
|
||||
name: 'ipal-graph',
|
||||
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
|
||||
defaultFromName: args.fallbackFromName ?? 'Website',
|
||||
sendEmail: async (message)=>{
|
||||
const env = readGraphEnv();
|
||||
if (!env) {
|
||||
payload.logger.error('[ipal] Email not sent: Graph is not configured. Set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER.');
|
||||
return {
|
||||
error: 'Graph is not configured (missing env vars).',
|
||||
sent: false
|
||||
};
|
||||
}
|
||||
// Display name on the From, WITHOUT triggering Send-As.
|
||||
//
|
||||
// The trick: we may set a `from` as long as its ADDRESS stays the sender
|
||||
// mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
|
||||
// demands Send-As when the from ADDRESS differs from the mailbox, so a
|
||||
// same-address / custom-name From is allowed and gives each project its
|
||||
// own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
|
||||
//
|
||||
// The panel's from-address becomes Reply-To (so replies reach the client),
|
||||
// and the panel's from-name becomes the sender display name.
|
||||
const panel = await getSiteIntegrations(payload);
|
||||
const replyToAddress = panel.smtpFromAddress || undefined;
|
||||
const senderName = panel.smtpFromName || undefined;
|
||||
const to = toRecipients(message.to);
|
||||
if (to.length === 0) {
|
||||
payload.logger.error('[ipal] Email not sent: no valid recipient.');
|
||||
return {
|
||||
error: 'No valid recipient.',
|
||||
sent: false
|
||||
};
|
||||
}
|
||||
// Graph accepts either HTML or Text; Payload gives us html and/or text.
|
||||
const isHtml = typeof message.html === 'string' && message.html.length > 0;
|
||||
const content = isHtml ? String(message.html) : String(message.text ?? '');
|
||||
// Reply-To: prefer whatever the caller set; otherwise the panel address.
|
||||
const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [
|
||||
{
|
||||
emailAddress: {
|
||||
address: replyToAddress
|
||||
}
|
||||
}
|
||||
] : [];
|
||||
const graphMessage = {
|
||||
body: {
|
||||
content,
|
||||
contentType: isHtml ? 'HTML' : 'Text'
|
||||
},
|
||||
subject: message.subject ?? '',
|
||||
toRecipients: to,
|
||||
...message.cc ? {
|
||||
ccRecipients: toRecipients(message.cc)
|
||||
} : {},
|
||||
...message.bcc ? {
|
||||
bccRecipients: toRecipients(message.bcc)
|
||||
} : {},
|
||||
// From with the sender's OWN address (no Send-As) plus an optional
|
||||
// display name from the panel. Omit entirely when no name is set —
|
||||
// Graph then uses the mailbox's default name.
|
||||
...senderName ? {
|
||||
from: {
|
||||
emailAddress: {
|
||||
name: senderName,
|
||||
address: env.sender
|
||||
}
|
||||
}
|
||||
} : {},
|
||||
...replyTo.length > 0 ? {
|
||||
replyTo
|
||||
} : {}
|
||||
};
|
||||
try {
|
||||
const token = await getAccessToken(env);
|
||||
// App-only: MUST target /users/{sender}, never /me.
|
||||
const res = await fetch(`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(env.sender)}/sendMail`, {
|
||||
body: JSON.stringify({
|
||||
message: graphMessage,
|
||||
saveToSentItems: false
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
method: 'POST'
|
||||
});
|
||||
// sendMail returns 202 Accepted with an empty body on success.
|
||||
if (res.status === 202) {
|
||||
return {
|
||||
sent: true
|
||||
};
|
||||
}
|
||||
const detail = await res.text();
|
||||
payload.logger.error(`[ipal] Graph sendMail failed (${res.status}): ${detail}`);
|
||||
return {
|
||||
error: `Graph sendMail failed (${res.status}).`,
|
||||
sent: false
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
payload.logger.error(`[ipal] Graph send error: ${msg}`);
|
||||
return {
|
||||
error: 'Graph send error.',
|
||||
sent: false
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//# sourceMappingURL=graphAdapter.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -1,2 +1,6 @@
|
||||
export { graphAdapter } from './graphAdapter.js';
|
||||
export type { GraphAdapterArgs } from './graphAdapter.js';
|
||||
export { mailAdapter } from './mailAdapter.js';
|
||||
export type { MailAdapterArgs } from './mailAdapter.js';
|
||||
export { sendEmail } from './sendEmail.js';
|
||||
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js';
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
export { graphAdapter } from './graphAdapter.js';
|
||||
export { mailAdapter } from './mailAdapter.js';
|
||||
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
|
||||
// so this must never be imported from a client component.
|
||||
export { sendEmail } from './sendEmail.js';
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["sendEmail"],"mappings":"AAAA,mFAAmF;AACnF,0DAA0D;AAC1D,SAASA,SAAS,QAAQ,iBAAgB"}
|
||||
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["export { graphAdapter } from './graphAdapter.js'\nexport type { GraphAdapterArgs } from './graphAdapter.js'\nexport { mailAdapter } from './mailAdapter.js'\nexport type { MailAdapterArgs } from './mailAdapter.js'\n// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["graphAdapter","mailAdapter","sendEmail"],"mappings":"AAAA,SAASA,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,mFAAmF;AACnF,0DAA0D;AAC1D,SAASC,SAAS,QAAQ,iBAAgB"}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
import type { PayloadEmailAdapter } from 'payload';
|
||||
import { type GraphAdapterArgs } from './graphAdapter.js';
|
||||
import { type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js';
|
||||
export type MailAdapterArgs = {
|
||||
fallbackFromAddress?: string;
|
||||
fallbackFromName?: string;
|
||||
graph?: GraphAdapterArgs;
|
||||
smtp?: PanelSmtpAdapterArgs;
|
||||
};
|
||||
/**
|
||||
* Dispatcher email adapter: wired into the config ONCE, but picks the transport
|
||||
* (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.
|
||||
* This is what makes the choice switchable in the panel — Payload builds the
|
||||
* email adapter at boot and can't swap it at runtime, so instead of choosing
|
||||
* between two adapters at boot we install one that delegates on every send.
|
||||
*
|
||||
* Availability guard: Graph only runs if its agency credentials exist in env
|
||||
* (this is *our* Exchange). If the panel says 'graph' but env isn't set up,
|
||||
* we DON'T silently fail — we log clearly and fall back to SMTP, so a client
|
||||
* flipping the switch without the backing config still gets mail out (over SMTP)
|
||||
* rather than silent nothing. If neither is usable, the send reports an error.
|
||||
*
|
||||
* @example
|
||||
* // payload.config.ts
|
||||
* import { mailAdapter } from '@intecion/ipal-kit'
|
||||
* email: mailAdapter()
|
||||
*/
|
||||
export declare const mailAdapter: (args?: MailAdapterArgs) => PayloadEmailAdapter;
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
import { getSiteIntegrations } from '../payload/index.js';
|
||||
import { graphAdapter } from './graphAdapter.js';
|
||||
import { panelSmtpAdapter } from './panelSmtpAdapter.js';
|
||||
/** True when the agency Graph credentials are present in the environment. */ function graphAvailable() {
|
||||
return Boolean(process.env.GRAPH_TENANT_ID && process.env.GRAPH_CLIENT_ID && process.env.GRAPH_CLIENT_SECRET && process.env.GRAPH_SENDER);
|
||||
}
|
||||
/**
|
||||
* Dispatcher email adapter: wired into the config ONCE, but picks the transport
|
||||
* (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.
|
||||
* This is what makes the choice switchable in the panel — Payload builds the
|
||||
* email adapter at boot and can't swap it at runtime, so instead of choosing
|
||||
* between two adapters at boot we install one that delegates on every send.
|
||||
*
|
||||
* Availability guard: Graph only runs if its agency credentials exist in env
|
||||
* (this is *our* Exchange). If the panel says 'graph' but env isn't set up,
|
||||
* we DON'T silently fail — we log clearly and fall back to SMTP, so a client
|
||||
* flipping the switch without the backing config still gets mail out (over SMTP)
|
||||
* rather than silent nothing. If neither is usable, the send reports an error.
|
||||
*
|
||||
* @example
|
||||
* // payload.config.ts
|
||||
* import { mailAdapter } from '@intecion/ipal-kit'
|
||||
* email: mailAdapter()
|
||||
*/ export const mailAdapter = (args = {})=>(deps)=>{
|
||||
// Build both delegates once; each still resolves its own config per send.
|
||||
const smtp = panelSmtpAdapter({
|
||||
fallbackFromAddress: args.fallbackFromAddress,
|
||||
fallbackFromName: args.fallbackFromName,
|
||||
...args.smtp
|
||||
})(deps);
|
||||
const graph = graphAdapter({
|
||||
fallbackFromAddress: args.fallbackFromAddress,
|
||||
fallbackFromName: args.fallbackFromName,
|
||||
...args.graph
|
||||
})(deps);
|
||||
const { payload } = deps;
|
||||
return {
|
||||
name: 'ipal-mail-dispatcher',
|
||||
defaultFromAddress: smtp.defaultFromAddress,
|
||||
defaultFromName: smtp.defaultFromName,
|
||||
sendEmail: async (message)=>{
|
||||
const settings = await getSiteIntegrations(payload);
|
||||
const choice = settings.emailTransport ?? 'smtp';
|
||||
if (choice === 'graph') {
|
||||
if (graphAvailable()) {
|
||||
return graph.sendEmail(message);
|
||||
}
|
||||
// Panel asked for Graph but the agency creds aren't configured for
|
||||
// this project. Fall back to SMTP rather than silently dropping mail.
|
||||
payload.logger.warn('[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.');
|
||||
return smtp.sendEmail(message);
|
||||
}
|
||||
return smtp.sendEmail(message);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
//# sourceMappingURL=mailAdapter.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/email/mailAdapter.ts"],"sourcesContent":["import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'\n\nimport { getSiteIntegrations } from '../payload/index.js'\nimport { graphAdapter, type GraphAdapterArgs } from './graphAdapter.js'\nimport { panelSmtpAdapter, type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js'\n\ntype TransportIntegrations = {\n /** 'smtp' | 'graph' — chosen by the editor in SiteIntegrations. */\n emailTransport?: 'graph' | 'smtp' | null\n}\n\nexport type MailAdapterArgs = {\n fallbackFromAddress?: string\n fallbackFromName?: string\n graph?: GraphAdapterArgs\n smtp?: PanelSmtpAdapterArgs\n}\n\n/** True when the agency Graph credentials are present in the environment. */\nfunction graphAvailable(): boolean {\n return Boolean(\n process.env.GRAPH_TENANT_ID &&\n process.env.GRAPH_CLIENT_ID &&\n process.env.GRAPH_CLIENT_SECRET &&\n process.env.GRAPH_SENDER,\n )\n}\n\n/**\n * Dispatcher email adapter: wired into the config ONCE, but picks the transport\n * (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.\n * This is what makes the choice switchable in the panel — Payload builds the\n * email adapter at boot and can't swap it at runtime, so instead of choosing\n * between two adapters at boot we install one that delegates on every send.\n *\n * Availability guard: Graph only runs if its agency credentials exist in env\n * (this is *our* Exchange). If the panel says 'graph' but env isn't set up,\n * we DON'T silently fail — we log clearly and fall back to SMTP, so a client\n * flipping the switch without the backing config still gets mail out (over SMTP)\n * rather than silent nothing. If neither is usable, the send reports an error.\n *\n * @example\n * // payload.config.ts\n * import { mailAdapter } from '@intecion/ipal-kit'\n * email: mailAdapter()\n */\nexport const mailAdapter =\n (args: MailAdapterArgs = {}): PayloadEmailAdapter =>\n (deps) => {\n // Build both delegates once; each still resolves its own config per send.\n const smtp = panelSmtpAdapter({\n fallbackFromAddress: args.fallbackFromAddress,\n fallbackFromName: args.fallbackFromName,\n ...args.smtp,\n })(deps)\n const graph = graphAdapter({\n fallbackFromAddress: args.fallbackFromAddress,\n fallbackFromName: args.fallbackFromName,\n ...args.graph,\n })(deps)\n\n const { payload } = deps\n\n return {\n name: 'ipal-mail-dispatcher',\n defaultFromAddress: smtp.defaultFromAddress,\n defaultFromName: smtp.defaultFromName,\n\n sendEmail: async (message: SendEmailOptions) => {\n const settings = await getSiteIntegrations<TransportIntegrations>(payload)\n const choice = settings.emailTransport ?? 'smtp'\n\n if (choice === 'graph') {\n if (graphAvailable()) {\n return graph.sendEmail(message)\n }\n // Panel asked for Graph but the agency creds aren't configured for\n // this project. Fall back to SMTP rather than silently dropping mail.\n payload.logger.warn(\n '[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.',\n )\n return smtp.sendEmail(message)\n }\n\n return smtp.sendEmail(message)\n },\n }\n }\n"],"names":["getSiteIntegrations","graphAdapter","panelSmtpAdapter","graphAvailable","Boolean","process","env","GRAPH_TENANT_ID","GRAPH_CLIENT_ID","GRAPH_CLIENT_SECRET","GRAPH_SENDER","mailAdapter","args","deps","smtp","fallbackFromAddress","fallbackFromName","graph","payload","name","defaultFromAddress","defaultFromName","sendEmail","message","settings","choice","emailTransport","logger","warn"],"mappings":"AAEA,SAASA,mBAAmB,QAAQ,sBAAqB;AACzD,SAASC,YAAY,QAA+B,oBAAmB;AACvE,SAASC,gBAAgB,QAAmC,wBAAuB;AAcnF,2EAA2E,GAC3E,SAASC;IACP,OAAOC,QACLC,QAAQC,GAAG,CAACC,eAAe,IAC3BF,QAAQC,GAAG,CAACE,eAAe,IAC3BH,QAAQC,GAAG,CAACG,mBAAmB,IAC/BJ,QAAQC,GAAG,CAACI,YAAY;AAE5B;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,cACX,CAACC,OAAwB,CAAC,CAAC,GAC3B,CAACC;QACC,0EAA0E;QAC1E,MAAMC,OAAOZ,iBAAiB;YAC5Ba,qBAAqBH,KAAKG,mBAAmB;YAC7CC,kBAAkBJ,KAAKI,gBAAgB;YACvC,GAAGJ,KAAKE,IAAI;QACd,GAAGD;QACH,MAAMI,QAAQhB,aAAa;YACzBc,qBAAqBH,KAAKG,mBAAmB;YAC7CC,kBAAkBJ,KAAKI,gBAAgB;YACvC,GAAGJ,KAAKK,KAAK;QACf,GAAGJ;QAEH,MAAM,EAAEK,OAAO,EAAE,GAAGL;QAEpB,OAAO;YACLM,MAAM;YACNC,oBAAoBN,KAAKM,kBAAkB;YAC3CC,iBAAiBP,KAAKO,eAAe;YAErCC,WAAW,OAAOC;gBAChB,MAAMC,WAAW,MAAMxB,oBAA2CkB;gBAClE,MAAMO,SAASD,SAASE,cAAc,IAAI;gBAE1C,IAAID,WAAW,SAAS;oBACtB,IAAItB,kBAAkB;wBACpB,OAAOc,MAAMK,SAAS,CAACC;oBACzB;oBACA,mEAAmE;oBACnE,sEAAsE;oBACtEL,QAAQS,MAAM,CAACC,IAAI,CACjB;oBAEF,OAAOd,KAAKQ,SAAS,CAACC;gBACxB;gBAEA,OAAOT,KAAKQ,SAAS,CAACC;YACxB;QACF;IACF,EAAC"}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import type { Endpoint } from 'payload';
|
||||
/**
|
||||
* Custom endpoint: send a test email to a given address through whatever
|
||||
* transport is currently active (SMTP or Graph — mailAdapter reads the panel
|
||||
* setting per send, so the test exercises the REAL path a form email would
|
||||
* take). Mounted at POST /api/ipal/test-email.
|
||||
*
|
||||
* Admin-only: uses payload.sendEmail (server-side), and requires an
|
||||
* authenticated admin user — a test-send button must never be open to the
|
||||
* public (it would be an open relay / spam vector).
|
||||
*
|
||||
* Returns the adapter's own result so the panel can show exactly what happened,
|
||||
* including the transport-specific error (SMTP auth failure, Graph 401, etc.).
|
||||
*/
|
||||
export declare const testEmailEndpoint: Endpoint;
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { addDataAndFileToRequest } from 'payload';
|
||||
/**
|
||||
* Custom endpoint: send a test email to a given address through whatever
|
||||
* transport is currently active (SMTP or Graph — mailAdapter reads the panel
|
||||
* setting per send, so the test exercises the REAL path a form email would
|
||||
* take). Mounted at POST /api/ipal/test-email.
|
||||
*
|
||||
* Admin-only: uses payload.sendEmail (server-side), and requires an
|
||||
* authenticated admin user — a test-send button must never be open to the
|
||||
* public (it would be an open relay / spam vector).
|
||||
*
|
||||
* Returns the adapter's own result so the panel can show exactly what happened,
|
||||
* including the transport-specific error (SMTP auth failure, Graph 401, etc.).
|
||||
*/ export const testEmailEndpoint = {
|
||||
handler: async (req)=>{
|
||||
// Auth: only signed-in admins may trigger a send.
|
||||
if (!req.user) {
|
||||
return Response.json({
|
||||
error: 'Unauthorized',
|
||||
ok: false
|
||||
}, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
await addDataAndFileToRequest(req);
|
||||
const to = req.data?.to?.trim();
|
||||
if (!to || !/^[^@\s]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(to)) {
|
||||
return Response.json({
|
||||
error: 'Provide a valid recipient address.',
|
||||
ok: false
|
||||
}, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
try {
|
||||
const info = await req.payload.sendEmail({
|
||||
html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',
|
||||
subject: 'ipal-kit — test email',
|
||||
text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',
|
||||
to
|
||||
});
|
||||
// Payload's sendEmail resolves with the adapter's result. Our adapters
|
||||
// return { sent: boolean, error?: string }; nodemailer returns info with
|
||||
// messageId. Normalize to a simple ok/message for the panel.
|
||||
const sent = info && typeof info === 'object' && 'sent' in info ? info.sent !== false : true;
|
||||
if (!sent) {
|
||||
const error = info?.error ?? 'Send failed (see server logs).';
|
||||
return Response.json({
|
||||
error,
|
||||
ok: false
|
||||
}, {
|
||||
status: 502
|
||||
});
|
||||
}
|
||||
return Response.json({
|
||||
message: `Test email sent to ${to}.`,
|
||||
ok: true
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
req.payload.logger.error(`[ipal] Test email failed: ${message}`);
|
||||
return Response.json({
|
||||
error: 'Send failed. Check transport settings and server logs.',
|
||||
ok: false
|
||||
}, {
|
||||
status: 502
|
||||
});
|
||||
}
|
||||
},
|
||||
method: 'post',
|
||||
path: '/ipal/test-email'
|
||||
};
|
||||
|
||||
//# sourceMappingURL=testEmailEndpoint.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../../src/modules/email/test/testEmailEndpoint.ts"],"sourcesContent":["import type { Endpoint, PayloadRequest } from 'payload'\n\nimport { addDataAndFileToRequest } from 'payload'\n\n/**\n * Custom endpoint: send a test email to a given address through whatever\n * transport is currently active (SMTP or Graph — mailAdapter reads the panel\n * setting per send, so the test exercises the REAL path a form email would\n * take). Mounted at POST /api/ipal/test-email.\n *\n * Admin-only: uses payload.sendEmail (server-side), and requires an\n * authenticated admin user — a test-send button must never be open to the\n * public (it would be an open relay / spam vector).\n *\n * Returns the adapter's own result so the panel can show exactly what happened,\n * including the transport-specific error (SMTP auth failure, Graph 401, etc.).\n */\nexport const testEmailEndpoint: Endpoint = {\n handler: async (req: PayloadRequest) => {\n // Auth: only signed-in admins may trigger a send.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized', ok: false }, { status: 401 })\n }\n\n await addDataAndFileToRequest(req)\n const to = (req.data?.to as string | undefined)?.trim()\n\n if (!to || !/^[^@\\s]+@[^\\s@][^\\s.@]*\\.[^\\s@]+$/.test(to)) {\n return Response.json(\n { error: 'Provide a valid recipient address.', ok: false },\n { status: 400 },\n )\n }\n\n try {\n const info = await req.payload.sendEmail({\n html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',\n subject: 'ipal-kit — test email',\n text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',\n to,\n })\n\n // Payload's sendEmail resolves with the adapter's result. Our adapters\n // return { sent: boolean, error?: string }; nodemailer returns info with\n // messageId. Normalize to a simple ok/message for the panel.\n const sent =\n info && typeof info === 'object' && 'sent' in info\n ? (info as { sent?: boolean }).sent !== false\n : true\n\n if (!sent) {\n const error = (info as { error?: string })?.error ?? 'Send failed (see server logs).'\n return Response.json({ error, ok: false }, { status: 502 })\n }\n\n return Response.json({ message: `Test email sent to ${to}.`, ok: true })\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n req.payload.logger.error(`[ipal] Test email failed: ${message}`)\n return Response.json(\n { error: 'Send failed. Check transport settings and server logs.', ok: false },\n { status: 502 },\n )\n }\n },\n method: 'post',\n path: '/ipal/test-email',\n}\n"],"names":["addDataAndFileToRequest","testEmailEndpoint","handler","req","user","Response","json","error","ok","status","to","data","trim","test","info","payload","sendEmail","html","subject","text","sent","message","err","Error","String","logger","method","path"],"mappings":"AAEA,SAASA,uBAAuB,QAAQ,UAAS;AAEjD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,oBAA8B;IACzCC,SAAS,OAAOC;QACd,kDAAkD;QAClD,IAAI,CAACA,IAAIC,IAAI,EAAE;YACb,OAAOC,SAASC,IAAI,CAAC;gBAAEC,OAAO;gBAAgBC,IAAI;YAAM,GAAG;gBAAEC,QAAQ;YAAI;QAC3E;QAEA,MAAMT,wBAAwBG;QAC9B,MAAMO,KAAMP,IAAIQ,IAAI,EAAED,IAA2BE;QAEjD,IAAI,CAACF,MAAM,CAAC,oCAAoCG,IAAI,CAACH,KAAK;YACxD,OAAOL,SAASC,IAAI,CAClB;gBAAEC,OAAO;gBAAsCC,IAAI;YAAM,GACzD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,IAAI;YACF,MAAMK,OAAO,MAAMX,IAAIY,OAAO,CAACC,SAAS,CAAC;gBACvCC,MAAM;gBACNC,SAAS;gBACTC,MAAM;gBACNT;YACF;YAEA,uEAAuE;YACvE,yEAAyE;YACzE,6DAA6D;YAC7D,MAAMU,OACJN,QAAQ,OAAOA,SAAS,YAAY,UAAUA,OAC1C,AAACA,KAA4BM,IAAI,KAAK,QACtC;YAEN,IAAI,CAACA,MAAM;gBACT,MAAMb,QAAQ,AAACO,MAA6BP,SAAS;gBACrD,OAAOF,SAASC,IAAI,CAAC;oBAAEC;oBAAOC,IAAI;gBAAM,GAAG;oBAAEC,QAAQ;gBAAI;YAC3D;YAEA,OAAOJ,SAASC,IAAI,CAAC;gBAAEe,SAAS,CAAC,mBAAmB,EAAEX,GAAG,CAAC,CAAC;gBAAEF,IAAI;YAAK;QACxE,EAAE,OAAOc,KAAK;YACZ,MAAMD,UAAUC,eAAeC,QAAQD,IAAID,OAAO,GAAGG,OAAOF;YAC5DnB,IAAIY,OAAO,CAACU,MAAM,CAAClB,KAAK,CAAC,CAAC,0BAA0B,EAAEc,SAAS;YAC/D,OAAOhB,SAASC,IAAI,CAClB;gBAAEC,OAAO;gBAA0DC,IAAI;YAAM,GAC7E;gBAAEC,QAAQ;YAAI;QAElB;IACF;IACAiB,QAAQ;IACRC,MAAM;AACR,EAAC"}
|
||||
Vendored
+12
-1
@@ -1,6 +1,11 @@
|
||||
import 'server-only';
|
||||
import type { BasePayload } from 'payload';
|
||||
export type SubmitFormArgs = {
|
||||
/**
|
||||
* Name of the GDPR consent checkbox. A field with this name must be checked
|
||||
* for the submission to succeed (enforced server-side). Defaults to 'consent'.
|
||||
*/
|
||||
consentFieldName?: string;
|
||||
/** Submitted field data — shape matches the form's fields. */
|
||||
data: Record<string, unknown>;
|
||||
/** Form-builder form ID this submission belongs to. */
|
||||
@@ -30,6 +35,7 @@ export type SubmitFormArgs = {
|
||||
* `field` and `kind` narrow it down when a specific field is
|
||||
* at fault (absent for whole-payload problems like unknown keys)
|
||||
* - `not_found` — no form with this id
|
||||
* - `consent` — a required GDPR consent checkbox was left unchecked
|
||||
* - `error` — persistence failed unexpectedly
|
||||
*/
|
||||
export type SubmitFailure = {
|
||||
@@ -39,6 +45,11 @@ export type SubmitFailure = {
|
||||
kind?: 'required' | 'too_long' | 'unknown_fields';
|
||||
reason: 'validation';
|
||||
success: false;
|
||||
} | {
|
||||
field?: string;
|
||||
/** A GDPR consent field existed on the form but wasn't checked. */
|
||||
reason: 'consent';
|
||||
success: false;
|
||||
} | {
|
||||
reason: 'error';
|
||||
success: false;
|
||||
@@ -69,4 +80,4 @@ export type SubmitFormResult = {
|
||||
*
|
||||
* server-only: touches the Turnstile secret.
|
||||
*/
|
||||
export declare function submitForm({ data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise<SubmitFormResult>;
|
||||
export declare function submitForm({ consentFieldName, data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise<SubmitFormResult>;
|
||||
|
||||
Vendored
+9
-2
@@ -14,7 +14,7 @@ import { validateSubmission } from './validateSubmission.js';
|
||||
* which goes out over panelSmtpAdapter. Storing the submission is enough.
|
||||
*
|
||||
* server-only: touches the Turnstile secret.
|
||||
*/ export async function submitForm({ data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) {
|
||||
*/ export async function submitForm({ consentFieldName, data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) {
|
||||
// 1. Rate limit — cheapest gate, drops a flood before any real work.
|
||||
if (maxPerMinute > 0 && ip) {
|
||||
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
|
||||
// shape of `data` — drop unknown keys, enforce required, cap length.
|
||||
const validation = await validateSubmission(payload, formId, data);
|
||||
const validation = await validateSubmission(payload, formId, data, consentFieldName);
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === 'not_found') {
|
||||
return {
|
||||
@@ -51,6 +51,13 @@ import { validateSubmission } from './validateSubmission.js';
|
||||
success: false
|
||||
};
|
||||
}
|
||||
if (validation.reason === 'consent') {
|
||||
return {
|
||||
field: validation.field,
|
||||
reason: 'consent',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
reason: 'validation',
|
||||
success: false,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+6
@@ -18,6 +18,12 @@ export type FormsCollectionOverrides = {
|
||||
* form-builder plugin. Kept minimal; the plugin passes these through.
|
||||
*/
|
||||
export type FormsOption = {
|
||||
/**
|
||||
* Name of the checkbox field treated as a GDPR consent gate. A form field
|
||||
* with this name must be checked for submission to succeed — enforced
|
||||
* server-side in submitForm. Defaults to 'consent'.
|
||||
*/
|
||||
consentFieldName?: string;
|
||||
/** Field types available in the form builder. Sensible defaults applied. */
|
||||
fields?: {
|
||||
checkbox?: boolean;
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WA6BC"}
|
||||
{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /**\n * Name of the checkbox field treated as a GDPR consent gate. A form field\n * with this name must be checked for submission to succeed — enforced\n * server-side in submitForm. Defaults to 'consent'.\n */\n consentFieldName?: string\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WAmCC"}
|
||||
+5
-1
@@ -27,6 +27,10 @@ export type FormValidationResult = {
|
||||
cleaned: Record<string, unknown>;
|
||||
form: FormDoc;
|
||||
ok: true;
|
||||
} | {
|
||||
field: string;
|
||||
ok: false;
|
||||
reason: 'consent';
|
||||
} | {
|
||||
ok: false;
|
||||
reason: 'not_found';
|
||||
@@ -44,5 +48,5 @@ export type FormValidationResult = {
|
||||
* Returns the loaded form on success so the caller doesn't fetch it twice, and
|
||||
* a code + offending field on failure so the frontend can point at it.
|
||||
*/
|
||||
export declare function validateSubmission(payload: BasePayload, formId: string, data: Record<string, unknown>): Promise<FormValidationResult>;
|
||||
export declare function validateSubmission(payload: BasePayload, formId: string, data: Record<string, unknown>, consentFieldName?: string): Promise<FormValidationResult>;
|
||||
export {};
|
||||
|
||||
+22
-2
@@ -12,6 +12,14 @@
|
||||
'g-recaptcha-response'
|
||||
]);
|
||||
/** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000;
|
||||
/**
|
||||
* Default name for a GDPR consent field. A checkbox with this name is treated
|
||||
* as a consent gate: it MUST be checked for the submission to go through,
|
||||
* enforced here server-side regardless of how the field was configured in the
|
||||
* panel (so an editor can't weaken it by forgetting `required` or, worse,
|
||||
* pre-ticking it with defaultValue: true — which GDPR forbids). Configurable
|
||||
* via FormsOption.consentFieldName.
|
||||
*/ const DEFAULT_CONSENT_FIELD = 'consent';
|
||||
/**
|
||||
* Checks submitted data against the form's own definition, rather than trusting
|
||||
* whatever arrived.
|
||||
@@ -24,7 +32,7 @@
|
||||
*
|
||||
* Returns the loaded form on success so the caller doesn't fetch it twice, and
|
||||
* a code + offending field on failure so the frontend can point at it.
|
||||
*/ export async function validateSubmission(payload, formId, data) {
|
||||
*/ export async function validateSubmission(payload, formId, data, consentFieldName = DEFAULT_CONSENT_FIELD) {
|
||||
let form;
|
||||
try {
|
||||
form = await payload.findByID({
|
||||
@@ -47,7 +55,19 @@
|
||||
for (const field of fields){
|
||||
const value = data[field.name];
|
||||
const isBlank = value == null || typeof value === 'string' && value.trim() === '' || value === false;
|
||||
if (field.required && isBlank) {
|
||||
// GDPR consent gate: a field matching the consent name must be truthy
|
||||
// (checked). Enforced independently of `required`, so it can't be weakened
|
||||
// in the panel. This is the one field where server-side enforcement is the
|
||||
// legal guarantee — the frontend can't bypass it, the editor can't misset it.
|
||||
if (field.name === consentFieldName) {
|
||||
if (value !== true) {
|
||||
return {
|
||||
field: field.name,
|
||||
ok: false,
|
||||
reason: 'consent'
|
||||
};
|
||||
}
|
||||
} else if (field.required && isBlank) {
|
||||
return {
|
||||
field: field.name,
|
||||
kind: 'required',
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+16
-16
@@ -1,14 +1,8 @@
|
||||
import type { BasePayload, SanitizedConfig } from 'payload';
|
||||
import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js';
|
||||
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js';
|
||||
import type { I18nConfig } from '../i18n/index.js';
|
||||
import type { RobotsRules, SitemapEntry } from '../seo/index.js';
|
||||
import type { SitemapEntry, RobotsRules } from '../seo/index.js';
|
||||
type CreateContentHelpersArgs = {
|
||||
/**
|
||||
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||
* reject, so set one in production).
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* The client's payload config promise (the default export of payload.config).
|
||||
* Passed in because the plugin never imports the client's config directly.
|
||||
@@ -16,15 +10,21 @@ type CreateContentHelpersArgs = {
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig;
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption;
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string;
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string;
|
||||
/**
|
||||
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||
* handlers — they need the locale list to emit hreflang.
|
||||
*/
|
||||
i18n?: I18nConfig;
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string;
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string;
|
||||
/**
|
||||
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||
* reject, so set one in production).
|
||||
*/
|
||||
baseUrl?: string;
|
||||
};
|
||||
/**
|
||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||
@@ -52,13 +52,13 @@ type CreateContentHelpersArgs = {
|
||||
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
||||
* not the second, and a page component composes them in two obvious lines.
|
||||
*/
|
||||
export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): {
|
||||
export declare function createContentHelpers({ config, content, settingsSlug, pagesSlug, i18n, baseUrl, }: CreateContentHelpersArgs): {
|
||||
getCachedPayload: () => Promise<BasePayload>;
|
||||
getConfiguredLocales: () => Promise<string[]>;
|
||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||
getSettings: (locale: string) => Promise<import("payload").JsonObject>;
|
||||
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<null | ResolvedRoute>;
|
||||
robots: () => RobotsRules;
|
||||
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<ResolvedRoute | null>;
|
||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||
sitemap: () => Promise<SitemapEntry[]>;
|
||||
robots: () => RobotsRules;
|
||||
};
|
||||
export {};
|
||||
|
||||
+42
-22
@@ -1,7 +1,7 @@
|
||||
import { getPayload } from 'payload';
|
||||
import { cache } from 'react';
|
||||
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js';
|
||||
import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
import { getPayload } from 'payload';
|
||||
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js';
|
||||
import { buildSitemapEntries, buildRobots } from '../seo/index.js';
|
||||
/**
|
||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||
* wrappers every project was writing by hand (getPayload, settings, locale
|
||||
@@ -27,7 +27,7 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
* `resolveRoute` and `getEntries` are separate on purpose: routing decides what
|
||||
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
||||
* not the second, and a page component composes them in two obvious lines.
|
||||
*/ export function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug = 'pages', settingsSlug = 'site-settings' }) {
|
||||
*/ export function createContentHelpers({ config, content, settingsSlug = 'site-settings', pagesSlug = 'pages', i18n, baseUrl }) {
|
||||
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
|
||||
const getCachedPayload = cache(async ()=>getPayload({
|
||||
config: await config
|
||||
@@ -40,29 +40,29 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
const payload = await getCachedPayload();
|
||||
return payload.findGlobal({
|
||||
slug: settingsSlug,
|
||||
depth: 2,
|
||||
locale: locale
|
||||
locale: locale,
|
||||
depth: 2
|
||||
});
|
||||
});
|
||||
/** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{
|
||||
const payload = await getCachedPayload();
|
||||
return resolveRouteRaw({
|
||||
content,
|
||||
locale,
|
||||
page,
|
||||
pagesSlug,
|
||||
payload,
|
||||
locale,
|
||||
segments,
|
||||
page,
|
||||
content,
|
||||
pagesSlug,
|
||||
settingsSlug
|
||||
});
|
||||
});
|
||||
/** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{
|
||||
const payload = await getCachedPayload();
|
||||
return getArchiveEntries({
|
||||
payload,
|
||||
collection,
|
||||
locale,
|
||||
page,
|
||||
payload,
|
||||
perPage
|
||||
});
|
||||
});
|
||||
@@ -73,19 +73,39 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
* ```ts
|
||||
* // app/sitemap.ts
|
||||
* export { sitemap as default } from '@/lib/content'
|
||||
* export const dynamic = 'force-dynamic' // generate at runtime, not build
|
||||
* ```
|
||||
*
|
||||
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
|
||||
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
|
||||
* calls into Payload → the database. The build container usually has no access
|
||||
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
|
||||
* the build dies. Two defenses:
|
||||
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
|
||||
* prerender, generates at runtime when the DB is reachable (recommended).
|
||||
* 2. This handler also catches DB errors and returns [] so that even without
|
||||
* (1) the build won't crash — it just ships an empty sitemap until the
|
||||
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
|
||||
*/ const sitemap = cache(async ()=>{
|
||||
if (!i18n) {
|
||||
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
|
||||
}
|
||||
return buildSitemapEntries({
|
||||
baseUrl: origin,
|
||||
config: i18n,
|
||||
content,
|
||||
pagesSlug,
|
||||
payload: await getCachedPayload(),
|
||||
settingsSlug
|
||||
});
|
||||
try {
|
||||
return await buildSitemapEntries({
|
||||
payload: await getCachedPayload(),
|
||||
config: i18n,
|
||||
baseUrl: origin,
|
||||
content,
|
||||
pagesSlug,
|
||||
settingsSlug
|
||||
});
|
||||
} catch (error) {
|
||||
// DB unreachable (typically a container build with no DB network) — return
|
||||
// an empty sitemap instead of failing the build. Runtime regeneration will
|
||||
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
|
||||
console.warn('[ipal] sitemap: could not reach the database, returning empty entries ' + "(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " + 'generate at runtime and avoid build-time DB access):', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Ready-made handler for Next's `app/robots.ts`. Re-export directly:
|
||||
@@ -100,11 +120,11 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
return {
|
||||
getCachedPayload,
|
||||
getConfiguredLocales,
|
||||
getEntries,
|
||||
getSettings,
|
||||
resolveRoute,
|
||||
robots,
|
||||
sitemap
|
||||
getEntries,
|
||||
sitemap,
|
||||
robots
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+22
-22
@@ -1,21 +1,21 @@
|
||||
import type { I18nConfig } from './types.js';
|
||||
import type { I18nConfig } from '../i18n/index.js';
|
||||
/**
|
||||
* Minimal request shape the middleware reads. Kept structural so the plugin
|
||||
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
||||
*/
|
||||
type MiddlewareRequest = {
|
||||
nextUrl: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
clone: () => URL;
|
||||
};
|
||||
cookies: {
|
||||
get: (name: string) => {
|
||||
value: string;
|
||||
} | undefined;
|
||||
};
|
||||
headers: {
|
||||
get: (name: string) => null | string;
|
||||
};
|
||||
nextUrl: {
|
||||
clone: () => URL;
|
||||
pathname: string;
|
||||
search: string;
|
||||
get: (name: string) => string | null;
|
||||
};
|
||||
url: string;
|
||||
};
|
||||
@@ -25,21 +25,23 @@ type MiddlewareRequest = {
|
||||
* `next` means let the request pass through untouched.
|
||||
*/
|
||||
export type LocaleMiddlewareResult = {
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
location: string;
|
||||
type: 'redirect';
|
||||
} | {
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
type: 'next';
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
} | {
|
||||
type: 'redirect';
|
||||
location: string;
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
type CreateLocaleMiddlewareArgs = {
|
||||
config: I18nConfig;
|
||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||
cookieName?: string;
|
||||
/**
|
||||
* Consent category that gates *persisting* the locale cookie. The locale is
|
||||
* always detected (routing works regardless), but the choice is only written
|
||||
@@ -47,11 +49,9 @@ type CreateLocaleMiddlewareArgs = {
|
||||
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
||||
* necessary), which restores the pre-consent behaviour.
|
||||
*/
|
||||
consentCategory?: 'functional' | 'necessary';
|
||||
consentCategory?: 'necessary' | 'functional';
|
||||
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
||||
consentCookieName?: string;
|
||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||
cookieName?: string;
|
||||
};
|
||||
/**
|
||||
* Builds locale-routing logic for Next.js middleware.
|
||||
@@ -80,7 +80,7 @@ type CreateLocaleMiddlewareArgs = {
|
||||
* return res
|
||||
* }
|
||||
*/
|
||||
export declare function createLocaleMiddleware({ config, consentCategory, consentCookieName, cookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;
|
||||
export declare function createLocaleMiddleware({ config, cookieName, consentCategory, consentCookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;
|
||||
/**
|
||||
* Default Next.js middleware matcher: run on everything except API routes, the
|
||||
* admin panel, Next internals, and files with an extension (static assets).
|
||||
|
||||
+15
-7
@@ -1,5 +1,5 @@
|
||||
import { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.js';
|
||||
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js';
|
||||
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js';
|
||||
/**
|
||||
* First path segment of a URL pathname, or '' for root.
|
||||
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
|
||||
@@ -32,18 +32,26 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
|
||||
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
|
||||
* return res
|
||||
* }
|
||||
*/ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) {
|
||||
*/ export function createLocaleMiddleware({ config, cookieName = LOCALE_COOKIE_NAME, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE }) {
|
||||
// Whether the locale cookie may be written: 'necessary' is always granted;
|
||||
// 'functional' (default) requires the visitor to have consented.
|
||||
function mayPersistLocale(request) {
|
||||
if (consentCategory === 'necessary') {
|
||||
return true;
|
||||
}
|
||||
if (consentCategory === 'necessary') return true;
|
||||
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
|
||||
return consent?.[consentCategory] === true;
|
||||
}
|
||||
return function localeMiddleware(request) {
|
||||
const { pathname } = request.nextUrl;
|
||||
// Single-locale sites have no /pl, /en prefix and no negotiation — one
|
||||
// language, no redirect. The middleware becomes a pass-through: paths stay
|
||||
// as-is (/o-nas), nothing to detect or persist. (Projects that are truly
|
||||
// single-locale usually don't even mount this middleware, but guarding here
|
||||
// makes it safe if they do.)
|
||||
if (config.locales.length === 1) {
|
||||
return {
|
||||
type: 'next'
|
||||
};
|
||||
}
|
||||
// Already locale-prefixed (e.g. the visitor switched language by
|
||||
// navigating to /en). Routing is fine — but if the URL's locale differs
|
||||
// from the stored cookie, the visitor is *choosing* a language, and we
|
||||
@@ -68,9 +76,9 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
|
||||
}
|
||||
// Resolve the locale to use
|
||||
const locale = negotiateLocale({
|
||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||
acceptLanguage: request.headers.get('accept-language'),
|
||||
config,
|
||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null
|
||||
config
|
||||
});
|
||||
// Redirect to the locale-prefixed path, preserving the rest
|
||||
const url = request.nextUrl.clone();
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-3
@@ -28,6 +28,12 @@ import { isValidLocale } from './helpers.js';
|
||||
if (!slug) {
|
||||
return undefined;
|
||||
}
|
||||
// Single-locale sites have no /pl, /en prefix — the language segment is
|
||||
// dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:
|
||||
// one configured locale means one language, so no prefix is needed. The
|
||||
// project's folder structure matches (app/[[...slug]] without [locale]).
|
||||
const singleLocale = config.locales.length === 1;
|
||||
const localeSegment = singleLocale ? '' : `/${locale}`;
|
||||
if (prefix) {
|
||||
const segment = prefix[locale];
|
||||
// No archive slug in this locale means the entry is unreachable there —
|
||||
@@ -36,12 +42,13 @@ import { isValidLocale } from './helpers.js';
|
||||
if (!segment) {
|
||||
return undefined;
|
||||
}
|
||||
return `/${locale}/${segment}/${slug}`;
|
||||
return `${localeSegment}/${segment}/${slug}`;
|
||||
}
|
||||
if (slug === homeSlug) {
|
||||
return `/${locale}`;
|
||||
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
|
||||
return localeSegment || '/';
|
||||
}
|
||||
return `/${locale}/${slug}`;
|
||||
return `${localeSegment}/${slug}`;
|
||||
}
|
||||
/**
|
||||
* Resolves the equivalent path for the same document in a different locale —
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+8
-1
@@ -1,6 +1,13 @@
|
||||
import type { I18nConfig } from './types.js';
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */
|
||||
export declare const LOCALE_COOKIE_NAME = "ipal-locale";
|
||||
/**
|
||||
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||
* other libraries that read the active locale (instead of a plugin-specific
|
||||
* name). Written only under functional consent; cleared when that consent is
|
||||
* withdrawn (see consent cookieMap).
|
||||
*/
|
||||
export declare const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
|
||||
type NegotiateLocaleArgs = {
|
||||
/** Raw Accept-Language header value */
|
||||
acceptLanguage?: null | string;
|
||||
|
||||
Vendored
+7
-1
@@ -1,5 +1,11 @@
|
||||
import { getLocaleCodes, isValidLocale } from './helpers.js';
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */ export const LOCALE_COOKIE_NAME = 'ipal-locale';
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */ /**
|
||||
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||
* other libraries that read the active locale (instead of a plugin-specific
|
||||
* name). Written only under functional consent; cleared when that consent is
|
||||
* withdrawn (see consent cookieMap).
|
||||
*/ export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE';
|
||||
/**
|
||||
* Resolves which locale to serve, in priority order:
|
||||
* 1. Cookie (explicit prior choice)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/media/index.ts"],"sourcesContent":["export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js'\n"],"names":["normalizeFilename","normalizeFilenameHook"],"mappings":"AAAA,SAASA,iBAAiB,EAAEC,qBAAqB,QAAQ,yBAAwB"}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { CollectionBeforeOperationHook } from 'payload';
|
||||
/**
|
||||
* Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)
|
||||
* while preserving the extension. Keeps uploaded media URLs clean and portable.
|
||||
*
|
||||
* "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||
* "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||
* "already-clean.webp" → "already-clean.webp"
|
||||
*
|
||||
* Why not toSlug(): toSlug uses strict:true, which would strip the dot and
|
||||
* merge name+extension. Here we split on the LAST dot, slug the stem, lowercase
|
||||
* the extension, and rejoin.
|
||||
*/
|
||||
export declare function normalizeFilename(filename: string): string;
|
||||
/**
|
||||
* beforeOperation hook for an upload collection (e.g. Media). Rewrites the
|
||||
* incoming file's name to its normalized form before Payload stores it, so both
|
||||
* the stored file and its DB filename are clean. Works with local disk and with
|
||||
* cloud storage adapters (R2/S3) — it runs before the storage layer.
|
||||
*
|
||||
* Wire into your Media collection:
|
||||
* import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||
* hooks: { beforeOperation: [normalizeFilenameHook] }
|
||||
*/
|
||||
export declare const normalizeFilenameHook: CollectionBeforeOperationHook;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import slugify from 'slugify';
|
||||
/**
|
||||
* Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)
|
||||
* while preserving the extension. Keeps uploaded media URLs clean and portable.
|
||||
*
|
||||
* "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||
* "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||
* "already-clean.webp" → "already-clean.webp"
|
||||
*
|
||||
* Why not toSlug(): toSlug uses strict:true, which would strip the dot and
|
||||
* merge name+extension. Here we split on the LAST dot, slug the stem, lowercase
|
||||
* the extension, and rejoin.
|
||||
*/ export function normalizeFilename(filename) {
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
// No extension (or leading-dot dotfile) → slug the whole thing.
|
||||
if (lastDot <= 0) {
|
||||
return slugify(filename, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
trim: true
|
||||
});
|
||||
}
|
||||
const stem = filename.slice(0, lastDot);
|
||||
const ext = filename.slice(lastDot + 1).toLowerCase();
|
||||
const cleanStem = slugify(stem, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
trim: true
|
||||
});
|
||||
const cleanExt = slugify(ext, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
trim: true
|
||||
});
|
||||
// Stem could slug to empty (e.g. filename was all symbols) — fall back so we
|
||||
// never produce a nameless file.
|
||||
const safeStem = cleanStem || 'plik';
|
||||
return cleanExt ? `${safeStem}.${cleanExt}` : safeStem;
|
||||
}
|
||||
/**
|
||||
* beforeOperation hook for an upload collection (e.g. Media). Rewrites the
|
||||
* incoming file's name to its normalized form before Payload stores it, so both
|
||||
* the stored file and its DB filename are clean. Works with local disk and with
|
||||
* cloud storage adapters (R2/S3) — it runs before the storage layer.
|
||||
*
|
||||
* Wire into your Media collection:
|
||||
* import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||
* hooks: { beforeOperation: [normalizeFilenameHook] }
|
||||
*/ export const normalizeFilenameHook = ({ req, operation })=>{
|
||||
if (operation !== 'create' && operation !== 'update') return;
|
||||
const file = req.file;
|
||||
if (file?.name) {
|
||||
file.name = normalizeFilename(file.name);
|
||||
}
|
||||
};
|
||||
|
||||
//# sourceMappingURL=normalizeFilename.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/media/normalizeFilename.ts"],"sourcesContent":["import type { CollectionBeforeOperationHook } from 'payload'\nimport slugify from 'slugify'\n\n/**\n * Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)\n * while preserving the extension. Keeps uploaded media URLs clean and portable.\n *\n * \"Zdjęcie jeden nad morzem.jpg\" → \"zdjecie-jeden-nad-morzem.jpg\"\n * \"Faktura #12 (2024).PDF\" → \"faktura-12-2024.pdf\"\n * \"already-clean.webp\" → \"already-clean.webp\"\n *\n * Why not toSlug(): toSlug uses strict:true, which would strip the dot and\n * merge name+extension. Here we split on the LAST dot, slug the stem, lowercase\n * the extension, and rejoin.\n */\nexport function normalizeFilename(filename: string): string {\n const lastDot = filename.lastIndexOf('.')\n\n // No extension (or leading-dot dotfile) → slug the whole thing.\n if (lastDot <= 0) {\n return slugify(filename, { lower: true, strict: true, trim: true })\n }\n\n const stem = filename.slice(0, lastDot)\n const ext = filename.slice(lastDot + 1).toLowerCase()\n\n const cleanStem = slugify(stem, { lower: true, strict: true, trim: true })\n const cleanExt = slugify(ext, { lower: true, strict: true, trim: true })\n\n // Stem could slug to empty (e.g. filename was all symbols) — fall back so we\n // never produce a nameless file.\n const safeStem = cleanStem || 'plik'\n\n return cleanExt ? `${safeStem}.${cleanExt}` : safeStem\n}\n\n/**\n * beforeOperation hook for an upload collection (e.g. Media). Rewrites the\n * incoming file's name to its normalized form before Payload stores it, so both\n * the stored file and its DB filename are clean. Works with local disk and with\n * cloud storage adapters (R2/S3) — it runs before the storage layer.\n *\n * Wire into your Media collection:\n * import { normalizeFilenameHook } from '@intecion/ipal-kit'\n * hooks: { beforeOperation: [normalizeFilenameHook] }\n */\nexport const normalizeFilenameHook: CollectionBeforeOperationHook = ({ req, operation }) => {\n if (operation !== 'create' && operation !== 'update') return\n const file = req.file\n if (file?.name) {\n file.name = normalizeFilename(file.name)\n }\n}\n"],"names":["slugify","normalizeFilename","filename","lastDot","lastIndexOf","lower","strict","trim","stem","slice","ext","toLowerCase","cleanStem","cleanExt","safeStem","normalizeFilenameHook","req","operation","file","name"],"mappings":"AACA,OAAOA,aAAa,UAAS;AAE7B;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBAAkBC,QAAgB;IAChD,MAAMC,UAAUD,SAASE,WAAW,CAAC;IAErC,gEAAgE;IAChE,IAAID,WAAW,GAAG;QAChB,OAAOH,QAAQE,UAAU;YAAEG,OAAO;YAAMC,QAAQ;YAAMC,MAAM;QAAK;IACnE;IAEA,MAAMC,OAAON,SAASO,KAAK,CAAC,GAAGN;IAC/B,MAAMO,MAAMR,SAASO,KAAK,CAACN,UAAU,GAAGQ,WAAW;IAEnD,MAAMC,YAAYZ,QAAQQ,MAAM;QAAEH,OAAO;QAAMC,QAAQ;QAAMC,MAAM;IAAK;IACxE,MAAMM,WAAWb,QAAQU,KAAK;QAAEL,OAAO;QAAMC,QAAQ;QAAMC,MAAM;IAAK;IAEtE,6EAA6E;IAC7E,iCAAiC;IACjC,MAAMO,WAAWF,aAAa;IAE9B,OAAOC,WAAW,GAAGC,SAAS,CAAC,EAAED,UAAU,GAAGC;AAChD;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMC,wBAAuD,CAAC,EAAEC,GAAG,EAAEC,SAAS,EAAE;IACrF,IAAIA,cAAc,YAAYA,cAAc,UAAU;IACtD,MAAMC,OAAOF,IAAIE,IAAI;IACrB,IAAIA,MAAMC,MAAM;QACdD,KAAKC,IAAI,GAAGlB,kBAAkBiB,KAAKC,IAAI;IACzC;AACF,EAAC"}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { NotificationTexts } from './types.js';
|
||||
/**
|
||||
* Built-in English fallbacks, used per field when the Notifications global
|
||||
* leaves a text empty. Same philosophy as consent FALLBACK: the site works out
|
||||
* of the box, editors override per locale as needed.
|
||||
*/
|
||||
export declare const NOTIFICATION_FALLBACK: NotificationTexts;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Built-in English fallbacks, used per field when the Notifications global
|
||||
* leaves a text empty. Same philosophy as consent FALLBACK: the site works out
|
||||
* of the box, editors override per locale as needed.
|
||||
*/ export const NOTIFICATION_FALLBACK = {
|
||||
form: {
|
||||
success: 'Thank you — your message has been sent.',
|
||||
error: 'Something went wrong. Please try again later.',
|
||||
rateLimited: 'Too many attempts. Please wait a moment and try again.',
|
||||
turnstile: 'Captcha verification failed. Please try again.',
|
||||
validation: 'Please check the {field} field and try again.',
|
||||
consent: 'Please accept the privacy policy to continue.',
|
||||
notFound: 'This form is no longer available.'
|
||||
}
|
||||
};
|
||||
|
||||
//# sourceMappingURL=defaults.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/defaults.ts"],"sourcesContent":["import type { NotificationTexts } from './types.js'\n\n/**\n * Built-in English fallbacks, used per field when the Notifications global\n * leaves a text empty. Same philosophy as consent FALLBACK: the site works out\n * of the box, editors override per locale as needed.\n */\nexport const NOTIFICATION_FALLBACK: NotificationTexts = {\n form: {\n success: 'Thank you — your message has been sent.',\n error: 'Something went wrong. Please try again later.',\n rateLimited: 'Too many attempts. Please wait a moment and try again.',\n turnstile: 'Captcha verification failed. Please try again.',\n validation: 'Please check the {field} field and try again.',\n consent: 'Please accept the privacy policy to continue.',\n notFound: 'This form is no longer available.',\n },\n}\n"],"names":["NOTIFICATION_FALLBACK","form","success","error","rateLimited","turnstile","validation","consent","notFound"],"mappings":"AAEA;;;;CAIC,GACD,OAAO,MAAMA,wBAA2C;IACtDC,MAAM;QACJC,SAAS;QACTC,OAAO;QACPC,aAAa;QACbC,WAAW;QACXC,YAAY;QACZC,SAAS;QACTC,UAAU;IACZ;AACF,EAAC"}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { BasePayload } from 'payload';
|
||||
import type { NotificationTexts } from './types.js';
|
||||
type GetNotificationTextsArgs = {
|
||||
/** Active locale — selects the language variant of each text. */
|
||||
locale?: string;
|
||||
payload: BasePayload;
|
||||
};
|
||||
/**
|
||||
* Resolves notification texts from the Notifications global, falling back to
|
||||
* English defaults per field. Mirrors getConsentTexts: one read, per-field
|
||||
* fallback, locale-aware. The frontend maps a submitForm result code to the
|
||||
* matching text and styles it however it likes (toast, inline, banner).
|
||||
*/
|
||||
export declare function getNotificationTexts({ locale, payload, }: GetNotificationTextsArgs): Promise<NotificationTexts>;
|
||||
export {};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getGlobal } from '../payload/index.js';
|
||||
import { NOTIFICATION_FALLBACK } from './defaults.js';
|
||||
/**
|
||||
* Resolves notification texts from the Notifications global, falling back to
|
||||
* English defaults per field. Mirrors getConsentTexts: one read, per-field
|
||||
* fallback, locale-aware. The frontend maps a submitForm result code to the
|
||||
* matching text and styles it however it likes (toast, inline, banner).
|
||||
*/ export async function getNotificationTexts({ locale, payload }) {
|
||||
const g = await getGlobal(payload, 'notifications', {
|
||||
locale
|
||||
});
|
||||
const f = g.form ?? {};
|
||||
const fb = NOTIFICATION_FALLBACK.form;
|
||||
return {
|
||||
form: {
|
||||
consent: f.consent || fb.consent,
|
||||
error: f.error || fb.error,
|
||||
notFound: f.notFound || fb.notFound,
|
||||
rateLimited: f.rateLimited || fb.rateLimited,
|
||||
success: f.success || fb.success,
|
||||
turnstile: f.turnstile || fb.turnstile,
|
||||
validation: f.validation || fb.validation
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=getNotificationTexts.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/getNotificationTexts.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { NotificationsData, NotificationTexts } from './types.js'\n\nimport { getGlobal } from '../payload/index.js'\nimport { NOTIFICATION_FALLBACK } from './defaults.js'\n\ntype GetNotificationTextsArgs = {\n /** Active locale — selects the language variant of each text. */\n locale?: string\n payload: BasePayload\n}\n\n/**\n * Resolves notification texts from the Notifications global, falling back to\n * English defaults per field. Mirrors getConsentTexts: one read, per-field\n * fallback, locale-aware. The frontend maps a submitForm result code to the\n * matching text and styles it however it likes (toast, inline, banner).\n */\nexport async function getNotificationTexts({\n locale,\n payload,\n}: GetNotificationTextsArgs): Promise<NotificationTexts> {\n const g = await getGlobal<NotificationsData>(payload, 'notifications', { locale })\n\n const f = g.form ?? {}\n const fb = NOTIFICATION_FALLBACK.form\n\n return {\n form: {\n consent: f.consent || fb.consent,\n error: f.error || fb.error,\n notFound: f.notFound || fb.notFound,\n rateLimited: f.rateLimited || fb.rateLimited,\n success: f.success || fb.success,\n turnstile: f.turnstile || fb.turnstile,\n validation: f.validation || fb.validation,\n },\n }\n}\n"],"names":["getGlobal","NOTIFICATION_FALLBACK","getNotificationTexts","locale","payload","g","f","form","fb","consent","error","notFound","rateLimited","success","turnstile","validation"],"mappings":"AAIA,SAASA,SAAS,QAAQ,sBAAqB;AAC/C,SAASC,qBAAqB,QAAQ,gBAAe;AAQrD;;;;;CAKC,GACD,OAAO,eAAeC,qBAAqB,EACzCC,MAAM,EACNC,OAAO,EACkB;IACzB,MAAMC,IAAI,MAAML,UAA6BI,SAAS,iBAAiB;QAAED;IAAO;IAEhF,MAAMG,IAAID,EAAEE,IAAI,IAAI,CAAC;IACrB,MAAMC,KAAKP,sBAAsBM,IAAI;IAErC,OAAO;QACLA,MAAM;YACJE,SAASH,EAAEG,OAAO,IAAID,GAAGC,OAAO;YAChCC,OAAOJ,EAAEI,KAAK,IAAIF,GAAGE,KAAK;YAC1BC,UAAUL,EAAEK,QAAQ,IAAIH,GAAGG,QAAQ;YACnCC,aAAaN,EAAEM,WAAW,IAAIJ,GAAGI,WAAW;YAC5CC,SAASP,EAAEO,OAAO,IAAIL,GAAGK,OAAO;YAChCC,WAAWR,EAAEQ,SAAS,IAAIN,GAAGM,SAAS;YACtCC,YAAYT,EAAES,UAAU,IAAIP,GAAGO,UAAU;QAC3C;IACF;AACF"}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export { NOTIFICATION_FALLBACK } from './defaults.js';
|
||||
export { getNotificationTexts } from './getNotificationTexts.js';
|
||||
export { resolveFormMessage } from './resolveFormMessage.js';
|
||||
export type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js';
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
export { NOTIFICATION_FALLBACK } from './defaults.js';
|
||||
export { getNotificationTexts } from './getNotificationTexts.js';
|
||||
export { resolveFormMessage } from './resolveFormMessage.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/index.ts"],"sourcesContent":["export { NOTIFICATION_FALLBACK } from './defaults.js'\nexport { getNotificationTexts } from './getNotificationTexts.js'\nexport { resolveFormMessage } from './resolveFormMessage.js'\nexport type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js'\n"],"names":["NOTIFICATION_FALLBACK","getNotificationTexts","resolveFormMessage"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,gBAAe;AACrD,SAASC,oBAAoB,QAAQ,4BAA2B;AAChE,SAASC,kBAAkB,QAAQ,0BAAyB"}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SubmitFormResult } from '../forms/index.js';
|
||||
import type { FormNotificationTexts } from './types.js';
|
||||
/**
|
||||
* Maps a submitForm result to the user-facing message, interpolating {field}
|
||||
* for validation errors. This is the bridge the frontend uses: it gets a result
|
||||
* code from submitForm and the resolved texts from getNotificationTexts, and
|
||||
* this turns them into one string to display. Keeping the mapping here means the
|
||||
* frontend never hard-codes messages or knows about result codes.
|
||||
*
|
||||
* Never surfaces raw backend/exception detail — 'error' maps to a friendly
|
||||
* generic message, not the thrown error's text (which could leak internals).
|
||||
*/
|
||||
export declare function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string;
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Maps a submitForm result to the user-facing message, interpolating {field}
|
||||
* for validation errors. This is the bridge the frontend uses: it gets a result
|
||||
* code from submitForm and the resolved texts from getNotificationTexts, and
|
||||
* this turns them into one string to display. Keeping the mapping here means the
|
||||
* frontend never hard-codes messages or knows about result codes.
|
||||
*
|
||||
* Never surfaces raw backend/exception detail — 'error' maps to a friendly
|
||||
* generic message, not the thrown error's text (which could leak internals).
|
||||
*/ export function resolveFormMessage(result, texts) {
|
||||
if (result.success) {
|
||||
return texts.success;
|
||||
}
|
||||
switch(result.reason){
|
||||
case 'consent':
|
||||
return texts.consent;
|
||||
case 'not_found':
|
||||
return texts.notFound;
|
||||
case 'rate_limited':
|
||||
return texts.rateLimited;
|
||||
case 'turnstile':
|
||||
return texts.turnstile;
|
||||
case 'validation':
|
||||
{
|
||||
// Interpolate {field} with the offending field name when present.
|
||||
const field = 'field' in result && result.field ? result.field : '';
|
||||
return texts.validation.replace('{field}', field);
|
||||
}
|
||||
case 'error':
|
||||
default:
|
||||
return texts.error;
|
||||
}
|
||||
}
|
||||
|
||||
//# sourceMappingURL=resolveFormMessage.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/resolveFormMessage.ts"],"sourcesContent":["import type { SubmitFormResult } from '../forms/index.js'\nimport type { FormNotificationTexts } from './types.js'\n\n/**\n * Maps a submitForm result to the user-facing message, interpolating {field}\n * for validation errors. This is the bridge the frontend uses: it gets a result\n * code from submitForm and the resolved texts from getNotificationTexts, and\n * this turns them into one string to display. Keeping the mapping here means the\n * frontend never hard-codes messages or knows about result codes.\n *\n * Never surfaces raw backend/exception detail — 'error' maps to a friendly\n * generic message, not the thrown error's text (which could leak internals).\n */\nexport function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string {\n if (result.success) {return texts.success}\n\n switch (result.reason) {\n case 'consent':\n return texts.consent\n case 'not_found':\n return texts.notFound\n case 'rate_limited':\n return texts.rateLimited\n case 'turnstile':\n return texts.turnstile\n case 'validation': {\n // Interpolate {field} with the offending field name when present.\n const field = 'field' in result && result.field ? result.field : ''\n return texts.validation.replace('{field}', field)\n }\n case 'error':\n default:\n return texts.error\n }\n}\n"],"names":["resolveFormMessage","result","texts","success","reason","consent","notFound","rateLimited","turnstile","field","validation","replace","error"],"mappings":"AAGA;;;;;;;;;CASC,GACD,OAAO,SAASA,mBAAmBC,MAAwB,EAAEC,KAA4B;IACvF,IAAID,OAAOE,OAAO,EAAE;QAAC,OAAOD,MAAMC,OAAO;IAAA;IAEzC,OAAQF,OAAOG,MAAM;QACnB,KAAK;YACH,OAAOF,MAAMG,OAAO;QACtB,KAAK;YACH,OAAOH,MAAMI,QAAQ;QACvB,KAAK;YACH,OAAOJ,MAAMK,WAAW;QAC1B,KAAK;YACH,OAAOL,MAAMM,SAAS;QACxB,KAAK;YAAc;gBACjB,kEAAkE;gBAClE,MAAMC,QAAQ,WAAWR,UAAUA,OAAOQ,KAAK,GAAGR,OAAOQ,KAAK,GAAG;gBACjE,OAAOP,MAAMQ,UAAU,CAACC,OAAO,CAAC,WAAWF;YAC7C;QACA,KAAK;QACL;YACE,OAAOP,MAAMU,KAAK;IACtB;AACF"}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Resolved notification texts, ready for the frontend. Grouped per context;
|
||||
* `form` maps submitForm result codes to user-facing messages.
|
||||
*/
|
||||
export type FormNotificationTexts = {
|
||||
success: string;
|
||||
error: string;
|
||||
rateLimited: string;
|
||||
turnstile: string;
|
||||
/** May contain the {field} placeholder — resolve with resolveValidationText. */
|
||||
validation: string;
|
||||
/** Shown when a required GDPR consent checkbox was left unchecked. */
|
||||
consent: string;
|
||||
notFound: string;
|
||||
};
|
||||
export type NotificationTexts = {
|
||||
form: FormNotificationTexts;
|
||||
};
|
||||
/** Raw shape read from the Notifications global (all fields optional). */
|
||||
export type NotificationsData = {
|
||||
form?: Partial<FormNotificationTexts>;
|
||||
};
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Resolved notification texts, ready for the frontend. Grouped per context;
|
||||
* `form` maps submitForm result codes to user-facing messages.
|
||||
*/ /** Raw shape read from the Notifications global (all fields optional). */ export { };
|
||||
|
||||
//# sourceMappingURL=types.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/types.ts"],"sourcesContent":["/**\n * Resolved notification texts, ready for the frontend. Grouped per context;\n * `form` maps submitForm result codes to user-facing messages.\n */\nexport type FormNotificationTexts = {\n success: string\n error: string\n rateLimited: string\n turnstile: string\n /** May contain the {field} placeholder — resolve with resolveValidationText. */\n validation: string\n /** Shown when a required GDPR consent checkbox was left unchecked. */\n consent: string\n notFound: string\n}\n\nexport type NotificationTexts = {\n form: FormNotificationTexts\n}\n\n/** Raw shape read from the Notifications global (all fields optional). */\nexport type NotificationsData = {\n form?: Partial<FormNotificationTexts>\n}\n"],"names":[],"mappings":"AAAA;;;CAGC,GAiBD,wEAAwE,GACxE,WAEC"}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* A single HTTP header, in the shape Next.js next.config headers() expects.
|
||||
*/
|
||||
export type SecurityHeader = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
export type BuildSecurityHeadersArgs = {
|
||||
/**
|
||||
* Extra headers to append or override. Same-key entries replace the default,
|
||||
* so you can e.g. add your project's Content-Security-Policy here — CSP is
|
||||
* intentionally NOT a default because it depends on the project's own
|
||||
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
|
||||
*/
|
||||
additional?: SecurityHeader[];
|
||||
/**
|
||||
* X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'
|
||||
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
|
||||
* modern browsers, but X-Frame-Options is kept for older ones. Set to null
|
||||
* to omit (e.g. if you set frame-ancestors in your project CSP).
|
||||
*/
|
||||
frameOptions?: 'DENY' | 'SAMEORIGIN' | null;
|
||||
/**
|
||||
* Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and
|
||||
* tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF
|
||||
* in local/dev over plain HTTP, or you may lock the browser to https on
|
||||
* localhost. Set the env guard in your next.config (see docs).
|
||||
*/
|
||||
hsts?: boolean;
|
||||
/** Add includeSubDomains to HSTS. Default true. */
|
||||
hstsIncludeSubDomains?: boolean;
|
||||
/** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */
|
||||
hstsMaxAge?: number;
|
||||
/** Add preload to HSTS (only if you'll submit to the preload list). Default false. */
|
||||
hstsPreload?: boolean;
|
||||
/**
|
||||
* Permissions-Policy. Default disables camera, microphone, geolocation. Pass
|
||||
* your own string to override, or null to omit.
|
||||
*/
|
||||
permissionsPolicy?: null | string;
|
||||
/** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */
|
||||
referrerPolicy?: null | string;
|
||||
};
|
||||
/**
|
||||
* Builds the generic, project-independent security headers every site should
|
||||
* send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
|
||||
* Permissions-Policy. These are identical across projects, so the plugin owns
|
||||
* the boilerplate; the client spreads the result into next.config's headers().
|
||||
*
|
||||
* Content-Security-Policy is deliberately excluded: a useful CSP enumerates the
|
||||
* exact domains a project loads from (its CDN, analytics, embeds), so it can't
|
||||
* be generic without being either too loose (useless) or too strict (breaks the
|
||||
* site). Add your project's CSP via `additional`.
|
||||
*
|
||||
* @example
|
||||
* // next.config.ts
|
||||
* import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
* const securityHeaders = buildSecurityHeaders({
|
||||
* hsts: process.env.NODE_ENV === 'production', // off in dev over http
|
||||
* additional: [
|
||||
* { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
|
||||
* ],
|
||||
* })
|
||||
* const nextConfig = {
|
||||
* async headers() {
|
||||
* return [{ source: '/:path*', headers: securityHeaders }]
|
||||
* },
|
||||
* }
|
||||
*/
|
||||
export declare function buildSecurityHeaders(args?: BuildSecurityHeadersArgs): SecurityHeader[];
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* A single HTTP header, in the shape Next.js next.config headers() expects.
|
||||
*/ /**
|
||||
* Builds the generic, project-independent security headers every site should
|
||||
* send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
|
||||
* Permissions-Policy. These are identical across projects, so the plugin owns
|
||||
* the boilerplate; the client spreads the result into next.config's headers().
|
||||
*
|
||||
* Content-Security-Policy is deliberately excluded: a useful CSP enumerates the
|
||||
* exact domains a project loads from (its CDN, analytics, embeds), so it can't
|
||||
* be generic without being either too loose (useless) or too strict (breaks the
|
||||
* site). Add your project's CSP via `additional`.
|
||||
*
|
||||
* @example
|
||||
* // next.config.ts
|
||||
* import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
* const securityHeaders = buildSecurityHeaders({
|
||||
* hsts: process.env.NODE_ENV === 'production', // off in dev over http
|
||||
* additional: [
|
||||
* { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
|
||||
* ],
|
||||
* })
|
||||
* const nextConfig = {
|
||||
* async headers() {
|
||||
* return [{ source: '/:path*', headers: securityHeaders }]
|
||||
* },
|
||||
* }
|
||||
*/ export function buildSecurityHeaders(args = {}) {
|
||||
const { additional = [], frameOptions = 'DENY', hsts = true, hstsIncludeSubDomains = true, hstsMaxAge = 63072000, hstsPreload = false, permissionsPolicy = 'camera=(), microphone=(), geolocation=()', referrerPolicy = 'strict-origin-when-cross-origin' } = args;
|
||||
const headers = [];
|
||||
if (hsts) {
|
||||
const parts = [
|
||||
`max-age=${hstsMaxAge}`
|
||||
];
|
||||
if (hstsIncludeSubDomains) {
|
||||
parts.push('includeSubDomains');
|
||||
}
|
||||
if (hstsPreload) {
|
||||
parts.push('preload');
|
||||
}
|
||||
headers.push({
|
||||
key: 'Strict-Transport-Security',
|
||||
value: parts.join('; ')
|
||||
});
|
||||
}
|
||||
if (frameOptions) {
|
||||
headers.push({
|
||||
key: 'X-Frame-Options',
|
||||
value: frameOptions
|
||||
});
|
||||
}
|
||||
// Prevents MIME-type sniffing — always safe, no project specifics.
|
||||
headers.push({
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff'
|
||||
});
|
||||
if (referrerPolicy) {
|
||||
headers.push({
|
||||
key: 'Referrer-Policy',
|
||||
value: referrerPolicy
|
||||
});
|
||||
}
|
||||
if (permissionsPolicy) {
|
||||
headers.push({
|
||||
key: 'Permissions-Policy',
|
||||
value: permissionsPolicy
|
||||
});
|
||||
}
|
||||
// Merge additional: same-key entries override the defaults above.
|
||||
for (const extra of additional){
|
||||
const i = headers.findIndex((h)=>h.key.toLowerCase() === extra.key.toLowerCase());
|
||||
if (i >= 0) {
|
||||
headers[i] = extra;
|
||||
} else {
|
||||
headers.push(extra);
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildSecurityHeaders.js.map
|
||||
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/security/index.ts"],"sourcesContent":["export { buildSecurityHeaders } from './buildSecurityHeaders.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'\n"],"names":["buildSecurityHeaders"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,4BAA2B"}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
type Crumb = {
|
||||
/** Visible name of the breadcrumb (e.g. 'Usługi'). */
|
||||
name: string;
|
||||
/** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
|
||||
* hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
|
||||
* and uses them to understand structure — a signal that helps navigational
|
||||
* results and sitelinks.
|
||||
*
|
||||
* Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
|
||||
* PER-PAGE — build them from the page's ancestry and emit on that page:
|
||||
*
|
||||
* import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildBreadcrumbJsonLd([
|
||||
* { name: 'Strona główna', url: `${base}/pl` },
|
||||
* { name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||
* { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||
* ])
|
||||
* <script type="application/ld+json" ... />
|
||||
*
|
||||
* The crumb data comes from the page's real position (parent pages / URL path),
|
||||
* NOT hardcoded. Derive it from the resolved route, not a static list.
|
||||
*
|
||||
* Returns null for an empty/single crumb list — a one-item breadcrumb isn't
|
||||
* meaningful and shouldn't be emitted.
|
||||
*/
|
||||
export declare function buildBreadcrumbJsonLd(crumbs: Crumb[]): {
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
itemListElement: {
|
||||
name: string;
|
||||
'@type': string;
|
||||
item: string;
|
||||
position: number;
|
||||
}[];
|
||||
} | null;
|
||||
export {};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
|
||||
* hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
|
||||
* and uses them to understand structure — a signal that helps navigational
|
||||
* results and sitelinks.
|
||||
*
|
||||
* Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
|
||||
* PER-PAGE — build them from the page's ancestry and emit on that page:
|
||||
*
|
||||
* import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildBreadcrumbJsonLd([
|
||||
* { name: 'Strona główna', url: `${base}/pl` },
|
||||
* { name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||
* { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||
* ])
|
||||
* <script type="application/ld+json" ... />
|
||||
*
|
||||
* The crumb data comes from the page's real position (parent pages / URL path),
|
||||
* NOT hardcoded. Derive it from the resolved route, not a static list.
|
||||
*
|
||||
* Returns null for an empty/single crumb list — a one-item breadcrumb isn't
|
||||
* meaningful and shouldn't be emitted.
|
||||
*/ export function buildBreadcrumbJsonLd(crumbs) {
|
||||
if (!crumbs || crumbs.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: crumbs.map((crumb, index)=>({
|
||||
name: crumb.name,
|
||||
'@type': 'ListItem',
|
||||
item: crumb.url,
|
||||
position: index + 1
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildBreadcrumbJsonLd.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildBreadcrumbJsonLd.ts"],"sourcesContent":["type Crumb = {\n /** Visible name of the breadcrumb (e.g. 'Usługi'). */\n name: string\n /** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */\n url: string\n}\n\n/**\n * Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site\n * hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)\n * and uses them to understand structure — a signal that helps navigational\n * results and sitelinks.\n *\n * Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are\n * PER-PAGE — build them from the page's ancestry and emit on that page:\n *\n * import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildBreadcrumbJsonLd([\n * { name: 'Strona główna', url: `${base}/pl` },\n * { name: 'Usługi', url: `${base}/pl/uslugi` },\n * { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },\n * ])\n * <script type=\"application/ld+json\" ... />\n *\n * The crumb data comes from the page's real position (parent pages / URL path),\n * NOT hardcoded. Derive it from the resolved route, not a static list.\n *\n * Returns null for an empty/single crumb list — a one-item breadcrumb isn't\n * meaningful and shouldn't be emitted.\n */\nexport function buildBreadcrumbJsonLd(crumbs: Crumb[]) {\n if (!crumbs || crumbs.length < 2) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: crumbs.map((crumb, index) => ({\n name: crumb.name,\n '@type': 'ListItem',\n item: crumb.url,\n position: index + 1,\n })),\n }\n}\n"],"names":["buildBreadcrumbJsonLd","crumbs","length","itemListElement","map","crumb","index","name","item","url","position"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASA,sBAAsBC,MAAe;IACnD,IAAI,CAACA,UAAUA,OAAOC,MAAM,GAAG,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,iBAAiBF,OAAOG,GAAG,CAAC,CAACC,OAAOC,QAAW,CAAA;gBAC7CC,MAAMF,MAAME,IAAI;gBAChB,SAAS;gBACTC,MAAMH,MAAMI,GAAG;gBACfC,UAAUJ,QAAQ;YACpB,CAAA;IACF;AACF"}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
type FaqItem = {
|
||||
answer: string;
|
||||
question: string;
|
||||
};
|
||||
/**
|
||||
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||
* with voice/AI answers. Strong for service landing pages.
|
||||
*
|
||||
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||
* the panel) — the structured data must match visible content, or Google may
|
||||
* flag it. Never invent Q&A that isn't on the page.
|
||||
*
|
||||
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildFaqJsonLd(
|
||||
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||
* )
|
||||
*
|
||||
* Returns null for empty list.
|
||||
*/
|
||||
export declare function buildFaqJsonLd(items: FaqItem[]): {
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
mainEntity: {
|
||||
name: string;
|
||||
'@type': string;
|
||||
acceptedAnswer: {
|
||||
'@type': string;
|
||||
text: string;
|
||||
};
|
||||
}[];
|
||||
} | null;
|
||||
export {};
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||
* with voice/AI answers. Strong for service landing pages.
|
||||
*
|
||||
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||
* the panel) — the structured data must match visible content, or Google may
|
||||
* flag it. Never invent Q&A that isn't on the page.
|
||||
*
|
||||
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildFaqJsonLd(
|
||||
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||
* )
|
||||
*
|
||||
* Returns null for empty list.
|
||||
*/ export function buildFaqJsonLd(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items.map((item)=>({
|
||||
name: item.question,
|
||||
'@type': 'Question',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer
|
||||
}
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildFaqJsonLd.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildFaqJsonLd.ts"],"sourcesContent":["type FaqItem = {\n answer: string\n question: string\n}\n\n/**\n * Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as\n * expandable FAQ rich results under the page, taking more SERP space and helping\n * with voice/AI answers. Strong for service landing pages.\n *\n * Feed it the SAME questions/answers rendered on the page (from an FAQ block in\n * the panel) — the structured data must match visible content, or Google may\n * flag it. Never invent Q&A that isn't on the page.\n *\n * import { buildFaqJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildFaqJsonLd(\n * faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))\n * )\n *\n * Returns null for empty list.\n */\nexport function buildFaqJsonLd(items: FaqItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'FAQPage',\n mainEntity: items.map((item) => ({\n name: item.question,\n '@type': 'Question',\n acceptedAnswer: {\n '@type': 'Answer',\n text: item.answer,\n },\n })),\n }\n}\n"],"names":["buildFaqJsonLd","items","length","mainEntity","map","item","name","question","acceptedAnswer","text","answer"],"mappings":"AAKA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASA,eAAeC,KAAgB;IAC7C,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,YAAYF,MAAMG,GAAG,CAAC,CAACC,OAAU,CAAA;gBAC/BC,MAAMD,KAAKE,QAAQ;gBACnB,SAAS;gBACTC,gBAAgB;oBACd,SAAS;oBACTC,MAAMJ,KAAKK,MAAM;gBACnB;YACF,CAAA;IACF;AACF"}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from 'next';
|
||||
type MediaLike = {
|
||||
height?: null | number;
|
||||
mimeType?: null | string;
|
||||
url?: null | string;
|
||||
width?: null | number;
|
||||
} | null | undefined;
|
||||
/**
|
||||
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||
*
|
||||
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||
* in search results. This generates the tags correctly, every time, from the
|
||||
* panel field.
|
||||
*
|
||||
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||
* generateMetadata — not per page:
|
||||
*
|
||||
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||
* export async function generateMetadata(): Promise<Metadata> {
|
||||
* const settings = await getSettings(locale)
|
||||
* return buildIconsMetadata(settings.favicon)
|
||||
* }
|
||||
*
|
||||
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||
* will reject.
|
||||
*/
|
||||
export declare function buildIconsMetadata(favicon: MediaLike): Metadata;
|
||||
export {};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||
*
|
||||
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||
* in search results. This generates the tags correctly, every time, from the
|
||||
* panel field.
|
||||
*
|
||||
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||
* generateMetadata — not per page:
|
||||
*
|
||||
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||
* export async function generateMetadata(): Promise<Metadata> {
|
||||
* const settings = await getSettings(locale)
|
||||
* return buildIconsMetadata(settings.favicon)
|
||||
* }
|
||||
*
|
||||
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||
* will reject.
|
||||
*/ export function buildIconsMetadata(favicon) {
|
||||
const url = favicon?.url;
|
||||
if (!url) {
|
||||
return {};
|
||||
}
|
||||
const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg');
|
||||
return {
|
||||
icons: {
|
||||
// Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).
|
||||
icon: isSvg ? [
|
||||
{
|
||||
type: 'image/svg+xml',
|
||||
url
|
||||
}
|
||||
] : [
|
||||
{
|
||||
sizes: 'any',
|
||||
url
|
||||
}
|
||||
],
|
||||
// Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.
|
||||
apple: [
|
||||
{
|
||||
url
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildIconsMetadata.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildIconsMetadata.ts"],"sourcesContent":["import type { Metadata } from 'next'\n\ntype MediaLike =\n | { height?: null | number; mimeType?: null | string; url?: null | string; width?: null | number }\n | null\n | undefined\n\n/**\n * Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's\n * favicon upload, so the browser tab AND Google get a proper <link rel=\"icon\">.\n *\n * Why the plugin must do this (not the project): favicon-in-Google has strict\n * rules — a real <link rel=\"icon\"> in <head>, square, ≥48×48, at a stable URL.\n * Leaving it to each project meant inconsistent hand-rolled tags and no favicon\n * in search results. This generates the tags correctly, every time, from the\n * panel field.\n *\n * Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's\n * generateMetadata — not per page:\n *\n * import { buildIconsMetadata } from '@intecion/ipal-kit'\n * export async function generateMetadata(): Promise<Metadata> {\n * const settings = await getSettings(locale)\n * return buildIconsMetadata(settings.favicon)\n * }\n *\n * Google notes: it caches favicons separately and slowly (days/weeks), and only\n * shows them for icons it deems valid. Warn on too-small icons at upload time\n * (see the media validation hook) so editors don't ship a <48px favicon Google\n * will reject.\n */\nexport function buildIconsMetadata(favicon: MediaLike): Metadata {\n const url = favicon?.url\n if (!url) {return {}}\n\n const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg')\n\n return {\n icons: {\n // Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).\n icon: isSvg ? [{ type: 'image/svg+xml', url }] : [{ sizes: 'any', url }],\n // Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.\n apple: [{ url }],\n },\n }\n}\n"],"names":["buildIconsMetadata","favicon","url","isSvg","mimeType","endsWith","icons","icon","type","sizes","apple"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASA,mBAAmBC,OAAkB;IACnD,MAAMC,MAAMD,SAASC;IACrB,IAAI,CAACA,KAAK;QAAC,OAAO,CAAC;IAAC;IAEpB,MAAMC,QAAQF,SAASG,aAAa,mBAAmBF,IAAIG,QAAQ,CAAC;IAEpE,OAAO;QACLC,OAAO;YACL,sEAAsE;YACtEC,MAAMJ,QAAQ;gBAAC;oBAAEK,MAAM;oBAAiBN;gBAAI;aAAE,GAAG;gBAAC;oBAAEO,OAAO;oBAAOP;gBAAI;aAAE;YACxE,yEAAyE;YACzEQ,OAAO;gBAAC;oBAAER;gBAAI;aAAE;QAClB;IACF;AACF"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user