Files
2026-09-28 16:34:54 +02:00

151 lines
6.9 KiB
JavaScript

import { buildCookieSettings } from './globals/CookieSettings/index.js';
import { buildNotifications } from './globals/Notifications/index.js';
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js';
import { buildSiteSettings } from './globals/SiteSettings/index.js';
import { injectRoles } from './modules/access/index.js';
import { buildArchiveFields } from './modules/content/index.js';
import { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js';
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js';
import { buildSystemPagesFields } from './modules/pages/index.js';
import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js';
/**
* IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3.
*
* @example
* ```ts
* import { ipalKit } from 'ipal-kit'
*
* export default buildConfig({
* plugins: [
* ipalKit({
* i18n: {
* locales: [
* { code: 'pl', label: 'Polski' },
* { code: 'en', label: 'English' },
* ],
* defaultLocale: 'pl',
* },
* access: { authCollection: 'users' },
* }),
* ],
* })
* ```
*/ export const ipalKit = (options)=>{
// Validate eagerly — fail fast before Payload boots
validateI18nConfig(options.i18n);
return async (incomingConfig)=>{
// Early return when disabled — schema stays, behavior off
if (options.enabled === false) {
return incomingConfig;
}
let config = {
...incomingConfig
};
// --- custom admin route (e.g. '/its' instead of '/admin') ---
// Sets config.routes.admin; the project must move its app/(payload)/<route>/
// folder to match (plugin can't create files in the project's app/).
if (options.adminRoute) {
config.routes = {
...config.routes ?? {},
admin: options.adminRoute
};
}
// --- enforced 2FA (TOTP) via @clocklimited/payload-2fa ---
// Enforced by default (forceSetup) unless twoFactor is explicitly false. The
// plugin is a PEER dependency imported dynamically — ipal-kit doesn't bundle
// it, so projects that opt out (twoFactor: false) needn't install it, and the
// import never runs under generate:importmap when 2FA is off. Wrapping access
// control (not just admin UI) means TOTP gates data access — no API bypass.
if (options.twoFactor !== false) {
const tf = options.twoFactor;
if (!tf?.issuer) {
throw new Error('[ipal] twoFactor.issuer is required (name shown in the authenticator ' + 'app). Pass twoFactor: { issuer: "Your Site" }, or twoFactor: false to ' + 'opt out (discouraged).');
}
try {
// Dynamic specifier via a variable so TS doesn't try to resolve this
// optional peer dependency at build time (it isn't in the plugin's own
// node_modules). Avoids TS2307 without @ts-expect-error; the module
// exists at runtime in projects that installed it.
// @ts-ignore
const mod = await import('@clocklimited/payload-2fa');
// The package exports `payloadTotp`; older/other builds may use
// `totpPlugin`. Accept either so a rename doesn't break us.
const totp = mod.payloadTotp ?? mod.totpPlugin;
if (typeof totp !== 'function') {
throw new Error('expected export payloadTotp (or totpPlugin) to be a function — ' + 'check the installed @clocklimited/payload-2fa version');
}
config = await totp({
collection: tf.collectionSlug ?? 'users',
forceSetup: true,
totp: {
issuer: tf.issuer
}
})(config);
} catch (err) {
throw new Error('[ipal] 2FA is enforced but @clocklimited/payload-2fa is not installed. ' + 'Run: pnpm add @clocklimited/payload-2fa — or set twoFactor: false to ' + `opt out (discouraged). Original error: ${String(err)}`);
}
}
// --- i18n ---
config.localization = buildLocalizationConfig(options.i18n);
// --- access: inject roles into the client's auth collection ---
if (options.access) {
config = injectRoles(config, options.access);
}
// --- seo: apply @payloadcms/plugin-seo directly ---
// NOTE: apply the plugin function to the config immediately rather than
// pushing it onto config.plugins. Payload has already iterated the plugins
// array by the time IPAL runs, so nested plugins added to that list are
// never executed. Calling the plugin as (config) => config applies its
// transform now.
if (options.seo) {
config = await buildSeoPlugin({
seo: options.seo
})(config);
// Auto-fill empty meta from document content on save
config = injectAutoFillMeta(config, options.seo);
// Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI,
// which breaks when other fields already exist in the collection)
config = injectSeoTabs(config, options.seo);
}
// --- forms: apply @payloadcms/plugin-form-builder directly ---
if (options.forms) {
config = await buildFormsPlugin(options.forms)(config);
}
// --- globals ---
// The System Pages tab collects every "which page plays this role"
// assignment. Composing it here keeps SiteSettings unaware of which modules
// are enabled — it just renders the fields it's given.
const systemPageFields = [
...options.pages ? buildSystemPagesFields(options.pages) : [],
...options.content && options.pages ? buildArchiveFields(options.content, options.pages.slug) : []
];
config.globals = [
...config.globals ?? [],
buildSiteSettings({
additionalFields: options.siteSettingsFields,
systemPageFields
}),
buildSiteIntegrations({
additionalFields: options.integrationsFields
}),
buildCookieSettings(),
buildNotifications()
];
config.endpoints = [
...config.endpoints ?? [],
testEmailEndpoint
];
// --- hooks: onInit ---
const incomingOnInit = config.onInit;
config.onInit = async (payload)=>{
if (incomingOnInit) {
await incomingOnInit(payload);
}
payload.logger.info('[ipal] Plugin initialized.');
};
return config;
};
};
//# sourceMappingURL=plugin.js.map