From af43000e79dbcb88f3eed359fd1514c6a98bca0b Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 27 Aug 2026 09:09:18 +0200 Subject: [PATCH 1/8] refactor(quality): use trusted-proxy-aware clientIp for webhook audit IPs Q-3 from the Aug 2026 quality audit: handleIncomingWebhook still built audit-log IPs from raw X-Forwarded-For / X-Real-IP headers, the same spoofable pattern already fixed elsewhere in the codebase. Applies the same clientIp(..., { trustForwardedFor }) policy so audit attribution is consistent everywhere. --- src/admin/public-api.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/admin/public-api.ts b/src/admin/public-api.ts index d87e6bd..011541c 100644 --- a/src/admin/public-api.ts +++ b/src/admin/public-api.ts @@ -337,6 +337,11 @@ export async function handleIncomingWebhook(ctx: AdminContext, req: Request): Pr } const executed: string[] = []; + // Trusted-proxy-aware IP for audit records — forwarded headers are only + // honored when system.trusted_proxies is set (same policy as _utils.getClientIp). + const auditIp = clientIp(req, { + trustForwardedFor: config.system?.trusted_proxies === true, + }); for (const action of requestedActions) { if (action === "rebuild") { @@ -347,7 +352,7 @@ export async function handleIncomingWebhook(ctx: AdminContext, req: Request): Pr void auditLogger?.log({ event: "system.rebuild", actor: null, - ip: req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? req.headers.get("x-real-ip") ?? null, + ip: auditIp === "unknown" ? null : auditIp, userAgent: req.headers.get("user-agent"), target: { type: "system" }, detail: {}, @@ -361,7 +366,7 @@ export async function handleIncomingWebhook(ctx: AdminContext, req: Request): Pr void auditLogger?.log({ event: "system.cache_purge", actor: null, - ip: req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? req.headers.get("x-real-ip") ?? null, + ip: auditIp === "unknown" ? null : auditIp, userAgent: req.headers.get("user-agent"), target: { type: "system" }, detail: {}, From 5381ebd58bac9558933657df61ee690aeda0e2f9 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 25 Aug 2026 13:23:23 +0200 Subject: [PATCH 2/8] refactor(quality): consolidate timing-safe comparison into one helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q-2 from the Aug 2026 quality audit: three independent copies of the constant-time byte comparison existed — auth/passwords.ts, auth/csrf.ts, and an inline loop in public-api.ts's webhook token check. Copies of security-critical comparison code are a drift risk. New src/admin/timing-safe.ts exports the single implementation; all three call sites now share it. No behavior change. --- src/admin/auth/csrf.ts | 12 ++---------- src/admin/auth/passwords.ts | 17 +---------------- src/admin/public-api.ts | 10 +++------- src/admin/timing-safe.ts | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 33 deletions(-) create mode 100644 src/admin/timing-safe.ts diff --git a/src/admin/auth/csrf.ts b/src/admin/auth/csrf.ts index a30360c..5bc0cf5 100644 --- a/src/admin/auth/csrf.ts +++ b/src/admin/auth/csrf.ts @@ -11,19 +11,11 @@ import { createHmac } from "node:crypto"; import type { AdminState } from "../types.ts"; +import { timingSafeEqual } from "../timing-safe.ts"; const SECRET_FILE = "csrf-secret"; const secretCache = new Map(); -function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean { - const maxLen = Math.max(a.length, b.length); - let result = a.length ^ b.length; - for (let i = 0; i < maxLen; i++) { - result |= (a[i] ?? 0) ^ (b[i] ?? 0); - } - return result === 0; -} - /** HMAC-SHA256 hex digest bound to one session id. */ export function mintCsrfToken(sessionId: string, secret: string): string { return createHmac("sha256", secret).update(sessionId).digest("hex"); @@ -36,7 +28,7 @@ export function csrfTokenMatches( ): boolean { if (!sessionId || !secret || !presented) return false; const expected = mintCsrfToken(sessionId, secret); - return timingSafeEqualBytes( + return timingSafeEqual( new TextEncoder().encode(expected), new TextEncoder().encode(presented), ); diff --git a/src/admin/auth/passwords.ts b/src/admin/auth/passwords.ts index bc9bf75..3ab85fa 100644 --- a/src/admin/auth/passwords.ts +++ b/src/admin/auth/passwords.ts @@ -13,6 +13,7 @@ */ import { encodeHex } from "@std/encoding/hex"; +import { timingSafeEqual } from "../timing-safe.ts"; /** * Pre-computed PBKDF2 hash used for constant-time dummy comparisons. @@ -120,19 +121,3 @@ function hexToBytes(hex: string): Uint8Array { return bytes; } -/** - * Constant-time comparison of two byte arrays. - * - * Runs for max(a.length, b.length) iterations regardless of content, - * and captures a length difference in the initial XOR — no early returns. - * Operating on raw bytes (not hex strings) removes the encoding layer. - */ -function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { - const maxLen = Math.max(a.length, b.length); - // Start with the length difference: if lengths differ, result is already non-zero. - let result = a.length ^ b.length; - for (let i = 0; i < maxLen; i++) { - result |= (a[i] ?? 0) ^ (b[i] ?? 0); - } - return result === 0; -} diff --git a/src/admin/public-api.ts b/src/admin/public-api.ts index 011541c..87bd3d3 100644 --- a/src/admin/public-api.ts +++ b/src/admin/public-api.ts @@ -19,6 +19,7 @@ import { RateLimiter, clientIp } from "@dune/core/security"; import { encodeHex } from "@std/encoding/hex"; import type { SubmissionFile } from "./submissions.ts"; import type { WebhookNotificationConfig } from "@dune/core/config"; +import { timingSafeEqual } from "./timing-safe.ts"; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -302,19 +303,14 @@ export async function handleIncomingWebhook(ctx: AdminContext, req: Request): Pr return t; }; - // Compare in constant time: equal-length byte comparison prevents an + // Compare in constant time (shared timingSafeEqual helper) to prevent an // attacker from progressively recovering the configured token by // measuring response timing. const tokenBytes = new TextEncoder().encode(token); let matched: (typeof incomingWebhooks)[number] | undefined; for (const wh of incomingWebhooks) { const candidate = new TextEncoder().encode(expandToken(wh.token)); - const maxLen = Math.max(candidate.byteLength, tokenBytes.byteLength); - let diff = candidate.byteLength ^ tokenBytes.byteLength; - for (let i = 0; i < maxLen; i++) { - diff |= (candidate[i] ?? 0) ^ (tokenBytes[i] ?? 0); - } - if (diff === 0) matched = wh; + if (timingSafeEqual(candidate, tokenBytes)) matched = wh; } if (!matched) { diff --git a/src/admin/timing-safe.ts b/src/admin/timing-safe.ts new file mode 100644 index 0000000..71b286d --- /dev/null +++ b/src/admin/timing-safe.ts @@ -0,0 +1,19 @@ +/** + * Constant-time byte-array comparison. + * + * Single implementation shared by password verification (auth/passwords.ts), + * CSRF token matching (auth/csrf.ts), and incoming-webhook token checks + * (public-api.ts) — three copies of security-critical code were a drift risk + * (Q-2, Aug 2026 quality audit). + * + * Runs for max(a.length, b.length) iterations regardless of content and + * captures a length difference in the initial XOR — no early returns. + */ +export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + const maxLen = Math.max(a.length, b.length); + let result = a.length ^ b.length; + for (let i = 0; i < maxLen; i++) { + result |= (a[i] ?? 0) ^ (b[i] ?? 0); + } + return result === 0; +} From 1eac5e3c437492d21b1f598d7f61093905fe29ac Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 25 Aug 2026 13:29:33 +0200 Subject: [PATCH 3/8] refactor(quality): share json()/serverError() between admin and public APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q-5 from the Aug 2026 quality audit: public-api.ts and routes/api/_utils.ts each carried their own json() responder, typed error classes, and serverError() mapper with slightly diverging behavior (pretty-printing, charset). Error-handling policy must evolve in lockstep. New src/admin/http.ts is the single implementation; _utils.ts re-exports so all existing route imports keep working. The public API's responses switch from pretty-printed to compact JSON — equivalent for machine consumers. --- src/admin/http.ts | 57 +++++++++++++++++++++++++++++++++ src/admin/public-api.ts | 16 +++------- src/admin/routes/api/_utils.ts | 58 ++++++---------------------------- 3 files changed, 71 insertions(+), 60 deletions(-) create mode 100644 src/admin/http.ts diff --git a/src/admin/http.ts b/src/admin/http.ts new file mode 100644 index 0000000..1d0e3b3 --- /dev/null +++ b/src/admin/http.ts @@ -0,0 +1,57 @@ +/** + * Shared HTTP response helpers for admin API surfaces. + * + * Single home for the JSON responder, typed error classes, and the + * error-to-Response mapper so the admin API and public API evolve their + * error-handling policy in lockstep (Q-5, Aug 2026 quality audit). + * routes/api/_utils.ts re-exports these for existing route imports. + */ + +export function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Typed errors that route handlers can throw to map onto specific + * status codes. Anything else is logged and returned as a generic 500 with + * no internal details, so internal stack-trace-adjacent strings can't leak + * to callers (they're still useful, but admins shouldn't be able to read + * e.g. database errors verbatim). + */ +export class ValidationError extends Error { + override name = "ValidationError"; +} +export class NotFoundError extends Error { + override name = "NotFoundError"; +} +export class PermissionError extends Error { + override name = "PermissionError"; +} + +export function serverError(err: unknown): Response { + console.error("[admin api]", err); + + if (err instanceof ValidationError) { + return json({ error: err.message || "Bad request" }, 400); + } + if (err instanceof NotFoundError) { + return json({ error: err.message || "Not found" }, 404); + } + if (err instanceof PermissionError) { + return json({ error: "Forbidden" }, 403); + } + // Map Deno's filesystem permission errors to 403 — this restores the + // mapping the old server.ts had (lost in the Fresh 2 rewrite). + if (err instanceof Deno.errors.PermissionDenied) { + return json({ error: "Forbidden" }, 403); + } + if (err instanceof Deno.errors.NotFound) { + return json({ error: "Not found" }, 404); + } + + // Generic — never leak err.message to the client. + return json({ error: "Internal server error" }, 500); +} diff --git a/src/admin/public-api.ts b/src/admin/public-api.ts index 87bd3d3..7a48c53 100644 --- a/src/admin/public-api.ts +++ b/src/admin/public-api.ts @@ -23,18 +23,10 @@ import { timingSafeEqual } from "./timing-safe.ts"; // ── Helpers ─────────────────────────────────────────────────────────────────── -function json(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data, null, 2), { - status, - headers: { "Content-Type": "application/json; charset=utf-8" }, - }); -} - -function serverError(err: unknown): Response { - console.error("[dune public-api]", err); - // Never reflect internal error strings on the public, unauthenticated API. - return json({ error: "Internal server error" }, 500); -} +// json()/serverError() shared with the admin API (Q-5) — the public variant +// previously pretty-printed with 2-space indent; compact JSON is equivalent +// for machine consumers and matches the admin API's output. +import { json, serverError } from "./http.ts"; /** Same-origin path only — form.success_url is otherwise an open redirect. */ function safeRedirectPath(target: string, req: Request): string { diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index 57d0e6d..c2dc157 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -7,54 +7,16 @@ import type { AdminPermission, AdminState } from "../../types.ts"; import { highestValidRole } from "../../auth/role-utils.ts"; import { csrfTokenMatches, resolveCsrfSecret } from "../../auth/csrf.ts"; -export function json(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -/** - * Typed errors that admin route handlers can throw to map onto specific - * status codes. Anything else is logged and returned as a generic 500 with - * no internal details, so internal stack-trace-adjacent strings can't leak - * to authenticated callers (they're still useful, but admins shouldn't be - * able to read e.g. database errors verbatim). - */ -export class ValidationError extends Error { - override name = "ValidationError"; -} -export class NotFoundError extends Error { - override name = "NotFoundError"; -} -export class PermissionError extends Error { - override name = "PermissionError"; -} - -export function serverError(err: unknown): Response { - console.error("[admin api]", err); - - if (err instanceof ValidationError) { - return json({ error: err.message || "Bad request" }, 400); - } - if (err instanceof NotFoundError) { - return json({ error: err.message || "Not found" }, 404); - } - if (err instanceof PermissionError) { - return json({ error: "Forbidden" }, 403); - } - // Map Deno's filesystem permission errors to 403 — this restores the - // mapping the old server.ts had (lost in the Fresh 2 rewrite). - if (err instanceof Deno.errors.PermissionDenied) { - return json({ error: "Forbidden" }, 403); - } - if (err instanceof Deno.errors.NotFound) { - return json({ error: "Not found" }, 404); - } - - // Generic — never leak err.message to the client. - return json({ error: "Internal server error" }, 500); -} +// json()/serverError()/typed error classes live in ../../http.ts so the +// public API (public-api.ts) and admin routes share one implementation. +export { + json, + serverError, + ValidationError, + NotFoundError, + PermissionError, +} from "../../http.ts"; +import { json, serverError } from "../../http.ts"; function logAuthzDenial( ctx: FreshContext, From 51c300ce8a65f929f71d15e6b3b2785b6d781935 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 25 Aug 2026 13:32:48 +0200 Subject: [PATCH 4/8] refactor(quality): consistent [dune/] prefixes on console output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q-7 from the Aug 2026 quality audit: ad-hoc console calls used inconsistent prefixes — some carried [dune]/[dune/authz], others were unprefixed with leading indentation. All five unprefixed sites (users bootstrap warnings, plugin/theme install notices) now carry a [dune/users], [dune/plugins], or [dune/themes] tag so log aggregation can filter by subsystem. --- src/admin/auth/users.ts | 4 ++-- src/admin/routes/api/plugins/install.ts | 2 +- src/admin/routes/api/themes/install.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/admin/auth/users.ts b/src/admin/auth/users.ts index bb330c5..457b779 100644 --- a/src/admin/auth/users.ts +++ b/src/admin/auth/users.ts @@ -108,13 +108,13 @@ export function createUserManager(config: UserManagerConfig): UserManager { if (admins.length > 0) { if (admins.length > 1) { - console.warn(` ⚠️ Found ${admins.length} enabled admin users (${admins.map((u) => u.username).join(", ")})`); + console.warn(`[dune/users] ⚠️ Found ${admins.length} enabled admin users (${admins.map((u) => u.username).join(", ")})`); } return { created: false }; } if (allUsers.length > 0) { - console.warn(` ⚠️ Found ${allUsers.length} user(s) but none are enabled admins — creating default admin`); + console.warn(`[dune/users] ⚠️ Found ${allUsers.length} user(s) but none are enabled admins — creating default admin`); } // Generate a random password and write it to a file — never log it. diff --git a/src/admin/routes/api/plugins/install.ts b/src/admin/routes/api/plugins/install.ts index 3eac53f..754f14f 100644 --- a/src/admin/routes/api/plugins/install.ts +++ b/src/admin/routes/api/plugins/install.ts @@ -86,7 +86,7 @@ export const handler = { stringifyYaml(updatedSite).trimEnd() + "\n", ); - console.log(` 🔌 Plugin "${name}" (${jsr}) added to site.yaml`); + console.log(`[dune/plugins] 🔌 Plugin "${name}" (${jsr}) added to site.yaml`); return json({ installed: true, name, jsr }); } catch (err) { return serverError(err); diff --git a/src/admin/routes/api/themes/install.ts b/src/admin/routes/api/themes/install.ts index 9fb1ae6..0007d59 100644 --- a/src/admin/routes/api/themes/install.ts +++ b/src/admin/routes/api/themes/install.ts @@ -163,7 +163,7 @@ export const handler = { return json({ installed: false, reason: "already installed", slug, jsr: entry.jsr }); } - console.log(` 📦 Registered theme package "${slug}" (${entry.jsr}) in site.yaml`); + console.log(`[dune/themes] 📦 Registered theme package "${slug}" (${entry.jsr}) in site.yaml`); return json({ success: true, slug, @@ -180,7 +180,7 @@ export const handler = { }, 502); } const { filesWritten } = await installZipTheme(storage, slug, entry); - console.log(` 📦 Installed theme "${slug}" (${filesWritten} files)`); + console.log(`[dune/themes] 📦 Installed theme "${slug}" (${filesWritten} files)`); return json({ success: true, slug, filesWritten, method: "zip" }); } From 038c0a5d409bb4e6450adaf579201530ac8b3fa3 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 25 Aug 2026 13:37:42 +0200 Subject: [PATCH 5/8] refactor(quality): extract adminCss() out of _layout.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q-6 from the Aug 2026 quality audit: _layout.tsx was 762 lines, ~530 of which were the embedded stylesheet string inside adminCss(). Moving it to routes/admin-css.ts cuts the layout component to ~230 lines and gives the CSS its own reviewable unit. No output change — same string, same call sites. --- src/admin/routes/_layout.tsx | 532 +-------------------------------- src/admin/routes/admin-css.ts | 536 ++++++++++++++++++++++++++++++++++ 2 files changed, 537 insertions(+), 531 deletions(-) create mode 100644 src/admin/routes/admin-css.ts diff --git a/src/admin/routes/_layout.tsx b/src/admin/routes/_layout.tsx index 882eb4e..9f0f94c 100644 --- a/src/admin/routes/_layout.tsx +++ b/src/admin/routes/_layout.tsx @@ -16,6 +16,7 @@ import { toAdminRelative, } from "./_middleware.ts"; import { csrfTokenFromState } from "../auth/csrf.ts"; +import { adminCss } from "./admin-css.ts"; function csrfMeta(state: AdminState) { const token = csrfTokenFromState(state); @@ -229,534 +230,3 @@ function sidebarScript(): string { `; } -function adminCss(rtl: boolean): string { - return ` - *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - :root { - --sidebar-w: 220px; - --topbar-h: 52px; - --accent: #4f46e5; - --accent-hover: #4338ca; - --bg: #f8f9fa; - --surface: #ffffff; - --border: #e2e8f0; - --text: #1a202c; - --text-muted: #718096; - --danger: #e53e3e; - } - body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); } - a { color: inherit; text-decoration: none; } - - /* Layout */ - .admin-layout { display: flex; min-height: 100vh; } - .admin-sidebar { - width: var(--sidebar-w); min-height: 100vh; background: var(--surface); - border-${rtl ? "left" : "right"}: 1px solid var(--border); - display: flex; flex-direction: column; flex-shrink: 0; - position: sticky; top: 0; height: 100vh; overflow-y: auto; - } - .admin-main { flex: 1; display: flex; flex-direction: column; min-width: 0; } - .admin-content { flex: 1; padding: 24px; } - - /* Sidebar */ - .sidebar-brand { - display: flex; align-items: center; justify-content: space-between; - padding: 16px; font-size: 18px; font-weight: 700; border-bottom: 1px solid var(--border); - } - .sidebar-brand a { color: var(--text); } - .sidebar-close { display: none; background: none; border: none; cursor: pointer; font-size: 18px; } - .sidebar-nav { padding: 12px 0; } - .nav-group { margin-bottom: 4px; } - .nav-group-label { - padding: 6px 16px 2px; font-size: 10px; font-weight: 600; - text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); - } - .nav-item { - display: flex; align-items: center; gap: 10px; - padding: 8px 16px; border-radius: 6px; margin: 0 8px; - color: var(--text); font-size: 14px; transition: background .15s; - } - .nav-item:hover { background: var(--bg); } - .nav-item.active { background: #eef2ff; color: var(--accent); font-weight: 500; } - - /* Top bar */ - .admin-topbar { - height: var(--topbar-h); display: flex; align-items: center; justify-content: space-between; - padding: 0 20px; background: var(--surface); border-bottom: 1px solid var(--border); - position: sticky; top: 0; z-index: 10; - } - .sidebar-toggle { - background: none; border: none; cursor: pointer; font-size: 20px; padding: 4px 8px; - } - .topbar-right { display: flex; align-items: center; gap: 12px; } - .topbar-user { font-size: 14px; color: var(--text-muted); } - - /* Buttons */ - .btn { - display: inline-flex; align-items: center; gap: 6px; - padding: 8px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; - border: 1px solid var(--border); background: var(--surface); cursor: pointer; - color: var(--text); transition: background .15s; - } - .btn:hover { background: var(--bg); } - .btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); } - .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } - .btn-sm { padding: 4px 10px; font-size: 13px; } - .btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); } - - /* Tables */ - .admin-table { width: 100%; border-collapse: collapse; font-size: 14px; } - .admin-table th { text-align: ${ - rtl ? "right" : "left" - }; padding: 10px 12px; background: var(--bg); border-bottom: 1px solid var(--border); font-weight: 600; } - .admin-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); } - .admin-table tr:hover td { background: var(--bg); } - - /* Forms */ - .form-group { margin-bottom: 16px; } - label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 6px; } - input[type=text], input[type=email], input[type=password], select, textarea { - width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; - font-size: 14px; background: var(--surface); - } - input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; } - - /* Alerts */ - .alert { padding: 10px 14px; border-radius: 6px; font-size: 14px; margin-bottom: 16px; } - .alert-error { background: #fff5f5; border: 1px solid #fed7d7; color: var(--danger); } - .alert-success { background: #f0fff4; border: 1px solid #c6f6d5; color: #276749; } - - /* Stats */ - .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; } - .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } - .stat-number { font-size: 28px; font-weight: 700; color: var(--accent); } - .stat-label { font-size: 13px; color: var(--text-muted); margin-top: 4px; } - - /* Badges */ - .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; } - .badge-md { background: #ebf8ff; color: #2b6cb0; } - .badge-mdx { background: #f0fff4; color: #276749; } - .badge-tsx { background: #faf5ff; color: #6b46c1; } - - /* Overlay + mobile sidebar */ - .sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 40; } - .sidebar-overlay.visible { display: block; } - @media (max-width: 768px) { - .admin-sidebar { - position: fixed; ${rtl ? "right" : "left"}: 0; top: 0; z-index: 50; - transform: translate${rtl ? "X(100%)" : "X(-100%)"}; - transition: transform .25s; - } - .admin-sidebar.open { transform: translateX(0); } - .sidebar-close { display: block; } - } - @media (min-width: 769px) { .sidebar-toggle { display: none; } } - - /* Island mount targets */ - .island-root { min-height: 60px; } - - /* Page editor (islands/PageEditor.tsx) — was entirely unstyled: no rule - anywhere gave .editor-body a height, so the textarea's own - height:100% resolved against an auto-height parent and collapsed to - the browser's default ~2-row textarea instead of filling the pane. */ - .editor-layout { display: flex; flex-direction: column; height: 100%; } - .editor-toolbar { - display: flex; align-items: center; justify-content: space-between; - gap: 8px; flex-wrap: wrap; flex-shrink: 0; - padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--surface); - } - .toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; min-width: 0; } - .editor-title { - font-weight: 600; font-size: 14px; max-width: 320px; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - } - .toolbar-dirty { - font-size: 12px; color: #b45309; background: #fffbeb; - border: 1px solid #fde68a; border-radius: 4px; padding: 2px 8px; - } - .editor-body { display: flex; flex: 1; min-height: 0; } - .editor-sidebar { - width: 260px; flex-shrink: 0; overflow-y: auto; - padding: 16px; border-${ - rtl ? "left" : "right" - }: 1px solid var(--border); background: var(--surface); - } - .editor-sidebar h4 { - font-size: 12px; font-weight: 600; text-transform: uppercase; - letter-spacing: .06em; color: var(--text-muted); margin-bottom: 14px; - } - .editor-main { flex: 1; min-width: 0; display: flex; } - .editor-main-split { flex: 0 0 50%; } - .editor-content { flex: 1; } - .editor-preview { - flex: 1; min-width: 0; display: flex; - border-${rtl ? "right" : "left"}: 1px solid var(--border); - } - .raw-frontmatter summary { cursor: pointer; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 6px; } - .raw-frontmatter-view { - margin-top: 8px; font-family: monospace; font-size: 12px; resize: vertical; - background: var(--bg); color: var(--text-muted); - } - .raw-frontmatter-hint { font-size: 12px; color: var(--text-muted); margin-top: 8px; } - .translation-links { display: flex; flex-direction: column; gap: 6px; } - .translation-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; } - .badge-warning { background: #fffbeb; color: #b45309; } - .badge-lang { background: #eef2ff; color: #4338ca; text-transform: uppercase; } - @media (max-width: 900px) { - .editor-body { flex-direction: column; } - .editor-sidebar { width: 100%; border-${ - rtl ? "left" : "right" - }: none; border-bottom: 1px solid var(--border); } - } - - /* Dismissable toast (PageEditor, PageBuilder) */ - .toast { - padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; - box-shadow: 0 4px 12px rgba(0,0,0,.15); - } - .toast-error { background: #fff5f5; border: 1px solid #fed7d7; color: var(--danger); } - - /* Section headers */ - .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } - .section-header h2 { font-size: 20px; font-weight: 600; } - - /* Metrics dashboard */ - .metric-card { border: 1px solid var(--border); border-radius: 8px; padding: 1rem; background: var(--surface); } - .metric-card-highlight { border-color: #fed7d7; background: #fff5f5; } - .metric-card-value { font-size: 1.5rem; font-weight: 700; color: #2d3748; } - .metric-card-value-highlight { color: var(--danger); } - .bar-track { flex: 1; height: 8px; background: #e2e8f0; border-radius: 4px; } - .bar-fill { height: 8px; border-radius: 4px; } - .bar-fill-ok { background: var(--accent); } - .bar-fill-warn { background: #f59e0b; } - .bar-fill-danger { background: var(--danger); } - .text-danger { color: var(--danger); } - - /* Buttons — additional variants referenced across admin islands but never - previously defined (the underlying .btn base already existed). */ - .btn:disabled { opacity: .5; cursor: not-allowed; } - .btn-outline { background: transparent; } - .btn-xs { padding: 2px 8px; font-size: 12px; } - .btn-enabled { background: #ecfdf5; color: #059669; border-color: #a7f3d0; } - .btn-disabled { background: #fef2f2; color: #b91c1c; border-color: #fecaca; } - - /* Badges — additional variants (badge-md/mdx/tsx already existed above) */ - .badge-fmt { background: #edf2f7; color: #4a5568; text-transform: uppercase; font-size: 10px; } - .badge-draft { background: #fffbeb; color: #b45309; } - .badge-latest { background: #ecfdf5; color: #059669; } - - /* Forms — additional pieces (.form-group/label/inputs already existed above) */ - .form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } - .form-error { color: var(--danger); font-size: 13px; margin-bottom: 12px; } - .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; } - - /* Modals (PageTree create dialog, MediaLibrary detail view) */ - .modal { position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; padding: 20px; } - .modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.5); } - .modal-content { - position: relative; background: var(--surface); border-radius: 10px; padding: 24px; - width: 100%; max-width: 480px; max-height: 85vh; overflow-y: auto; - box-shadow: 0 10px 40px rgba(0,0,0,.2); - } - .modal-content h3 { font-size: 18px; margin-bottom: 16px; } - .modal-wide { max-width: 720px; } - - /* Pages list (islands/PageTree.tsx) */ - .page-tree-wrap { display: flex; flex-direction: column; gap: 16px; } - .page-tree-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } - .tree-search-form { flex: 1; min-width: 200px; } - .tree-search-form input { width: 100%; } - .page-tree { display: flex; flex-direction: column; gap: 2px; } - .page-tree-row { - display: flex; align-items: center; gap: 8px; - padding: 8px 12px; border-radius: 6px; transition: background .1s; - } - .page-tree-row:hover { background: var(--bg); } - .tree-toggle { width: 16px; text-align: center; color: var(--text-muted); font-size: 11px; user-select: none; } - .page-tree-title { - flex: 1; min-width: 0; font-weight: 500; color: var(--text); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - } - .page-tree-title:hover { color: var(--accent); } - .tree-route { color: var(--text-muted); font-size: 12px; font-family: ui-monospace, monospace; } - .tree-actions { display: flex; gap: 6px; opacity: 0; transition: opacity .1s; } - .page-tree-row:hover .tree-actions { opacity: 1; } - .tree-children { border-${ - rtl ? "right" : "left" - }: 1px solid var(--border); margin-${rtl ? "right" : "left"}: 8px; padding-${ - rtl ? "right" : "left" - }: 8px; } - - /* Media library (islands/MediaLibrary.tsx) */ - .media-wrap { display: flex; flex-direction: column; gap: 16px; } - .media-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } - .media-toolbar select { width: auto; min-width: 140px; } - .media-search { flex: 1; min-width: 200px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; } - .media-count { color: var(--text-muted); font-size: 13px; margin-${ - rtl ? "right" : "left" - }: auto; } - .media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } - .media-item { - border: 1px solid var(--border); border-radius: 8px; padding: 8px; - cursor: pointer; background: var(--surface); transition: border-color .1s; text-align: center; - } - .media-item:hover { border-color: var(--accent); } - .media-item-selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79,70,229,.15); } - .media-icon { - display: flex; align-items: center; justify-content: center; height: 90px; - font-size: 32px; background: var(--bg); border-radius: 6px; margin-bottom: 8px; overflow: hidden; - } - .media-icon img { max-width: 100%; max-height: 100%; object-fit: cover; } - .media-name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .media-size { font-size: 11px; color: var(--text-muted); } - .media-detail { display: flex; gap: 20px; flex-wrap: wrap; } - .media-detail-preview { flex: 1; min-width: 200px; } - .media-detail-info { flex: 1; min-width: 200px; font-size: 14px; } - .media-detail-info p { margin-bottom: 8px; } - .focal-picker-wrap { position: relative; cursor: crosshair; border-radius: 6px; overflow: hidden; } - .focal-picker-wrap img { width: 100%; display: block; } - - /* Page builder (islands/PageBuilder.tsx) */ - .bld-layout { display: flex; flex-direction: column; height: 100%; } - .bld-toolbar { - display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; - padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--surface); - } - .bld-toolbar-left, .bld-toolbar-right { display: flex; align-items: center; gap: 8px; } - .bld-title { font-weight: 600; font-size: 14px; } - .bld-body { display: flex; flex: 1; min-height: 0; } - .bld-palette { - width: 220px; flex-shrink: 0; overflow-y: auto; padding: 16px; - border-${ - rtl ? "left" : "right" - }: 1px solid var(--border); background: var(--surface); - } - .bld-palette-item { - display: flex; align-items: center; gap: 8px; width: 100%; text-align: ${ - rtl ? "right" : "left" - }; - padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; - background: var(--bg); cursor: pointer; margin-bottom: 6px; font-size: 13px; - } - .bld-palette-item:hover { border-color: var(--accent); } - .bld-palette-icon { - display: inline-flex; align-items: center; justify-content: center; - width: 18px; height: 18px; border-radius: 4px; background: var(--accent); color: #fff; - font-size: 12px; flex-shrink: 0; - } - .bld-canvas { flex: 1; overflow-y: auto; padding: 20px; background: var(--bg); } - .bld-canvas-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); font-size: 14px; } - .bld-section { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; padding: 12px 16px; cursor: pointer; } - .bld-section-selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79,70,229,.15); } - .bld-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; } - .bld-section-type { font-weight: 600; font-size: 13px; } - .bld-section-controls { display: flex; gap: 4px; } - .bld-section-preview { display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: var(--text-muted); } - .bld-settings { - width: 280px; flex-shrink: 0; overflow-y: auto; padding: 16px; - border-${ - rtl ? "right" : "left" - }: 1px solid var(--border); background: var(--surface); - } - .bld-settings h4 { font-size: 14px; margin-bottom: 12px; } - - /* Config editor (islands/ConfigEditor.tsx) */ - .cfg-wrap { display: flex; flex-direction: column; gap: 24px; max-width: 720px; } - .cfg-section { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; } - .cfg-section h3 { font-size: 15px; margin-bottom: 16px; } - - /* Marketplace (islands/Marketplace.tsx) */ - .marketplace-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } - .marketplace-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 10px; } - - /* Themes (islands/ThemeSwitcher.tsx) */ - .theme-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px; cursor: pointer; transition: border-color .1s; } - .theme-card:hover { border-color: var(--accent); } - .theme-card-active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79,70,229,.15); } - - /* Translation memory (islands/TranslationMemory.tsx) */ - .tm-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; } - .tm-tabs { display: flex; gap: 6px; } - .tm-table td, .tm-table th { vertical-align: top; } - - /* Revision history (islands/RevisionHistory.tsx) */ - .revision-wrap { display: flex; gap: 20px; flex-wrap: wrap; } - .revision-timeline { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 10px; } - .revision-item { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px; } - .revision-latest { border-color: var(--accent); } - .revision-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } - .revision-number { font-weight: 600; font-size: 13px; } - .revision-date { color: var(--text-muted); font-size: 12px; margin-${ - rtl ? "right" : "left" - }: auto; } - .revision-message { font-size: 13px; margin-top: 4px; } - .revision-author { font-size: 12px; color: var(--text-muted); } - .revision-actions { display: flex; gap: 6px; margin-top: 8px; } - .revision-content-panel { flex: 2; min-width: 300px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } - - /* Metrics dashboard container (metric-card/-value etc. already existed above) */ - .metrics-wrap { display: flex; flex-direction: column; gap: 20px; } - .metrics-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; } - - /* Workflow panel (islands/WorkflowPanel.tsx) */ - .workflow-panel { display: flex; flex-direction: column; gap: 16px; } - - /* Extracted from inline style="" attributes across admin islands/routes — - plugin-admin's CSP (useNonce: true) drops 'unsafe-inline' from style-src, - and Fresh only auto-nonces