Compare commits
18
Commits
4a46651839
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca2b20c3d | ||
|
|
105c454d73 | ||
|
|
cf6feefebc | ||
|
|
1857c8f771 | ||
|
|
542ad4ab9c | ||
|
|
9bf8599116 | ||
|
|
170c9a53f1 | ||
|
|
e0d5095099 | ||
|
|
08a8a10478 | ||
|
|
7bbaf14d14 | ||
|
|
b7d0b01122 | ||
|
|
4627266577 | ||
|
|
471ec4b12a | ||
|
|
060a61fd41 | ||
|
|
7cef95225a | ||
|
|
0ae62226bc | ||
|
|
610ab6fdf5 | ||
|
|
81275c0395 |
Vendored
+76
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
urlset {
|
||||||
|
display: block;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #090d16;
|
||||||
|
color: #f1f5f9;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each URL entry as a card. */
|
||||||
|
url {
|
||||||
|
display: block;
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The URL itself. */
|
||||||
|
loc {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f97316;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata line: lastmod / changefreq / priority, each with a label. */
|
||||||
|
lastmod,
|
||||||
|
changefreq,
|
||||||
|
priority {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastmod::before {
|
||||||
|
content: 'Ostatnia modyfikacja: ';
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
changefreq::before {
|
||||||
|
content: 'Częstotliwość: ';
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
priority::before {
|
||||||
|
content: 'Priorytet: ';
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hreflang alternates as small pills. */
|
||||||
|
link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #38bdf8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0.4rem 0.35rem 0 0;
|
||||||
|
}
|
||||||
Vendored
+154
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* 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: #027bd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: #027bd0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
urlset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimalist header */
|
||||||
|
urlset::before {
|
||||||
|
content: "XML Sitemap";
|
||||||
|
display: block;
|
||||||
|
order: -2;
|
||||||
|
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;
|
||||||
|
order: -1;
|
||||||
|
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;
|
||||||
|
width: 100%;
|
||||||
|
order: 0;
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
url:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL address */
|
||||||
|
loc {
|
||||||
|
display: block;
|
||||||
|
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 row */
|
||||||
|
lastmod,
|
||||||
|
changefreq,
|
||||||
|
priority {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: 1.25rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastmod::before {
|
||||||
|
content: "Updated: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
changefreq::before {
|
||||||
|
content: "Frequency: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
priority::before {
|
||||||
|
content: "Priority: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternate language pills */
|
||||||
|
link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: var(--badge-bg);
|
||||||
|
border: 1px solid var(--badge-border);
|
||||||
|
color: var(--badge-text);
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
link::before {
|
||||||
|
content: attr(hreflang);
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+16
-1
@@ -17,12 +17,27 @@ export type PageMetadata = {
|
|||||||
url: string;
|
url: string;
|
||||||
}[];
|
}[];
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
/** Site/brand name for the OG card. */
|
||||||
|
siteName?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** 'website' | 'article' etc. Defaults to 'website'. */
|
||||||
|
type?: string;
|
||||||
|
/** Canonical URL of this page. */
|
||||||
|
url?: string;
|
||||||
};
|
};
|
||||||
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
/**
|
||||||
|
* robots directives. Indexed pages get generous snippet/preview limits by
|
||||||
|
* default (Google shows richer results); noindex pages get index:false.
|
||||||
|
*/
|
||||||
robots?: {
|
robots?: {
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
index: boolean;
|
index: boolean;
|
||||||
|
/** 'none' | 'standard' | 'large' — image preview size in results. */
|
||||||
|
'max-image-preview'?: 'large' | 'none' | 'standard';
|
||||||
|
/** Max text snippet length; -1 = no limit. Next maps to max-snippet. */
|
||||||
|
'max-snippet'?: number;
|
||||||
|
/** Max video preview seconds; -1 = no limit. */
|
||||||
|
'max-video-preview'?: number;
|
||||||
};
|
};
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+20
-4
@@ -71,16 +71,32 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
...images && {
|
...images && {
|
||||||
images
|
images
|
||||||
},
|
},
|
||||||
locale
|
type: 'website',
|
||||||
|
locale,
|
||||||
|
...siteName ? {
|
||||||
|
siteName
|
||||||
|
} : {},
|
||||||
|
...canonical ? {
|
||||||
|
url: canonical
|
||||||
|
} : {}
|
||||||
},
|
},
|
||||||
// noindex → tell search engines to exclude the page but still follow links
|
// robots: noindex pages are excluded (follow keeps link authority). Indexed
|
||||||
// (authority flows through). For legal/thin/search-result pages.
|
// pages get generous snippet/preview limits so Google can show rich results
|
||||||
|
// (long snippets, large image previews, full video previews).
|
||||||
...meta?.noindex ? {
|
...meta?.noindex ? {
|
||||||
robots: {
|
robots: {
|
||||||
follow: true,
|
follow: true,
|
||||||
index: false
|
index: false
|
||||||
}
|
}
|
||||||
} : {}
|
} : {
|
||||||
|
robots: {
|
||||||
|
follow: true,
|
||||||
|
index: true,
|
||||||
|
'max-image-preview': 'large',
|
||||||
|
'max-snippet': -1,
|
||||||
|
'max-video-preview': -1
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+44
-4
@@ -42,6 +42,50 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
|
|||||||
let config = {
|
let config = {
|
||||||
...incomingConfig
|
...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 ---
|
// --- i18n ---
|
||||||
config.localization = buildLocalizationConfig(options.i18n);
|
config.localization = buildLocalizationConfig(options.i18n);
|
||||||
// --- access: inject roles into the client's auth collection ---
|
// --- access: inject roles into the client's auth collection ---
|
||||||
@@ -88,10 +132,6 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
|
|||||||
buildCookieSettings(),
|
buildCookieSettings(),
|
||||||
buildNotifications()
|
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 = [
|
||||||
...config.endpoints ?? [],
|
...config.endpoints ?? [],
|
||||||
testEmailEndpoint
|
testEmailEndpoint
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+27
@@ -15,6 +15,17 @@ export type IpalOptions = {
|
|||||||
* (admin > editor > user) into the client's auth collection.
|
* (admin > editor > user) into the client's auth collection.
|
||||||
*/
|
*/
|
||||||
access?: AccessOption;
|
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
|
* Collections whose entries live under an archive page — blog posts, case
|
||||||
* studies, anything with a listing. Adds an "archive page" assignment per
|
* studies, anything with a listing. Adds an "archive page" assignment per
|
||||||
@@ -45,4 +56,20 @@ export type IpalOptions = {
|
|||||||
seo?: SeoOption;
|
seo?: SeoOption;
|
||||||
/** Additional fields injected into SiteSettings global */
|
/** Additional fields injected into SiteSettings global */
|
||||||
siteSettingsFields?: Field[];
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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"}
|
{"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"}
|
||||||
+105
-1
@@ -197,4 +197,108 @@ NIE wymuszaj `require-trusted-types-for 'script'`. Powód:
|
|||||||
przy zewnętrznych skryptach manipulujących DOM stringami (Turnstile, GA)
|
przy zewnętrznych skryptach manipulujących DOM stringami (Turnstile, GA)
|
||||||
- Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony
|
- Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony
|
||||||
|
|
||||||
Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia.
|
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/[email protected]
|
||||||
|
```
|
||||||
|
Uwaga: pakiet nie ma jeszcze stabilnego 3.0.0 — użyj konkretnej wersji beta
|
||||||
|
(albo `^3.0.0-0`, żeby dopuścić prereleasy). Sam `^3.0.0` da błąd
|
||||||
|
ERR_PNPM_NO_MATCHING_VERSION.
|
||||||
|
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.
|
||||||
+150
-121
@@ -19,14 +19,14 @@ admina i bez importMap się nie wyrenderują.
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
ipalKit({
|
ipalKit({
|
||||||
seo: {
|
seo: {
|
||||||
collections: ['pages', 'posts'], // które kolekcje dostają meta
|
collections: ['pages', 'posts'], // które kolekcje dostają meta
|
||||||
// generateTitle: ({ doc }) => `${doc.title}`, // opcjonalne
|
// generateTitle: ({ doc }) => `${doc.title}`, // opcjonalne
|
||||||
// generateDescription: ({ doc }) => doc.excerpt ?? '',
|
// generateDescription: ({ doc }) => doc.excerpt ?? '',
|
||||||
// fields: [...], // extra pola w grupie SEO
|
// fields: [...], // extra pola w grupie SEO
|
||||||
// autoFill: { title: 'title', description: 'excerpt' }, // mapowanie auto-fill
|
// autoFill: { title: 'title', description: 'excerpt' }, // mapowanie auto-fill
|
||||||
// autoFill: false, // wyłącz auto-fill
|
// autoFill: false, // wyłącz auto-fill
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,16 +89,16 @@ import { createPageMetadata } from '@intecion/ipal-kit'
|
|||||||
import { i18nConfig } from '@/i18n.config'
|
import { i18nConfig } from '@/i18n.config'
|
||||||
|
|
||||||
const pageMetadata = createPageMetadata({
|
const pageMetadata = createPageMetadata({
|
||||||
config: i18nConfig,
|
config: i18nConfig,
|
||||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||||
// collection: 'pages', // domyślne
|
// collection: 'pages', // domyślne
|
||||||
// settingsSlug: 'site-settings', // domyślne
|
// settingsSlug: 'site-settings', // domyślne
|
||||||
// siteNameField: 'siteName', // domyślne
|
// siteNameField: 'siteName', // domyślne
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
const { locale, slug } = await params
|
const { locale, slug } = await params
|
||||||
return pageMetadata({ payload: await getPayload({ config }), locale, slug })
|
return pageMetadata({ payload: await getPayload({ config }), locale, slug })
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -121,43 +121,43 @@ global. Klient dostarcza resolvery.
|
|||||||
import { createMetadataGenerator } from '@intecion/ipal-kit'
|
import { createMetadataGenerator } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
const generate = createMetadataGenerator({
|
const generate = createMetadataGenerator({
|
||||||
config: i18nConfig,
|
config: i18nConfig,
|
||||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||||
homeSlug: 'homepage',
|
homeSlug: 'homepage',
|
||||||
|
|
||||||
resolveDocument: async ({ payload, params, locale }) => {
|
resolveDocument: async ({ payload, params, locale }) => {
|
||||||
const slug = (params.slug as string[])?.join('/')
|
const slug = (params.slug as string[])?.join('/')
|
||||||
|
|
||||||
// meta MUSI przyjść w konkretnym locale (stringi), a slug jako mapa
|
// meta MUSI przyjść w konkretnym locale (stringi), a slug jako mapa
|
||||||
// locale→wartość (hreflang) — to dwa różne odczyty.
|
// locale→wartość (hreflang) — to dwa różne odczyty.
|
||||||
const found = await payload.find({
|
const found = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: { slug: { equals: slug } },
|
where: { slug: { equals: slug } },
|
||||||
locale,
|
locale,
|
||||||
depth: 1,
|
depth: 1,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
})
|
})
|
||||||
const doc = found.docs[0]
|
const doc = found.docs[0]
|
||||||
if (!doc) return null
|
if (!doc) return null
|
||||||
|
|
||||||
const allLocales = await payload.findByID({
|
const allLocales = await payload.findByID({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
locale: 'all',
|
locale: 'all',
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { ...doc, slug: allLocales.slug }
|
return { ...doc, slug: allLocales.slug }
|
||||||
},
|
},
|
||||||
|
|
||||||
resolveSiteName: async ({ payload, locale }) =>
|
resolveSiteName: async ({ payload, locale }) =>
|
||||||
(await getSiteSettings(payload, { locale })).siteName ?? null,
|
(await getSiteSettings(payload, { locale })).siteName ?? null,
|
||||||
resolveImageUrl: async ({ doc }) => doc.meta?.image?.url ?? null,
|
resolveImageUrl: async ({ doc }) => doc.meta?.image?.url ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }) {
|
||||||
const { locale, slug } = await params
|
const { locale, slug } = await params
|
||||||
return generate({ payload: await getPayload({ config }), params: { slug }, locale })
|
return generate({ payload: await getPayload({ config }), params: { slug }, locale })
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -176,16 +176,16 @@ Gdy chcesz pełną kontrolę:
|
|||||||
import { buildMetadata, getLocalizedSlugs } from '@intecion/ipal-kit'
|
import { buildMetadata, getLocalizedSlugs } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
return buildMetadata({
|
return buildMetadata({
|
||||||
meta: doc.meta, // z plugin-seo
|
meta: doc.meta, // z plugin-seo
|
||||||
siteName: settings.siteName,
|
siteName: settings.siteName,
|
||||||
imageUrl: '/og.png',
|
imageUrl: '/og.png',
|
||||||
locale: 'pl',
|
locale: 'pl',
|
||||||
slugs: getLocalizedSlugs({ slugField: docAllLocales.slug, config }),
|
slugs: getLocalizedSlugs({ slugField: docAllLocales.slug, config }),
|
||||||
config,
|
config,
|
||||||
baseUrl: 'https://example.com',
|
baseUrl: 'https://example.com',
|
||||||
separator: ' – ', // opcjonalne
|
separator: ' – ', // opcjonalne
|
||||||
order: 'site-first', // opcjonalne
|
order: 'site-first', // opcjonalne
|
||||||
homeSlug: 'homepage',
|
homeSlug: 'homepage',
|
||||||
})
|
})
|
||||||
// → { title, description, openGraph, alternates: { canonical, languages } }
|
// → { title, description, openGraph, alternates: { canonical, languages } }
|
||||||
```
|
```
|
||||||
@@ -232,10 +232,10 @@ nie rozjedzie się z tym, co strony serwują. Handlery są gotowe w
|
|||||||
```ts
|
```ts
|
||||||
// src/lib/content.ts
|
// src/lib/content.ts
|
||||||
export const { /* ... */, sitemap, robots } = createContentHelpers({
|
export const { /* ... */, sitemap, robots } = createContentHelpers({
|
||||||
config,
|
config,
|
||||||
content: contentConfig,
|
content: contentConfig,
|
||||||
i18n: i18nConfig, // wymagane dla sitemap (hreflang)
|
i18n: i18nConfig, // wymagane dla sitemap (hreflang)
|
||||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ Niskopoziomowo (własna trasa zamiast handlera z fabryki):
|
|||||||
import { buildSitemapEntries, buildRobots } from '@intecion/ipal-kit'
|
import { buildSitemapEntries, buildRobots } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
const entries = await buildSitemapEntries({
|
const entries = await buildSitemapEntries({
|
||||||
payload, config: i18nConfig, baseUrl, content: contentConfig,
|
payload, config: i18nConfig, baseUrl, content: contentConfig,
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -321,9 +321,9 @@ import { buildIconsMetadata } from '@intecion/ipal-kit'
|
|||||||
import { getSettings } from '@/lib/payload'
|
import { getSettings } from '@/lib/payload'
|
||||||
|
|
||||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
const { locale } = await params
|
const { locale } = await params
|
||||||
const settings = await getSettings(locale)
|
const settings = await getSettings(locale)
|
||||||
return buildIconsMetadata(settings.favicon) // z pola favicon (panel)
|
return buildIconsMetadata(settings.favicon) // z pola favicon (panel)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -346,16 +346,16 @@ wynikach, logo w knowledge panel.
|
|||||||
import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
const jsonLd = buildOrganizationJsonLd({
|
const jsonLd = buildOrganizationJsonLd({
|
||||||
name: settings.siteName,
|
name: settings.siteName,
|
||||||
url: process.env.NEXT_PUBLIC_SERVER_URL!,
|
url: process.env.NEXT_PUBLIC_SERVER_URL!,
|
||||||
logo: settings.logo,
|
logo: settings.logo,
|
||||||
sameAs: settings.socialLinks, // opcjonalne: profile społecznościowe
|
sameAs: settings.socialLinks, // opcjonalne: profile społecznościowe
|
||||||
})
|
})
|
||||||
|
|
||||||
// w JSX layoutu:
|
// w JSX layoutu:
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -403,42 +403,42 @@ import { i18nConfig } from '@/i18n.config'
|
|||||||
import type { SiteSetting } from '@/payload-types'
|
import type { SiteSetting } from '@/payload-types'
|
||||||
|
|
||||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||||
const payload = await getCachedPayload()
|
const payload = await getCachedPayload()
|
||||||
const settings = await getSiteSettings<SiteSetting>(payload, {
|
const settings = await getSiteSettings<SiteSetting>(payload, {
|
||||||
locale: i18nConfig.defaultLocale as never,
|
locale: i18nConfig.defaultLocale as never,
|
||||||
})
|
})
|
||||||
|
|
||||||
const siteName = settings?.siteName?.trim()
|
const siteName = settings?.siteName?.trim()
|
||||||
|
|
||||||
// Ikona z panelu (favicon → logo). Dla PNG podaj KONKRETNY rozmiar z media
|
// Ikona z panelu (favicon → logo). Dla PNG podaj KONKRETNY rozmiar z media
|
||||||
// (nie 'any' — 'any' jest tylko dla SVG). Bez ikony → pomiń pole icons.
|
// (nie 'any' — 'any' jest tylko dla SVG). Bez ikony → pomiń pole icons.
|
||||||
const icon = settings?.favicon ?? settings?.logo
|
const icon = settings?.favicon ?? settings?.logo
|
||||||
const iconEntry =
|
const iconEntry =
|
||||||
typeof icon === 'object' && icon?.url
|
typeof icon === 'object' && icon?.url
|
||||||
? (() => {
|
? (() => {
|
||||||
const isSvg = icon.mimeType === 'image/svg+xml' || icon.url.endsWith('.svg')
|
const isSvg = icon.mimeType === 'image/svg+xml' || icon.url.endsWith('.svg')
|
||||||
const size =
|
const size =
|
||||||
typeof icon.width === 'number' && typeof icon.height === 'number'
|
typeof icon.width === 'number' && typeof icon.height === 'number'
|
||||||
? `${Math.min(icon.width, icon.height)}x${Math.min(icon.width, icon.height)}`
|
? `${Math.min(icon.width, icon.height)}x${Math.min(icon.width, icon.height)}`
|
||||||
: '512x512'
|
: '512x512'
|
||||||
return {
|
return {
|
||||||
src: icon.url,
|
src: icon.url,
|
||||||
type: icon.mimeType ?? 'image/png',
|
type: icon.mimeType ?? 'image/png',
|
||||||
sizes: isSvg ? 'any' : size, // 'any' tylko dla SVG
|
sizes: isSvg ? 'any' : size, // 'any' tylko dla SVG
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Buduj TYLKO z tego, co jest. Brak pola → nie ma go w manifeście (zamiast
|
// Buduj TYLKO z tego, co jest. Brak pola → nie ma go w manifeście (zamiast
|
||||||
// zaszytego fallbacku). start_url z configu, nie zaszyte '/pl'.
|
// zaszytego fallbacku). start_url z configu, nie zaszyte '/pl'.
|
||||||
return {
|
return {
|
||||||
...(siteName ? { name: siteName, short_name: siteName } : {}),
|
...(siteName ? { name: siteName, short_name: siteName } : {}),
|
||||||
start_url: `/${i18nConfig.defaultLocale}`,
|
start_url: `/${i18nConfig.defaultLocale}`,
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
...(iconEntry ? { icons: [iconEntry] } : {}),
|
...(iconEntry ? { icons: [iconEntry] } : {}),
|
||||||
// theme_color / background_color / description — TYLKO jeśli dodasz pola w
|
// theme_color / background_color / description — TYLKO jeśli dodasz pola w
|
||||||
// panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta.
|
// panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -522,16 +522,16 @@ zanim strona wygeneruje metadane. To pcha metadata do body. NIE deklaruj `<head>
|
|||||||
```tsx
|
```tsx
|
||||||
// ŹLE — jawny <head> zamyka head za wcześnie
|
// ŹLE — jawny <head> zamyka head za wcześnie
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<head><MediaPreconnect /></head>
|
<head><MediaPreconnect /></head>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
// DOBRZE — MediaPreconnect w body, React 19 hoistuje link do head
|
// DOBRZE — MediaPreconnect w body, React 19 hoistuje link do head
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<body>
|
<body>
|
||||||
<MediaPreconnect />
|
<MediaPreconnect />
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -546,8 +546,8 @@ z jakiegoś powodu nie może być ISR):
|
|||||||
```ts
|
```ts
|
||||||
// next.config.ts
|
// next.config.ts
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
htmlLimitedBots:
|
htmlLimitedBots:
|
||||||
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
|
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -652,10 +652,10 @@ searchbox (pole wyszukiwania pod wynikiem marki). RAZ w root layout:
|
|||||||
```tsx
|
```tsx
|
||||||
import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||||
const jsonLd = buildWebSiteJsonLd({
|
const jsonLd = buildWebSiteJsonLd({
|
||||||
name: settings.siteName,
|
name: settings.siteName,
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
// TYLKO jeśli masz działającą stronę wyszukiwania:
|
// TYLKO jeśli masz działającą stronę wyszukiwania:
|
||||||
search: { target: `${baseUrl}/szukaj?q={search_term_string}` },
|
search: { target: `${baseUrl}/szukaj?q={search_term_string}` },
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
Pomiń `search`, jeśli nie ma realnej wyszukiwarki — SearchAction wskazujący na
|
Pomiń `search`, jeśli nie ma realnej wyszukiwarki — SearchAction wskazujący na
|
||||||
@@ -666,9 +666,9 @@ Detailing) + Google rozumie hierarchię. PER STRONA, z pozycji strony:
|
|||||||
```tsx
|
```tsx
|
||||||
import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||||
const jsonLd = buildBreadcrumbJsonLd([
|
const jsonLd = buildBreadcrumbJsonLd([
|
||||||
{ name: 'Strona główna', url: `${base}/pl` },
|
{ name: 'Strona główna', url: `${base}/pl` },
|
||||||
{ name: 'Usługi', url: `${base}/pl/uslugi` },
|
{ name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||||
{ name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
{ name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||||
])
|
])
|
||||||
```
|
```
|
||||||
Okruszki buduj z RZECZYWISTEJ pozycji strony (resolveRoute / ścieżka URL), NIE z
|
Okruszki buduj z RZECZYWISTEJ pozycji strony (resolveRoute / ścieżka URL), NIE z
|
||||||
@@ -679,7 +679,7 @@ samych pozycji co menu w headerze:
|
|||||||
```tsx
|
```tsx
|
||||||
import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||||
const jsonLd = buildSiteNavigationJsonLd(
|
const jsonLd = buildSiteNavigationJsonLd(
|
||||||
navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
Dane z tego samego źródła co widoczne menu — nie osobna zaszyta lista.
|
Dane z tego samego źródła co widoczne menu — nie osobna zaszyta lista.
|
||||||
@@ -843,6 +843,31 @@ const jsonLd = buildArticleJsonLd({
|
|||||||
|
|
||||||
Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.
|
Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.
|
||||||
|
|
||||||
|
## Robots — rich results (max-snippet, image/video preview)
|
||||||
|
|
||||||
|
Strony indeksowane dostają automatycznie dyrektywy pozwalające Google na bogate
|
||||||
|
wyniki (buildMetadata generuje):
|
||||||
|
- `max-snippet: -1` — pełnej długości snippet (bez limitu)
|
||||||
|
- `max-image-preview: large` — duży podgląd obrazu w wynikach
|
||||||
|
- `max-video-preview: -1` — pełny podgląd wideo
|
||||||
|
|
||||||
|
Strony noindex (polityki) dostają `index: false, follow: true` — bez powyższych.
|
||||||
|
Zero konfiguracji — działa automatycznie dla każdej strony przez createPageMetadata.
|
||||||
|
|
||||||
|
## Open Graph — pełne pola (type, siteName, url, description)
|
||||||
|
|
||||||
|
buildMetadata generuje kompletny OG dla social share (Facebook, LinkedIn, Slack):
|
||||||
|
- `og:title` — tytuł strony
|
||||||
|
- `og:description` — meta.description → fallback siteDescription (dziedziczy)
|
||||||
|
- `og:image` — defaultShareImage / obraz strony
|
||||||
|
- `og:type` — 'website'
|
||||||
|
- `og:site_name` — siteName z panelu
|
||||||
|
- `og:url` — canonical strony
|
||||||
|
- `og:locale` — język strony
|
||||||
|
|
||||||
|
Wszystko z panelu, automatycznie. Opis OG dziedziczy z meta.description (albo
|
||||||
|
globalnego siteDescription gdy pusty) — patrz niżej.
|
||||||
|
|
||||||
## Fallback meta description (siteDescription)
|
## Fallback meta description (siteDescription)
|
||||||
|
|
||||||
Strona bez `meta.description` → plugin używa globalnego `siteDescription` z
|
Strona bez `meta.description` → plugin używa globalnego `siteDescription` z
|
||||||
@@ -929,7 +954,11 @@ const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
|
|||||||
|
|
||||||
### Starter CSS (public/sitemap.css)
|
### Starter CSS (public/sitemap.css)
|
||||||
|
|
||||||
Skopiuj do `public/sitemap.css` w projekcie, dostosuj do designu. Selektory to
|
Plugin dostarcza gotowy plik — skopiuj do projektu:
|
||||||
|
```bash
|
||||||
|
cp node_modules/@intecion/ipal-kit/dist/modules/seo/assets/sitemap.css public/sitemap.css
|
||||||
|
```
|
||||||
|
Albo skopiuj poniższy starter i dostosuj do designu. Selektory to
|
||||||
bezpośrednio nazwy tagów XML:
|
bezpośrednio nazwy tagów XML:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.4.2",
|
"version": "1.5.6",
|
||||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
"slugify": "^1.6.6"
|
"slugify": "^1.6.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@clocklimited/payload-2fa": "^3.0.0",
|
||||||
"@payloadcms/next": "^3.88.0",
|
"@payloadcms/next": "^3.88.0",
|
||||||
"@payloadcms/plugin-form-builder": "^3.88.0",
|
"@payloadcms/plugin-form-builder": "^3.88.0",
|
||||||
"@payloadcms/plugin-seo": "^3.88.0",
|
"@payloadcms/plugin-seo": "^3.88.0",
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* 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: #027bd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: #027bd0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
urlset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimalist header */
|
||||||
|
urlset::before {
|
||||||
|
content: "XML Sitemap";
|
||||||
|
display: block;
|
||||||
|
order: -2;
|
||||||
|
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;
|
||||||
|
order: -1;
|
||||||
|
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;
|
||||||
|
width: 100%;
|
||||||
|
order: 0;
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
url:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL address */
|
||||||
|
loc {
|
||||||
|
display: block;
|
||||||
|
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 row */
|
||||||
|
lastmod,
|
||||||
|
changefreq,
|
||||||
|
priority {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: 1.25rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastmod::before {
|
||||||
|
content: "Updated: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
changefreq::before {
|
||||||
|
content: "Frequency: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
priority::before {
|
||||||
|
content: "Priority: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternate language pills */
|
||||||
|
link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: var(--badge-bg);
|
||||||
|
border: 1px solid var(--badge-border);
|
||||||
|
color: var(--badge-text);
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
link::before {
|
||||||
|
content: attr(hreflang);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -20,12 +20,27 @@ export type PageMetadata = {
|
|||||||
description?: string
|
description?: string
|
||||||
images?: { url: string }[]
|
images?: { url: string }[]
|
||||||
locale?: string
|
locale?: string
|
||||||
|
/** Site/brand name for the OG card. */
|
||||||
|
siteName?: string
|
||||||
title: string
|
title: string
|
||||||
|
/** 'website' | 'article' etc. Defaults to 'website'. */
|
||||||
|
type?: string
|
||||||
|
/** Canonical URL of this page. */
|
||||||
|
url?: string
|
||||||
}
|
}
|
||||||
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
/**
|
||||||
|
* robots directives. Indexed pages get generous snippet/preview limits by
|
||||||
|
* default (Google shows richer results); noindex pages get index:false.
|
||||||
|
*/
|
||||||
robots?: {
|
robots?: {
|
||||||
follow: boolean
|
follow: boolean
|
||||||
index: boolean
|
index: boolean
|
||||||
|
/** 'none' | 'standard' | 'large' — image preview size in results. */
|
||||||
|
'max-image-preview'?: 'large' | 'none' | 'standard'
|
||||||
|
/** Max text snippet length; -1 = no limit. Next maps to max-snippet. */
|
||||||
|
'max-snippet'?: number
|
||||||
|
/** Max video preview seconds; -1 = no limit. */
|
||||||
|
'max-video-preview'?: number
|
||||||
}
|
}
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
@@ -140,10 +155,24 @@ export function buildMetadata({
|
|||||||
title,
|
title,
|
||||||
...(description && { description }),
|
...(description && { description }),
|
||||||
...(images && { images }),
|
...(images && { images }),
|
||||||
|
type: 'website',
|
||||||
locale,
|
locale,
|
||||||
|
...(siteName ? { siteName } : {}),
|
||||||
|
...(canonical ? { url: canonical } : {}),
|
||||||
},
|
},
|
||||||
// noindex → tell search engines to exclude the page but still follow links
|
// robots: noindex pages are excluded (follow keeps link authority). Indexed
|
||||||
// (authority flows through). For legal/thin/search-result pages.
|
// pages get generous snippet/preview limits so Google can show rich results
|
||||||
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),
|
// (long snippets, large image previews, full video previews).
|
||||||
|
...(meta?.noindex
|
||||||
|
? { robots: { follow: true, index: false } }
|
||||||
|
: {
|
||||||
|
robots: {
|
||||||
|
follow: true,
|
||||||
|
index: true,
|
||||||
|
'max-image-preview': 'large' as const,
|
||||||
|
'max-snippet': -1,
|
||||||
|
'max-video-preview': -1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-7
@@ -49,6 +49,61 @@ export const ipalKit = (options: IpalOptions): Plugin => {
|
|||||||
|
|
||||||
let config = { ...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')) as {
|
||||||
|
payloadTotp?: (opts: Record<string, unknown>) => Plugin
|
||||||
|
totpPlugin?: (opts: Record<string, unknown>) => Plugin
|
||||||
|
}
|
||||||
|
// 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, // 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 ---
|
// --- i18n ---
|
||||||
config.localization = buildLocalizationConfig(options.i18n)
|
config.localization = buildLocalizationConfig(options.i18n)
|
||||||
|
|
||||||
@@ -99,18 +154,12 @@ export const ipalKit = (options: IpalOptions): Plugin => {
|
|||||||
buildNotifications(),
|
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]
|
config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint]
|
||||||
|
|
||||||
// --- hooks: onInit ---
|
// --- hooks: onInit ---
|
||||||
const incomingOnInit = config.onInit
|
const incomingOnInit = config.onInit
|
||||||
config.onInit = async (payload) => {
|
config.onInit = async (payload) => {
|
||||||
if (incomingOnInit) {
|
if (incomingOnInit) {await incomingOnInit(payload)}
|
||||||
await incomingOnInit(payload)
|
|
||||||
}
|
|
||||||
payload.logger.info('[ipal] Plugin initialized.')
|
payload.logger.info('[ipal] Plugin initialized.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ export type IpalOptions = {
|
|||||||
*/
|
*/
|
||||||
access?: AccessOption
|
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
|
* Collections whose entries live under an archive page — blog posts, case
|
||||||
* studies, anything with a listing. Adds an "archive page" assignment per
|
* studies, anything with a listing. Adds an "archive page" assignment per
|
||||||
@@ -55,4 +67,23 @@ export type IpalOptions = {
|
|||||||
|
|
||||||
/** Additional fields injected into SiteSettings global */
|
/** Additional fields injected into SiteSettings global */
|
||||||
siteSettingsFields?: Field[]
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user