diff --git a/src/app/admin/logs/page.tsx b/src/app/admin/logs/page.tsx index c24ca345..275bc481 100644 --- a/src/app/admin/logs/page.tsx +++ b/src/app/admin/logs/page.tsx @@ -2,6 +2,7 @@ import { getTranslations } from "next-intl/server"; import { ScrollText } from "lucide-react"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { buildStaffActivityWhere } from "@/lib/admin/log-filters"; export const dynamic = "force-dynamic"; @@ -14,7 +15,7 @@ function fromDate(d: Date | null | undefined): string { export default async function AdminStaffActivities({ searchParams, }: { - searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string }>; + searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string; authorization?: string }>; }) { const t = await getTranslations("pages.admin.logs"); @@ -23,27 +24,8 @@ export default async function AdminStaffActivities({ const page = Math.max(1, Number(sp.page ?? "1") || 1); const staffId = sp.staffId ? Number(sp.staffId) : null; const action = sp.action ?? null; - - let whereClause = {}; - if (q) { - whereClause = { - OR: [{ action: { contains: q } }, { description: { contains: q } }, { ipAddress: { contains: q } }], - }; - } - - if (staffId) { - whereClause = { - ...whereClause, - userId: BigInt(staffId), - }; - } - - if (action) { - whereClause = { - ...whereClause, - action: { contains: action }, - }; - } + const authorizationOnly = sp.authorization === "1"; + const whereClause = buildStaffActivityWhere({ q, staffId, action, authorizationOnly }); const activities = await prisma.staffActivities .findMany({ @@ -111,6 +93,10 @@ export default async function AdminStaffActivities({ placeholder={t("searchPlaceholder")} className="flex-1 min-w-[200px]" /> + {t("filter")} - {(q || staffId || action) && ( + {(q || staffId || action || authorizationOnly) && ( {t("clearFilters")} @@ -195,7 +181,7 @@ export default async function AdminStaffActivities({ <> {" · "} {t("next")} → diff --git a/src/app/globals.css b/src/app/globals.css index 14e6c430..7d51803d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -56,6 +56,7 @@ --button-danger-hover-color: #dc2626; --button-outline-color: #eeb425; --button-outline-text-color: #1a1a2e; + --button-outline-text-color-readable: #1a1a2e; --button-outline-hover-color: #d4a01f; --border-radius: 12px; --border-color: #eeb425; @@ -73,6 +74,11 @@ --link-hover-color: #d4a01f; --link-color-readable: #000000; --link-hover-color-readable: #000000; + --color-surface-text-readable: #0f172a; + --color-dropdown-text-readable: #0f172a; + --color-pill-text-readable: #0f172a; + --color-badge-text-readable: #000000; + --color-overlay-text-readable: #ffffff; --input-border-color: #d1d5db; --input-focus-color: #eeb425; --navbar-height: 56px; @@ -164,7 +170,7 @@ body { } .dropdown-item { @apply block py-2.5 px-4 text-sm font-semibold transition-all duration-100 rounded-lg mx-1; - color: var(--color-text-readable, var(--color-text)); + color: var(--color-dropdown-text-readable, var(--color-text-readable)); } .dropdown-item:hover { background-color: color-mix(in srgb, var(--color-primary) 12%, transparent); @@ -324,7 +330,7 @@ a:hover { .btn-outline { background: transparent; border-color: var(--button-outline-color); - color: #8a6a05; + color: var(--button-outline-text-color-readable, var(--button-outline-text-color)); } input, select, @@ -385,6 +391,7 @@ tbody tr:hover { border-radius: 999px; background: #f8fafc; border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent); + color: var(--color-pill-text-readable, var(--color-text-readable)); } .currency .coin { width: 22px; diff --git a/src/components/theme-vars.tsx b/src/components/theme-vars.tsx index ea065eb1..7c227cba 100644 --- a/src/components/theme-vars.tsx +++ b/src/components/theme-vars.tsx @@ -1,6 +1,6 @@ import { siteSettings } from "@/lib/services/site-settings"; import { FONTS } from "@/lib/theme-presets"; -import { readableColor } from "@/lib/theme-contrast"; +import { derivePublicForegrounds, readableColor } from "@/lib/theme-contrast"; // Injects the DB-driven CSS custom properties into :root, exactly like // AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers @@ -109,6 +109,18 @@ export async function ThemeVars() { [safe(primary, "#f59e0b")], ); const readableAccentForeground = readableColor("#ffffff", [safe(accent, "#10b981")]); + const semantic = derivePublicForegrounds({ + color_background: safeBackground, color_surface: safeSurface, + color_dropdown: safe(dropdown, "#ffffff"), color_navbar: safe(navbar, "#ffffff"), + color_navbar_text: safe(navbarText, "#1e293b"), color_text: safe(text, "#0f172a"), + color_text_muted: safe(textMuted, "#64748b"), color_primary: safe(primary, "#f59e0b"), + color_accent: safe(accent, "#10b981"), button_primary_color: safe(buttonColor, "#f59e0b"), + button_text_color: safe(buttonText, "#1e293b"), button_secondary_color: safe(buttonSecondary, "#22c55e"), + button_secondary_text_color: safe(buttonSecondaryText, "#ffffff"), button_danger_color: safe(buttonDanger, "#ef4444"), + button_danger_text_color: safe(buttonDangerText, "#ffffff"), button_outline_color: safe(buttonOutline, "#eeb425"), + button_outline_text_color: safe(buttonOutlineText, "#1a1a2e"), link_color: safe(linkColor, "#eeb425"), + link_hover_color: safe(linkHover, "#cf9d15"), + }); const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito; @@ -151,6 +163,12 @@ export async function ThemeVars() { --button-danger-text-color-readable:${readableDangerText}; --link-color-readable:${readableLink}; --link-hover-color-readable:${readableLinkHover}; +--color-surface-text-readable:${semantic.byName.surface}; +--color-dropdown-text-readable:${semantic.byName.dropdown}; +--button-outline-text-color-readable:${semantic.byName["button-outline"]}; +--color-pill-text-readable:${semantic.byName.pill}; +--color-badge-text-readable:${semantic.byName.badge}; +--color-overlay-text-readable:${semantic.byName.overlay}; --border-radius:${px(borderRadius, "12")}px; --font-family:${font.stack}; --size-heading-h1:${px(h1, "30")}px; diff --git a/src/lib/admin/authorization-event.ts b/src/lib/admin/authorization-event.ts new file mode 100644 index 00000000..d8022e88 --- /dev/null +++ b/src/lib/admin/authorization-event.ts @@ -0,0 +1,23 @@ +export interface AuthorizationEvent { + kind: "permission.denied" | "permission.load_error"; + userId: number; + username?: string; + rank: number; + permission?: string; + source: string; + reason: string; + error?: unknown; +} + +const clean = (value: string) => value.replace(/(token|password|secret|cookie|authorization|select|insert|update|delete)[^\s]*/gi, "[REDACTED]").slice(0, 160); + +export function authorizationActivity(event: AuthorizationEvent) { + const description = [ + `user=${clean(event.username ?? String(event.userId))}`, + `rank=${event.rank}`, + event.permission ? `permission=${clean(event.permission)}` : null, + `source=${clean(event.source)}`, + `reason=${clean(event.reason)}`, + ].filter(Boolean).join("; "); + return { staffId: event.userId, action: event.kind, description, targetType: "user", targetId: event.userId }; +} diff --git a/src/lib/admin/authorization-events.test.ts b/src/lib/admin/authorization-events.test.ts new file mode 100644 index 00000000..3d74d57d --- /dev/null +++ b/src/lib/admin/authorization-events.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { authorizationActivity } from "@/lib/admin/authorization-event"; + +describe("authorizationActivity", () => { + it("creates a safe rank-aware denial record", () => { + const record = authorizationActivity({ kind: "permission.denied", userId: 42, username: "admin", rank: 11, permission: "admin.logs.view", source: "/admin/logs", reason: "missing permission" }); + expect(record.action).toBe("permission.denied"); + expect(record.description).toContain("rank=11"); + expect(record.description).toContain("permission=admin.logs.view"); + }); + + it("redacts secrets and technical details", () => { + const record = authorizationActivity({ kind: "permission.load_error", userId: 42, rank: 11, source: "permissions", reason: "token=abc password=hunter2 SELECT * FROM users" }); + expect(record.description).not.toMatch(/abc|hunter2|SELECT/i); + }); +}); diff --git a/src/lib/admin/authorization-events.ts b/src/lib/admin/authorization-events.ts new file mode 100644 index 00000000..8227aa22 --- /dev/null +++ b/src/lib/admin/authorization-events.ts @@ -0,0 +1,9 @@ +import { logServerError } from "@/lib/server-log"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { authorizationActivity, type AuthorizationEvent } from "@/lib/admin/authorization-event"; + +export async function logAuthorizationEvent(event: AuthorizationEvent): Promise { + const correlationId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + await logStaffActivity({ ...authorizationActivity(event), description: `${authorizationActivity(event).description}; correlation=${correlationId}` }); + if (event.error) logServerError(event.kind, event.error, { correlationId, userId: event.userId, rank: event.rank, permission: event.permission ?? null, source: event.source }); +} diff --git a/src/lib/admin/log-filters.test.ts b/src/lib/admin/log-filters.test.ts new file mode 100644 index 00000000..747a8a02 --- /dev/null +++ b/src/lib/admin/log-filters.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { buildStaffActivityWhere } from "@/lib/admin/log-filters"; + +describe("buildStaffActivityWhere", () => { + it("filters authorization events by prefix", () => { + expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({ action: { startsWith: "permission." } }); + }); + it("combines staff and search filters", () => { + const result = buildStaffActivityWhere({ q: "rank", staffId: 11, authorizationOnly: true }); + expect(result).toMatchObject({ userId: 11n, action: { startsWith: "permission." } }); + expect(result.OR).toHaveLength(3); + }); +}); diff --git a/src/lib/admin/log-filters.ts b/src/lib/admin/log-filters.ts new file mode 100644 index 00000000..cb4d930a --- /dev/null +++ b/src/lib/admin/log-filters.ts @@ -0,0 +1,16 @@ +import type { Prisma } from "@/generated/prisma/client"; + +export interface StaffActivityFilters { q?: string; staffId?: number | null; action?: string | null; authorizationOnly?: boolean } + +export function buildStaffActivityWhere(filters: StaffActivityFilters): Prisma.StaffActivitiesWhereInput { + const where: Prisma.StaffActivitiesWhereInput = {}; + if (filters.q?.trim()) where.OR = [ + { action: { contains: filters.q.trim() } }, + { description: { contains: filters.q.trim() } }, + { ipAddress: { contains: filters.q.trim() } }, + ]; + if (filters.staffId) where.userId = BigInt(filters.staffId); + if (filters.authorizationOnly) where.action = { startsWith: "permission." }; + else if (filters.action) where.action = { contains: filters.action }; + return where; +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 8d1554a9..ae24a90c 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -4,6 +4,7 @@ import { cache } from 'react' import { auth } from './auth' import { sessionUserId } from './auth/session-user' import { prisma } from './prisma' +import { logAuthorizationEvent } from './admin/authorization-events' // Re-export PERMS from the standalone file (safe for client components) export { PERMS } from './permission-slugs' @@ -82,7 +83,8 @@ export const loadUserPermissions = cache(async function loadUserPermissions( hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)), isSuperAdmin: false, } - } catch { + } catch (error) { + await logAuthorizationEvent({ kind: 'permission.load_error', userId, rank, source: 'loadUserPermissions', reason: 'ACL query failed', error }) // Fail-closed: return empty set on any error return createEmptySet() } diff --git a/src/lib/safe-action.ts b/src/lib/safe-action.ts index ba65d959..1f3750c0 100644 --- a/src/lib/safe-action.ts +++ b/src/lib/safe-action.ts @@ -2,6 +2,7 @@ import type { z } from 'zod' import { auth } from '@/lib/auth' import { canAccess, getApiAdminContext } from '@/lib/permissions' import { type ActionResult, actionError, handleActionError } from '@/lib/safe-action-shared' +import { logAuthorizationEvent } from '@/lib/admin/authorization-events' export type { ActionResult } @@ -42,6 +43,11 @@ export function adminAction( if (options.permission) { if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) { + await logAuthorizationEvent({ + kind: 'permission.denied', userId: apiCtx.session.user.id, + username: apiCtx.session.user.name ?? undefined, rank: apiCtx.session.user.rank, + permission: options.permission, source: 'adminAction', reason: 'Permission check denied', + }) return actionError('Unauthorized') } } diff --git a/src/lib/theme-contrast.test.ts b/src/lib/theme-contrast.test.ts index 715fc1bc..5b14b5be 100644 --- a/src/lib/theme-contrast.test.ts +++ b/src/lib/theme-contrast.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { contrastRatio, readableColor } from "@/lib/theme-contrast"; +import { contrastRatio, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast"; import { PRESETS } from "@/lib/theme-presets"; describe("contrastRatio", () => { @@ -64,3 +64,27 @@ describe("readableColor", () => { expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe("rgb(1, 2, 3)"); }); }); + +describe("derivePublicForegrounds", () => { + it.each(Object.entries(PRESETS))("makes every semantic pair readable for %s", (_name, preset) => { + const result = derivePublicForegrounds(preset); + for (const pair of result.pairs) { + expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5); + } + }); + + it("repairs hostile white-on-white custom colors", () => { + const result = derivePublicForegrounds({ + color_background: "#ffffff", color_surface: "#ffffff", color_dropdown: "#ffffff", + color_navbar: "#ffffff", color_navbar_text: "#ffffff", color_text: "#ffffff", + color_text_muted: "#ffffff", color_primary: "#ffffff", color_accent: "#ffffff", + button_primary_color: "#ffffff", button_text_color: "#ffffff", + button_secondary_color: "#ffffff", button_secondary_text_color: "#ffffff", + button_danger_color: "#ffffff", button_danger_text_color: "#ffffff", + button_outline_color: "#ffffff", button_outline_text_color: "#ffffff", + link_color: "#ffffff", link_hover_color: "#ffffff", + }); + expect(result.pairs.length).toBeGreaterThan(10); + for (const pair of result.pairs) expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5); + }); +}); diff --git a/src/lib/theme-contrast.ts b/src/lib/theme-contrast.ts index 3d103947..45197166 100644 --- a/src/lib/theme-contrast.ts +++ b/src/lib/theme-contrast.ts @@ -1,5 +1,40 @@ type Rgb = readonly [number, number, number]; +export type PublicThemeColors = Record; +export interface ContrastPair { name: string; foreground: string; background: string } + +export function derivePublicForegrounds(c: PublicThemeColors) { + const value = (key: string, fallback: string) => c[key] ?? fallback; + const background = value("color_background", "#f8fafc"); + const surface = value("color_surface", "#ffffff"); + const dropdown = value("color_dropdown", surface); + const navbar = value("color_navbar", surface); + const primary = value("color_primary", "#f59e0b"); + const accent = value("color_accent", "#10b981"); + const make = (name: string, preferred: string, bg: string): ContrastPair => ({ + name, foreground: readableColor(preferred, [bg]), background: bg, + }); + const pairs = [ + make("body", value("color_text", "#0f172a"), background), + make("surface", value("color_text", "#0f172a"), surface), + make("muted", value("color_text_muted", "#64748b"), surface), + make("link", value("link_color", primary), surface), + make("link-hover", value("link_hover_color", primary), surface), + make("navbar", value("color_navbar_text", "#1e293b"), navbar), + make("dropdown", value("color_text", "#0f172a"), dropdown), + make("primary", value("button_text_color", "#1e293b"), primary), + make("accent", "#ffffff", accent), + make("button-primary", value("button_text_color", "#1e293b"), value("button_primary_color", primary)), + make("button-secondary", value("button_secondary_text_color", "#ffffff"), value("button_secondary_color", "#22c55e")), + make("button-danger", value("button_danger_text_color", "#ffffff"), value("button_danger_color", "#ef4444")), + make("button-outline", value("button_outline_text_color", "#1a1a2e"), surface), + make("pill", value("color_text", "#0f172a"), surface), + make("badge", value("button_text_color", "#1e293b"), primary), + make("overlay", "#ffffff", "#111827"), + ]; + return { pairs, byName: Object.fromEntries(pairs.map((pair) => [pair.name, pair.foreground])) }; +} + export function contrastRatio(foreground: string, background: string): number | null { const fg = parseHex(foreground); const bg = parseHex(background);