Added secuirty 2FA for users

This commit is contained in:
2026-09-28 15:49:36 +02:00
parent 7cef95225a
commit 060a61fd41
10 changed files with 453 additions and 86 deletions
+103 -36
View File
@@ -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;
}
+46 -7
View File
@@ -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)/<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.
const pkg = '@clocklimited/payload-2fa'
const { totpPlugin } = (await import(pkg)) as {
totpPlugin: (opts: Record<string, unknown>) => 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.')
}
+31
View File
@@ -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
}