diff --git a/dist/modules/seo/assets/sitemap.css b/dist/modules/seo/assets/sitemap.css index d28b43a..4230cee 100644 --- a/dist/modules/seo/assets/sitemap.css +++ b/dist/modules/seo/assets/sitemap.css @@ -1,76 +1,143 @@ /* - * Starter stylesheet for a sitemap served via buildSitemapXml({ cssUrl }). - * Uses type="text/css" on XML (W3C "Associating Style Sheets with XML") — NOT - * XSLT, so no browser deprecation warning. Selectors are the XML tag names. + * Universal, minimalist & elegant stylesheet for XML Sitemap. + * Neutral palette with automatic dark and light mode support. + * W3C standard: type="text/css" on XML (zero browser deprecation warnings). * - * Copy to your project's /public/sitemap.css and adjust colors/spacing to taste. - * Crawlers ignore this; it only affects the human-readable browser view. + * Ships with @intecion/ipal-kit. Copy to /public/sitemap.css and adjust freely. */ +:root { + --bg: #fafafa; + --card: #ffffff; + --border: #e5e7eb; + --border-hover: #d1d5db; + --text-main: #111827; + --text-secondary: #4b5563; + --text-muted: #9ca3af; + --url-color: #1e293b; + --badge-bg: #f3f4f6; + --badge-border: #e5e7eb; + --badge-text: #4b5563; + --accent: #f97316; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #090a0f; + --card: #12131a; + --border: #1e202e; + --border-hover: #2e3247; + --text-main: #f9fafb; + --text-secondary: #9ca3af; + --text-muted: #6b7280; + --url-color: #f3f4f6; + --badge-bg: #1a1c26; + --badge-border: #282b3d; + --badge-text: #9ca3af; + --accent: #fb923c; + } +} + urlset { display: block; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #090d16; - color: #f1f5f9; - padding: 2rem 1.5rem; - max-width: 1200px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Helvetica, Arial, sans-serif; + background-color: var(--bg); + color: var(--text-main); + padding: 3rem 1.5rem; + max-width: 1040px; margin: 0 auto; + min-height: 100vh; + box-sizing: border-box; line-height: 1.5; } -/* Each URL entry as a card. */ +/* Minimalist header */ +urlset::before { + content: "XML Sitemap"; + display: block; + font-size: 1.35rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-main); + padding-bottom: 0.4rem; +} + +/* Brand note under the header — crafted by Intecion Group */ +urlset::after { + content: "Intecion.com, Technology — engineered for modern digital experiences."; + display: block; + font-size: 0.8rem; + color: var(--text-muted); + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +/* URL card */ url { display: block; - background: #111827; - border: 1px solid #1e293b; + background-color: var(--card); + border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.25rem; - margin-bottom: 0.75rem; + margin-bottom: 0.65rem; + box-sizing: border-box; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } -/* The URL itself. */ +url:hover { + border-color: var(--border-hover); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03); +} + +/* URL address */ loc { display: block; - font-size: 0.95rem; - font-weight: 600; - color: #f97316; - margin-bottom: 0.5rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.875rem; + font-weight: 500; + color: var(--url-color); word-break: break-all; + line-height: 1.45; + margin-bottom: 0.45rem; } -/* Metadata line: lastmod / changefreq / priority, each with a label. */ +/* Metadata row */ lastmod, changefreq, priority { display: inline-block; - font-size: 0.8rem; - color: #94a3b8; - margin-right: 1.5rem; + font-size: 0.775rem; + color: var(--text-secondary); + margin-right: 1.25rem; + margin-top: 0.15rem; } lastmod::before { - content: 'Ostatnia modyfikacja: '; - color: #64748b; + content: "Zaktualizowano: "; + color: var(--text-muted); } changefreq::before { - content: 'Częstotliwość: '; - color: #64748b; + content: "Częstotliwość: "; + color: var(--text-muted); } priority::before { - content: 'Priorytet: '; - color: #64748b; + content: "Priorytet: "; + color: var(--text-muted); } -/* hreflang alternates as small pills. */ +/* Alternate language pills */ link { display: inline-block; - font-size: 0.75rem; - background: #1e293b; - color: #38bdf8; - border: 1px solid #334155; - padding: 0.15rem 0.45rem; + font-size: 0.7rem; + font-weight: 500; + background-color: var(--badge-bg); + border: 1px solid var(--badge-border); + color: var(--badge-text); + padding: 0.1rem 0.45rem; border-radius: 4px; - margin: 0.4rem 0.35rem 0 0; + margin-right: 0.3rem; + margin-top: 0.35rem; } \ No newline at end of file diff --git a/dist/plugin.js b/dist/plugin.js index 3222581..cc0d99f 100644 --- a/dist/plugin.js +++ b/dist/plugin.js @@ -42,6 +42,44 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo let config = { ...incomingConfig }; + // --- custom admin route (e.g. '/its' instead of '/admin') --- + // Sets config.routes.admin; the project must move its app/(payload)// + // 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. + const pkg = '@clocklimited/payload-2fa'; + const { totpPlugin } = await import(pkg); + config = await totpPlugin({ + 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 --- @@ -88,10 +126,6 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo buildCookieSettings(), buildNotifications() ]; - // --- endpoints --- - // Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe - // message through the currently selected transport, so the panel's "send - // test" button can confirm delivery without leaving the admin UI. config.endpoints = [ ...config.endpoints ?? [], testEmailEndpoint diff --git a/dist/plugin.js.map b/dist/plugin.js.map index a5c8ada..1efe0ed 100644 --- a/dist/plugin.js.map +++ b/dist/plugin.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Plugin } from 'payload'\n\nimport type { IpalOptions } from './types.js'\n\nimport { buildCookieSettings } from './globals/CookieSettings/index.js'\nimport { buildNotifications } from './globals/Notifications/index.js'\nimport { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'\nimport { buildSiteSettings } from './globals/SiteSettings/index.js'\nimport { injectRoles } from './modules/access/index.js'\nimport { buildArchiveFields } from './modules/content/index.js'\nimport { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js'\nimport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nimport { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'\nimport { buildSystemPagesFields } from './modules/pages/index.js'\nimport { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js'\n\n/**\n * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3.\n *\n * @example\n * ```ts\n * import { ipalKit } from 'ipal-kit'\n *\n * export default buildConfig({\n * plugins: [\n * ipalKit({\n * i18n: {\n * locales: [\n * { code: 'pl', label: 'Polski' },\n * { code: 'en', label: 'English' },\n * ],\n * defaultLocale: 'pl',\n * },\n * access: { authCollection: 'users' },\n * }),\n * ],\n * })\n * ```\n */\nexport const ipalKit = (options: IpalOptions): Plugin => {\n // Validate eagerly — fail fast before Payload boots\n validateI18nConfig(options.i18n)\n\n return async (incomingConfig: Config): Promise => {\n // Early return when disabled — schema stays, behavior off\n if (options.enabled === false) {\n return incomingConfig\n }\n\n let config = { ...incomingConfig }\n\n // --- i18n ---\n config.localization = buildLocalizationConfig(options.i18n)\n\n // --- access: inject roles into the client's auth collection ---\n if (options.access) {\n config = injectRoles(config, options.access)\n }\n\n // --- seo: apply @payloadcms/plugin-seo directly ---\n // NOTE: apply the plugin function to the config immediately rather than\n // pushing it onto config.plugins. Payload has already iterated the plugins\n // array by the time IPAL runs, so nested plugins added to that list are\n // never executed. Calling the plugin as (config) => config applies its\n // transform now.\n if (options.seo) {\n config = await buildSeoPlugin({ seo: options.seo })(config)\n // Auto-fill empty meta from document content on save\n config = injectAutoFillMeta(config, options.seo)\n // Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI,\n // which breaks when other fields already exist in the collection)\n config = injectSeoTabs(config, options.seo)\n }\n\n // --- forms: apply @payloadcms/plugin-form-builder directly ---\n if (options.forms) {\n config = await buildFormsPlugin(options.forms)(config)\n }\n\n // --- globals ---\n // The System Pages tab collects every \"which page plays this role\"\n // assignment. Composing it here keeps SiteSettings unaware of which modules\n // are enabled — it just renders the fields it's given.\n const systemPageFields = [\n ...(options.pages ? buildSystemPagesFields(options.pages) : []),\n ...(options.content && options.pages\n ? buildArchiveFields(options.content, options.pages.slug)\n : []),\n ]\n\n config.globals = [\n ...(config.globals ?? []),\n buildSiteSettings({\n additionalFields: options.siteSettingsFields,\n systemPageFields,\n }),\n buildSiteIntegrations({ additionalFields: options.integrationsFields }),\n buildCookieSettings(),\n buildNotifications(),\n ]\n\n // --- endpoints ---\n // Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe\n // message through the currently selected transport, so the panel's \"send\n // test\" button can confirm delivery without leaving the admin UI.\n config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint]\n\n // --- hooks: onInit ---\n const incomingOnInit = config.onInit\n config.onInit = async (payload) => {\n if (incomingOnInit) {\n await incomingOnInit(payload)\n }\n payload.logger.info('[ipal] Plugin initialized.')\n }\n\n return config\n }\n}\n"],"names":["buildCookieSettings","buildNotifications","buildSiteIntegrations","buildSiteSettings","injectRoles","buildArchiveFields","testEmailEndpoint","buildFormsPlugin","buildLocalizationConfig","validateI18nConfig","buildSystemPagesFields","buildSeoPlugin","injectAutoFillMeta","injectSeoTabs","ipalKit","options","i18n","incomingConfig","enabled","config","localization","access","seo","forms","systemPageFields","pages","content","slug","globals","additionalFields","siteSettingsFields","integrationsFields","endpoints","incomingOnInit","onInit","payload","logger","info"],"mappings":"AAIA,SAASA,mBAAmB,QAAQ,oCAAmC;AACvE,SAASC,kBAAkB,QAAQ,mCAAkC;AACrE,SAASC,qBAAqB,QAAQ,sCAAqC;AAC3E,SAASC,iBAAiB,QAAQ,kCAAiC;AACnE,SAASC,WAAW,QAAQ,4BAA2B;AACvD,SAASC,kBAAkB,QAAQ,6BAA4B;AAC/D,SAASC,iBAAiB,QAAQ,4CAA2C;AAC7E,SAASC,gBAAgB,QAAQ,uCAAsC;AACvE,SAASC,uBAAuB,EAAEC,kBAAkB,QAAQ,0BAAyB;AACrF,SAASC,sBAAsB,QAAQ,2BAA0B;AACjE,SAASC,cAAc,EAAEC,kBAAkB,EAAEC,aAAa,QAAQ,yBAAwB;AAE1F;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,MAAMC,UAAU,CAACC;IACtB,oDAAoD;IACpDN,mBAAmBM,QAAQC,IAAI;IAE/B,OAAO,OAAOC;QACZ,0DAA0D;QAC1D,IAAIF,QAAQG,OAAO,KAAK,OAAO;YAC7B,OAAOD;QACT;QAEA,IAAIE,SAAS;YAAE,GAAGF,cAAc;QAAC;QAEjC,eAAe;QACfE,OAAOC,YAAY,GAAGZ,wBAAwBO,QAAQC,IAAI;QAE1D,iEAAiE;QACjE,IAAID,QAAQM,MAAM,EAAE;YAClBF,SAASf,YAAYe,QAAQJ,QAAQM,MAAM;QAC7C;QAEA,qDAAqD;QACrD,wEAAwE;QACxE,2EAA2E;QAC3E,wEAAwE;QACxE,uEAAuE;QACvE,iBAAiB;QACjB,IAAIN,QAAQO,GAAG,EAAE;YACfH,SAAS,MAAMR,eAAe;gBAAEW,KAAKP,QAAQO,GAAG;YAAC,GAAGH;YACpD,qDAAqD;YACrDA,SAASP,mBAAmBO,QAAQJ,QAAQO,GAAG;YAC/C,uEAAuE;YACvE,kEAAkE;YAClEH,SAASN,cAAcM,QAAQJ,QAAQO,GAAG;QAC5C;QAEA,gEAAgE;QAChE,IAAIP,QAAQQ,KAAK,EAAE;YACjBJ,SAAS,MAAMZ,iBAAiBQ,QAAQQ,KAAK,EAAEJ;QACjD;QAEA,kBAAkB;QAClB,mEAAmE;QACnE,4EAA4E;QAC5E,uDAAuD;QACvD,MAAMK,mBAAmB;eACnBT,QAAQU,KAAK,GAAGf,uBAAuBK,QAAQU,KAAK,IAAI,EAAE;eAC1DV,QAAQW,OAAO,IAAIX,QAAQU,KAAK,GAChCpB,mBAAmBU,QAAQW,OAAO,EAAEX,QAAQU,KAAK,CAACE,IAAI,IACtD,EAAE;SACP;QAEDR,OAAOS,OAAO,GAAG;eACXT,OAAOS,OAAO,IAAI,EAAE;YACxBzB,kBAAkB;gBAChB0B,kBAAkBd,QAAQe,kBAAkB;gBAC5CN;YACF;YACAtB,sBAAsB;gBAAE2B,kBAAkBd,QAAQgB,kBAAkB;YAAC;YACrE/B;YACAC;SACD;QAED,oBAAoB;QACpB,4EAA4E;QAC5E,yEAAyE;QACzE,kEAAkE;QAClEkB,OAAOa,SAAS,GAAG;eAAKb,OAAOa,SAAS,IAAI,EAAE;YAAG1B;SAAkB;QAEnE,wBAAwB;QACxB,MAAM2B,iBAAiBd,OAAOe,MAAM;QACpCf,OAAOe,MAAM,GAAG,OAAOC;YACrB,IAAIF,gBAAgB;gBAClB,MAAMA,eAAeE;YACvB;YACAA,QAAQC,MAAM,CAACC,IAAI,CAAC;QACtB;QAEA,OAAOlB;IACT;AACF,EAAC"} \ No newline at end of file +{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Plugin } from 'payload'\n\nimport type { IpalOptions } from './types.js'\n\nimport { buildCookieSettings } from './globals/CookieSettings/index.js'\nimport { buildNotifications } from './globals/Notifications/index.js'\nimport { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'\nimport { buildSiteSettings } from './globals/SiteSettings/index.js'\nimport { injectRoles } from './modules/access/index.js'\nimport { buildArchiveFields } from './modules/content/index.js'\nimport { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js'\nimport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nimport { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'\nimport { buildSystemPagesFields } from './modules/pages/index.js'\nimport { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js'\n\n/**\n * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3.\n *\n * @example\n * ```ts\n * import { ipalKit } from 'ipal-kit'\n *\n * export default buildConfig({\n * plugins: [\n * ipalKit({\n * i18n: {\n * locales: [\n * { code: 'pl', label: 'Polski' },\n * { code: 'en', label: 'English' },\n * ],\n * defaultLocale: 'pl',\n * },\n * access: { authCollection: 'users' },\n * }),\n * ],\n * })\n * ```\n */\nexport const ipalKit = (options: IpalOptions): Plugin => {\n // Validate eagerly — fail fast before Payload boots\n validateI18nConfig(options.i18n)\n\n return async (incomingConfig: Config): Promise => {\n // Early return when disabled — schema stays, behavior off\n if (options.enabled === false) {\n return incomingConfig\n }\n\n let config = { ...incomingConfig }\n\n // --- custom admin route (e.g. '/its' instead of '/admin') ---\n // Sets config.routes.admin; the project must move its app/(payload)//\n // folder to match (plugin can't create files in the project's app/).\n if (options.adminRoute) {\n config.routes = { ...(config.routes ?? {}), admin: options.adminRoute }\n }\n\n // --- enforced 2FA (TOTP) via @clocklimited/payload-2fa ---\n // Enforced by default (forceSetup) unless twoFactor is explicitly false. The\n // plugin is a PEER dependency imported dynamically — ipal-kit doesn't bundle\n // it, so projects that opt out (twoFactor: false) needn't install it, and the\n // import never runs under generate:importmap when 2FA is off. Wrapping access\n // control (not just admin UI) means TOTP gates data access — no API bypass.\n if (options.twoFactor !== false) {\n const tf = options.twoFactor\n if (!tf?.issuer) {\n throw new Error(\n '[ipal] twoFactor.issuer is required (name shown in the authenticator ' +\n 'app). Pass twoFactor: { issuer: \"Your Site\" }, or twoFactor: false to ' +\n 'opt out (discouraged).',\n )\n }\n try {\n // Dynamic specifier via a variable so TS doesn't try to resolve this\n // optional peer dependency at build time (it isn't in the plugin's own\n // node_modules). Avoids TS2307 without @ts-expect-error; the module\n // exists at runtime in projects that installed it.\n const pkg = '@clocklimited/payload-2fa'\n const { totpPlugin } = (await import(pkg)) as {\n totpPlugin: (opts: Record) => Plugin\n }\n config = await totpPlugin({\n collection: tf.collectionSlug ?? 'users',\n forceSetup: true, // ENFORCED — every user must set up TOTP; no opt-out\n totp: { issuer: tf.issuer },\n })(config)\n } catch (err) {\n throw new Error(\n '[ipal] 2FA is enforced but @clocklimited/payload-2fa is not installed. ' +\n 'Run: pnpm add @clocklimited/payload-2fa — or set twoFactor: false to ' +\n `opt out (discouraged). Original error: ${String(err)}`,\n )\n }\n }\n\n // --- i18n ---\n config.localization = buildLocalizationConfig(options.i18n)\n\n // --- access: inject roles into the client's auth collection ---\n if (options.access) {\n config = injectRoles(config, options.access)\n }\n\n // --- seo: apply @payloadcms/plugin-seo directly ---\n // NOTE: apply the plugin function to the config immediately rather than\n // pushing it onto config.plugins. Payload has already iterated the plugins\n // array by the time IPAL runs, so nested plugins added to that list are\n // never executed. Calling the plugin as (config) => config applies its\n // transform now.\n if (options.seo) {\n config = await buildSeoPlugin({ seo: options.seo })(config)\n // Auto-fill empty meta from document content on save\n config = injectAutoFillMeta(config, options.seo)\n // Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI,\n // which breaks when other fields already exist in the collection)\n config = injectSeoTabs(config, options.seo)\n }\n\n // --- forms: apply @payloadcms/plugin-form-builder directly ---\n if (options.forms) {\n config = await buildFormsPlugin(options.forms)(config)\n }\n\n // --- globals ---\n // The System Pages tab collects every \"which page plays this role\"\n // assignment. Composing it here keeps SiteSettings unaware of which modules\n // are enabled — it just renders the fields it's given.\n const systemPageFields = [\n ...(options.pages ? buildSystemPagesFields(options.pages) : []),\n ...(options.content && options.pages\n ? buildArchiveFields(options.content, options.pages.slug)\n : []),\n ]\n\n config.globals = [\n ...(config.globals ?? []),\n buildSiteSettings({\n additionalFields: options.siteSettingsFields,\n systemPageFields,\n }),\n buildSiteIntegrations({ additionalFields: options.integrationsFields }),\n buildCookieSettings(),\n buildNotifications(),\n ]\n\n config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint]\n\n // --- hooks: onInit ---\n const incomingOnInit = config.onInit\n config.onInit = async (payload) => {\n if (incomingOnInit) {await incomingOnInit(payload)}\n payload.logger.info('[ipal] Plugin initialized.')\n }\n\n return config\n }\n}\n"],"names":["buildCookieSettings","buildNotifications","buildSiteIntegrations","buildSiteSettings","injectRoles","buildArchiveFields","testEmailEndpoint","buildFormsPlugin","buildLocalizationConfig","validateI18nConfig","buildSystemPagesFields","buildSeoPlugin","injectAutoFillMeta","injectSeoTabs","ipalKit","options","i18n","incomingConfig","enabled","config","adminRoute","routes","admin","twoFactor","tf","issuer","Error","pkg","totpPlugin","collection","collectionSlug","forceSetup","totp","err","String","localization","access","seo","forms","systemPageFields","pages","content","slug","globals","additionalFields","siteSettingsFields","integrationsFields","endpoints","incomingOnInit","onInit","payload","logger","info"],"mappings":"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"} \ No newline at end of file diff --git a/dist/types.d.ts b/dist/types.d.ts index 3287ee5..d36a163 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -15,6 +15,17 @@ export type IpalOptions = { * (admin > editor > user) into the client's auth collection. */ access?: AccessOption; + /** + * Custom admin panel route, e.g. '/its' instead of the default '/admin'. + * The plugin sets config.routes.admin. IMPORTANT: this alone isn't enough — + * the project must ALSO move its panel folder to match: + * app/(payload)/its/[[...segments]]/page.tsx (and the not-found). The plugin + * can't create files in the project's app/. See docs/security.md. + * + * This is obscurity, not security: it hides the panel from dumb bots scanning + * /admin, but real protection is strong auth + 2FA + rate limiting. + */ + adminRoute?: string; /** * Collections whose entries live under an archive page — blog posts, case * studies, anything with a listing. Adds an "archive page" assignment per @@ -45,4 +56,20 @@ export type IpalOptions = { seo?: SeoOption; /** Additional fields injected into SiteSettings global */ siteSettingsFields?: Field[]; + /** + * Two-factor authentication (TOTP), ENFORCED for every user. Wires + * @clocklimited/payload-2fa with forceSetup — no per-project opt-out. Every + * user must configure an authenticator app after login; TOTP is checked before + * data access (not just the admin UI). Requires the peer dep installed and an + * issuer name (shown in the authenticator app). + * + * Pass `false` ONLY for a project that genuinely can't use 2FA (rare, discouraged) + * — default is enforced. See docs/security.md. + */ + twoFactor?: { + /** Auth collection slug. Defaults to 'users'. */ + collectionSlug?: string; + /** Name shown in the authenticator app (e.g. company/site name). */ + issuer: string; + } | false; }; diff --git a/dist/types.js.map b/dist/types.js.map index 7c3b73f..fce4d83 100644 --- a/dist/types.js.map +++ b/dist/types.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AccessOption } from './modules/access/types.js'\nimport type { ContentOption } from './modules/content/types.js'\nimport type { FormsOption } from './modules/forms/types.js'\nimport type { I18nConfig } from './modules/i18n/types.js'\nimport type { PagesOption } from './modules/pages/types.js'\nimport type { SeoOption } from './modules/seo/types.js'\n\n/**\n * Configuration options for the IPAL plugin.\n * Passed by the client project in payload.config.ts.\n */\nexport type IpalOptions = {\n /**\n * Role-based access control. Injects a fixed `roles` field\n * (admin > editor > user) into the client's auth collection.\n */\n access?: AccessOption\n\n /**\n * Collections whose entries live under an archive page — blog posts, case\n * studies, anything with a listing. Adds an \"archive page\" assignment per\n * collection in SiteSettings; the assigned page's localized slug becomes the\n * URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`.\n */\n content?: ContentOption\n\n /** Disable the plugin without uninstalling (keeps DB schema intact) */\n enabled?: boolean\n\n /**\n * Forms — form-builder collections (forms, form-submissions) plus the\n * callable submitForm (Turnstile + persistence + SMTP-from-panel email).\n */\n forms?: FormsOption\n\n /** Internationalization — locales, default locale, fallback behavior */\n i18n: I18nConfig\n\n /** Additional fields injected into SiteIntegrations global */\n integrationsFields?: Field[]\n\n /**\n * System-page assignments (homepage, privacy, cookies) in SiteSettings.\n * Provide the slug of the client's Pages collection to enable.\n */\n pages?: PagesOption\n\n /**\n * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo)\n * and enables locale-aware metadata helpers.\n */\n seo?: SeoOption\n\n /** Additional fields injected into SiteSettings global */\n siteSettingsFields?: Field[]\n}\n"],"names":[],"mappings":"AASA;;;CAGC,GACD,WA4CC"} \ No newline at end of file +{"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AccessOption } from './modules/access/types.js'\nimport type { ContentOption } from './modules/content/types.js'\nimport type { FormsOption } from './modules/forms/types.js'\nimport type { I18nConfig } from './modules/i18n/types.js'\nimport type { PagesOption } from './modules/pages/types.js'\nimport type { SeoOption } from './modules/seo/types.js'\n\n/**\n * Configuration options for the IPAL plugin.\n * Passed by the client project in payload.config.ts.\n */\nexport type IpalOptions = {\n /**\n * Role-based access control. Injects a fixed `roles` field\n * (admin > editor > user) into the client's auth collection.\n */\n access?: AccessOption\n\n /**\n * Custom admin panel route, e.g. '/its' instead of the default '/admin'.\n * The plugin sets config.routes.admin. IMPORTANT: this alone isn't enough —\n * the project must ALSO move its panel folder to match:\n * app/(payload)/its/[[...segments]]/page.tsx (and the not-found). The plugin\n * can't create files in the project's app/. See docs/security.md.\n *\n * This is obscurity, not security: it hides the panel from dumb bots scanning\n * /admin, but real protection is strong auth + 2FA + rate limiting.\n */\n adminRoute?: string\n\n /**\n * Collections whose entries live under an archive page — blog posts, case\n * studies, anything with a listing. Adds an \"archive page\" assignment per\n * collection in SiteSettings; the assigned page's localized slug becomes the\n * URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`.\n */\n content?: ContentOption\n\n /** Disable the plugin without uninstalling (keeps DB schema intact) */\n enabled?: boolean\n\n /**\n * Forms — form-builder collections (forms, form-submissions) plus the\n * callable submitForm (Turnstile + persistence + SMTP-from-panel email).\n */\n forms?: FormsOption\n\n /** Internationalization — locales, default locale, fallback behavior */\n i18n: I18nConfig\n\n /** Additional fields injected into SiteIntegrations global */\n integrationsFields?: Field[]\n\n /**\n * System-page assignments (homepage, privacy, cookies) in SiteSettings.\n * Provide the slug of the client's Pages collection to enable.\n */\n pages?: PagesOption\n\n /**\n * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo)\n * and enables locale-aware metadata helpers.\n */\n seo?: SeoOption\n\n /** Additional fields injected into SiteSettings global */\n siteSettingsFields?: Field[]\n\n /**\n * Two-factor authentication (TOTP), ENFORCED for every user. Wires\n * @clocklimited/payload-2fa with forceSetup — no per-project opt-out. Every\n * user must configure an authenticator app after login; TOTP is checked before\n * data access (not just the admin UI). Requires the peer dep installed and an\n * issuer name (shown in the authenticator app).\n *\n * Pass `false` ONLY for a project that genuinely can't use 2FA (rare, discouraged)\n * — default is enforced. See docs/security.md.\n */\n twoFactor?:\n | {\n /** Auth collection slug. Defaults to 'users'. */\n collectionSlug?: string\n /** Name shown in the authenticator app (e.g. company/site name). */\n issuer: string\n }\n | false\n}\n"],"names":[],"mappings":"AASA;;;CAGC,GACD,WA2EC"} \ No newline at end of file diff --git a/docs/security.md b/docs/security.md index eb19973..ea41e58 100644 --- a/docs/security.md +++ b/docs/security.md @@ -197,4 +197,105 @@ NIE wymuszaj `require-trusted-types-for 'script'`. Powód: przy zewnętrznych skryptach manipulujących DOM stringami (Turnstile, GA) - Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony -Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia. \ No newline at end of file +Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia. + +## Zmiana ścieżki panelu admina (/admin → /its) + +Ukrycie panelu przed botami skanującymi znane ścieżki (`/admin`, `/wp-admin`). +Plugin ustawia ścieżkę przez opcję `adminRoute`: + +```ts +// payload.config.ts +ipalKit({ + i18n: i18nConfig, + adminRoute: '/its', // panel pod /its zamiast /admin +}) +``` + +### WYMAGANE — przenieś folder panelu w projekcie + +Plugin ustawia `config.routes.admin`, ale NIE tworzy plików w `app/` projektu. +Musisz przenieść folder panelu, żeby ścieżka zadziałała: + +``` +# PRZED: +app/(payload)/admin/[[...segments]]/page.tsx +app/(payload)/admin/[[...segments]]/not-found.tsx + +# PO (nazwa folderu = adminRoute bez ukośnika): +app/(payload)/its/[[...segments]]/page.tsx +app/(payload)/its/[[...segments]]/not-found.tsx +``` + +Bez przeniesienia folderu: `config.routes.admin = '/its'`, ale `/its` daje 404 +(brak pliku), a `/admin` też nie działa (config zmieniony). Oba muszą się zgadzać. + +### To OBSCURITY, nie SECURITY + +Zmiana ścieżki utrudnia automatyczne skany, ale NIE jest zabezpieczeniem. +Prawdziwa ochrona panelu: +- **2FA** dla każdego użytkownika (planowane — wymuszenie przez plugin) +- Silne hasła +- Rate limiting na logowaniu +- IP allowlist (jeśli panel tylko dla zespołu) +- buildSecurityHeaders (nagłówki) + +Zmiana `/admin → /its` to warstwa (odsiewa głupie boty), nie zamek. Traktuj jako +dodatek do prawdziwych zabezpieczeń, nie zamiast nich. + +## 2FA (TOTP) — WYMUSZONE dla każdego użytkownika + +Plugin wymusza dwuskładnikowe uwierzytelnianie (TOTP) dla WSZYSTKICH użytkowników +panelu — bez możliwości wyłączenia per użytkownik. Każdy projekt ma to z automatu. +Używa sprawdzonego `@clocklimited/payload-2fa` (wrapuje access control — TOTP +sprawdzane przed dostępem do DANYCH, nie tylko UI panelu). + +### Zależność + +```bash +pnpm add @clocklimited/payload-2fa +``` +To PEER dependency — ipal-kit importuje ją dynamicznie tylko gdy 2FA włączone +(domyślnie). Bez niej i z włączonym 2FA plugin rzuci jasny błąd. + +### Konfiguracja (payload.config.ts) + +```ts +ipalKit({ + i18n: i18nConfig, + twoFactor: { + issuer: 'Nazwa Firmy', // pokazywane w aplikacji authenticator (Google Auth itp.) + // collectionSlug: 'users', // domyślnie 'users' + }, +}) +``` + +Plugin ustawia `forceSetup: true` — każdy użytkownik MUSI skonfigurować TOTP po +zalogowaniu (przekierowanie na setup). Nie ma opcji „włącz/wyłącz" dla użytkownika. + +### Wyłączenie (ODRADZANE) + +```ts +twoFactor: false // TYLKO gdy projekt naprawdę nie może użyć 2FA (rzadkie) +``` +Domyślnie 2FA jest WYMUSZONE. `false` to świadoma rezygnacja — unikaj. + +### Jak działa dla użytkownika + +1. Loguje się (email + hasło) +2. Przy pierwszym logowaniu: przekierowanie na Setup TOTP (QR + sekret) +3. Skanuje QR aplikacją (Google Authenticator, Authy, 1Password, Microsoft Auth) +4. Wpisuje kod → 2FA aktywne +5. Kolejne logowania: email + hasło + kod TOTP + +### Reset 2FA (admin) + +Admin może zresetować 2FA innego użytkownika (gdy zgubi telefon) — przez +`adminManageAccess` w konfiguracji @clocklimited. Patrz jego dokumentacja. + +### Dlaczego @clocklimited, nie inne + +Wybrany, bo wrapuje ACCESS CONTROL (TOTP przed dostępem do danych) + forceSetup +(wymuszenie dla wszystkich). Inne pluginy 2FA dla Payload gatują tylko nawigację +/admin — user z hasłem może omijać przez REST/GraphQL/Bearer. @clocklimited chroni +dostęp do danych, nie tylko UI. \ No newline at end of file diff --git a/package.json b/package.json index 8decb14..3d51a94 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "slugify": "^1.6.6" }, "peerDependencies": { + "@clocklimited/payload-2fa": "^3.0.0", "@payloadcms/next": "^3.88.0", "@payloadcms/plugin-form-builder": "^3.88.0", "@payloadcms/plugin-seo": "^3.88.0", diff --git a/src/modules/seo/assets/sitemap.css b/src/modules/seo/assets/sitemap.css index d28b43a..4230cee 100644 --- a/src/modules/seo/assets/sitemap.css +++ b/src/modules/seo/assets/sitemap.css @@ -1,76 +1,143 @@ /* - * Starter stylesheet for a sitemap served via buildSitemapXml({ cssUrl }). - * Uses type="text/css" on XML (W3C "Associating Style Sheets with XML") — NOT - * XSLT, so no browser deprecation warning. Selectors are the XML tag names. + * Universal, minimalist & elegant stylesheet for XML Sitemap. + * Neutral palette with automatic dark and light mode support. + * W3C standard: type="text/css" on XML (zero browser deprecation warnings). * - * Copy to your project's /public/sitemap.css and adjust colors/spacing to taste. - * Crawlers ignore this; it only affects the human-readable browser view. + * Ships with @intecion/ipal-kit. Copy to /public/sitemap.css and adjust freely. */ +:root { + --bg: #fafafa; + --card: #ffffff; + --border: #e5e7eb; + --border-hover: #d1d5db; + --text-main: #111827; + --text-secondary: #4b5563; + --text-muted: #9ca3af; + --url-color: #1e293b; + --badge-bg: #f3f4f6; + --badge-border: #e5e7eb; + --badge-text: #4b5563; + --accent: #f97316; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #090a0f; + --card: #12131a; + --border: #1e202e; + --border-hover: #2e3247; + --text-main: #f9fafb; + --text-secondary: #9ca3af; + --text-muted: #6b7280; + --url-color: #f3f4f6; + --badge-bg: #1a1c26; + --badge-border: #282b3d; + --badge-text: #9ca3af; + --accent: #fb923c; + } +} + urlset { display: block; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #090d16; - color: #f1f5f9; - padding: 2rem 1.5rem; - max-width: 1200px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Helvetica, Arial, sans-serif; + background-color: var(--bg); + color: var(--text-main); + padding: 3rem 1.5rem; + max-width: 1040px; margin: 0 auto; + min-height: 100vh; + box-sizing: border-box; line-height: 1.5; } -/* Each URL entry as a card. */ +/* Minimalist header */ +urlset::before { + content: "XML Sitemap"; + display: block; + font-size: 1.35rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-main); + padding-bottom: 0.4rem; +} + +/* Brand note under the header — crafted by Intecion Group */ +urlset::after { + content: "Intecion.com, Technology — engineered for modern digital experiences."; + display: block; + font-size: 0.8rem; + color: var(--text-muted); + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +/* URL card */ url { display: block; - background: #111827; - border: 1px solid #1e293b; + background-color: var(--card); + border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.25rem; - margin-bottom: 0.75rem; + margin-bottom: 0.65rem; + box-sizing: border-box; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } -/* The URL itself. */ +url:hover { + border-color: var(--border-hover); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03); +} + +/* URL address */ loc { display: block; - font-size: 0.95rem; - font-weight: 600; - color: #f97316; - margin-bottom: 0.5rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.875rem; + font-weight: 500; + color: var(--url-color); word-break: break-all; + line-height: 1.45; + margin-bottom: 0.45rem; } -/* Metadata line: lastmod / changefreq / priority, each with a label. */ +/* Metadata row */ lastmod, changefreq, priority { display: inline-block; - font-size: 0.8rem; - color: #94a3b8; - margin-right: 1.5rem; + font-size: 0.775rem; + color: var(--text-secondary); + margin-right: 1.25rem; + margin-top: 0.15rem; } lastmod::before { - content: 'Ostatnia modyfikacja: '; - color: #64748b; + content: "Zaktualizowano: "; + color: var(--text-muted); } changefreq::before { - content: 'Częstotliwość: '; - color: #64748b; + content: "Częstotliwość: "; + color: var(--text-muted); } priority::before { - content: 'Priorytet: '; - color: #64748b; + content: "Priorytet: "; + color: var(--text-muted); } -/* hreflang alternates as small pills. */ +/* Alternate language pills */ link { display: inline-block; - font-size: 0.75rem; - background: #1e293b; - color: #38bdf8; - border: 1px solid #334155; - padding: 0.15rem 0.45rem; + font-size: 0.7rem; + font-weight: 500; + background-color: var(--badge-bg); + border: 1px solid var(--badge-border); + color: var(--badge-text); + padding: 0.1rem 0.45rem; border-radius: 4px; - margin: 0.4rem 0.35rem 0 0; + margin-right: 0.3rem; + margin-top: 0.35rem; } \ No newline at end of file diff --git a/src/plugin.ts b/src/plugin.ts index 67676ed..292137b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -49,6 +49,51 @@ export const ipalKit = (options: IpalOptions): Plugin => { let config = { ...incomingConfig } + // --- custom admin route (e.g. '/its' instead of '/admin') --- + // Sets config.routes.admin; the project must move its app/(payload)// + // 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. + const pkg = '@clocklimited/payload-2fa' + const { totpPlugin } = (await import(pkg)) as { + totpPlugin: (opts: Record) => Plugin + } + config = await totpPlugin({ + collection: tf.collectionSlug ?? 'users', + forceSetup: true, // ENFORCED — every user must set up TOTP; no opt-out + 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) @@ -99,18 +144,12 @@ export const ipalKit = (options: IpalOptions): Plugin => { buildNotifications(), ] - // --- endpoints --- - // Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe - // message through the currently selected transport, so the panel's "send - // test" button can confirm delivery without leaving the admin UI. config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint] // --- hooks: onInit --- const incomingOnInit = config.onInit config.onInit = async (payload) => { - if (incomingOnInit) { - await incomingOnInit(payload) - } + if (incomingOnInit) {await incomingOnInit(payload)} payload.logger.info('[ipal] Plugin initialized.') } diff --git a/src/types.ts b/src/types.ts index 7fb34a7..678c5d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,18 @@ export type IpalOptions = { */ access?: AccessOption + /** + * Custom admin panel route, e.g. '/its' instead of the default '/admin'. + * The plugin sets config.routes.admin. IMPORTANT: this alone isn't enough — + * the project must ALSO move its panel folder to match: + * app/(payload)/its/[[...segments]]/page.tsx (and the not-found). The plugin + * can't create files in the project's app/. See docs/security.md. + * + * This is obscurity, not security: it hides the panel from dumb bots scanning + * /admin, but real protection is strong auth + 2FA + rate limiting. + */ + adminRoute?: string + /** * Collections whose entries live under an archive page — blog posts, case * studies, anything with a listing. Adds an "archive page" assignment per @@ -55,4 +67,23 @@ export type IpalOptions = { /** Additional fields injected into SiteSettings global */ siteSettingsFields?: Field[] + + /** + * Two-factor authentication (TOTP), ENFORCED for every user. Wires + * @clocklimited/payload-2fa with forceSetup — no per-project opt-out. Every + * user must configure an authenticator app after login; TOTP is checked before + * data access (not just the admin UI). Requires the peer dep installed and an + * issuer name (shown in the authenticator app). + * + * Pass `false` ONLY for a project that genuinely can't use 2FA (rare, discouraged) + * — default is enforced. See docs/security.md. + */ + twoFactor?: + | { + /** Auth collection slug. Defaults to 'users'. */ + collectionSlug?: string + /** Name shown in the authenticator app (e.g. company/site name). */ + issuer: string + } + | false }