"use client"; /** * `` — sidebar + main column shell for the v1.5 admin split. * * Mirrors `` (`src/components/settings/settings-shell.tsx`): * each section lives at its own route under `/admin/[section]/page.tsx` and * mounts inside this shell. The shell only owns navigation; the page itself * renders the section heading and content. * * Mobile-first per `docs/ui-guidelines.md`: * - =md: sticky 220px sidebar + content column with `max-w-screen-xl`. */ import * as React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Bell, Blocks, Database, FileText, Info, KeyRound, Plug, Radio, ScrollText, Server, Settings, ShieldAlert, ShieldCheck, Sparkles, Ticket, Users, type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader } from "@/components/ui/page-header"; import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs"; interface AdminSection { slug: AdminSectionSlug; /** i18n key under `admin.section..title`. */ titleKey: string; icon: LucideIcon; group: AdminSectionGroup; } type AdminSectionGroup = "system" | "connectivity" | "ai" | "account" | "yourData"; const ADMIN_SECTION_GROUP_LABEL_KEYS: Record = { system: "settings.shell.groups.system", connectivity: "settings.shell.groups.connectivity", ai: "settings.shell.groups.ai", account: "settings.shell.groups.account", yourData: "settings.shell.groups.yourData", }; /** * Source of truth for the admin section list. Order matches the in-app * navigation order so the sidebar, mobile strip, and `generateStaticParams()` * all line up. Don't reorder without updating tests. */ export const ADMIN_SECTIONS: readonly AdminSection[] = [ { slug: "system-status", titleKey: "admin.section.system-status.title", icon: Server, group: "system", }, { slug: "general", titleKey: "admin.section.general.title", icon: Settings, group: "system", }, { slug: "services", titleKey: "admin.section.services.title", icon: Radio, group: "system", }, // v1.4.36 W4e — About / version / update check folded into Admin. { slug: "about", titleKey: "admin.section.about.title", icon: Info, group: "system", }, { slug: "integrations", titleKey: "admin.section.integrations.title", icon: Plug, group: "connectivity", }, { slug: "api-tokens", titleKey: "admin.section.api-tokens.title", icon: KeyRound, group: "connectivity", }, // v1.18.1 — single "Coach" entry replacing the former ai-quality, // assistant, and coach-feedback trio. The section body stacks all // three surfaces so the coach-quality picture lives in one place. { slug: "coach", titleKey: "admin.section.coach.title", icon: Sparkles, group: "ai", }, // v1.18.6 (W9) — server-wide module availability split out of Coach into // its own entry, sitting next to it. { slug: "module-availability", titleKey: "admin.section.module-availability.title", icon: Blocks, group: "ai", }, { slug: "reminders", titleKey: "admin.section.reminders.title", icon: Bell, group: "ai", }, { slug: "users", titleKey: "admin.section.users.title", icon: Users, group: "account", }, // v1.16.0 — invites as their own section, directly after Users // (admission lives next to the accounts it creates). { slug: "invites", titleKey: "admin.section.invites.title", icon: Ticket, group: "account", }, { slug: "login-overview", titleKey: "admin.section.login-overview.title", icon: ScrollText, group: "account", }, { slug: "app-logs", titleKey: "admin.section.app-logs.title", icon: FileText, group: "yourData", }, { slug: "backups", titleKey: "admin.section.backups.title", icon: Database, group: "yourData", }, // v1.23 — encryption coverage + key-rotation status / safe trigger. { slug: "encryption", titleKey: "admin.section.encryption.title", icon: ShieldCheck, group: "yourData", }, { slug: "danger-zone", titleKey: "admin.section.danger-zone.title", icon: ShieldAlert, group: "yourData", }, ] as const; export interface AdminShellProps { /** * Optional override for the active section. When omitted the shell reads * the active slug from `usePathname()` (the production behaviour). Tests * pass an explicit value so they don't need to mock the router. */ active?: AdminSectionSlug; children: React.ReactNode; } function deriveActiveSlug( pathname: string | null, override?: AdminSectionSlug, ): AdminSectionSlug | null { if (override) return override; if (!pathname) return null; // Match `/admin/` and ignore any trailing segments. The // `/admin` overview page falls through to `null` so no entry is // marked active. const match = pathname.match(/^\/admin\/([^/]+)/); const candidate = match?.[1] ?? ""; return isAdminSectionSlug(candidate) ? candidate : null; } export function AdminShell({ active, children }: AdminShellProps) { const pathname = usePathname(); const { t } = useTranslations(); const { user } = useAuth(); const activeSlug = deriveActiveSlug(pathname, active); const isConfirmedAdmin = user?.role === "ADMIN"; // Apply the existing fail-closed authorization predicate before grouping. // The filtered list is the only render source, so an unavailable item can // never leave a group heading without an allowed destination below it. const visibleSections = isConfirmedAdmin ? ADMIN_SECTIONS : []; // v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so // the active chip stays in view when the user lands on a deeper // admin route. Without it the 16-chip strip stays scrolled to the // left and the active chip lives off-screen, reading as a broken nav. const mobileStripRef = React.useRef(null); React.useEffect(() => { if (typeof window === "undefined") return; const strip = mobileStripRef.current; if (!strip) return; const activeChip = strip.querySelector( '[aria-current="page"]', ); if (!activeChip) return; // Adjust only the strip's own horizontal offset, instantly. The old // `scrollIntoView` walked every scrollable ancestor and could nudge the // document vertically, and a smooth behaviour animated the strip from a // reset `scrollLeft: 0` to the target on every tap — an unwanted "scroll // from the start" sweep. `scrollTo({ left, behavior: "auto" })` confines // the motion to the strip's horizontal axis and jumps without animation. const maxScroll = strip.scrollWidth - strip.clientWidth; const target = Math.max(0, Math.min(activeChip.offsetLeft, maxScroll)); strip.scrollTo({ left: target, behavior: "auto" }); }, [activeSlug]); // The section bodies are already role-gated, but the shell frame // itself (the full section nav in two layouts) used to paint for a // non-admin during the frames between auth resolving and AuthShell's // redirect effect replacing the route. Render nothing — frame AND // children — until the role is confirmed ADMIN. if (!isConfirmedAdmin) return null; // v1.18.6.1 — the heading lives in the shell so it can occupy its own grid // row spanning only the content column; the nav's first item then lines up // with the top of the first card by construction. `/admin` overview // (activeSlug === null) shows the console title; each section shows its own. const headingTitle = activeSlug === null ? t("admin.title") : t(`admin.section.${activeSlug}.title`); const headingSubtitle = activeSlug === null ? t("admin.subtitle") : t(`admin.section.${activeSlug}.subtitle`); // Same two-copy heading as the Settings shell (mobile above the chip strip, // desktop inside the grid row) and the same primitive, so the two consoles // cannot drift a step apart again. const headingBlock = (mobile = false) => ( ); // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. // Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was // producing visibly more top/bottom whitespace on Settings/Admin // pages than on Dashboard/Insights/Measurements. return (
{/* v1.18.6.1 — heading above the chip strip on mobile; on desktop it renders inside the grid (row 1 / content column). */}
{headingBlock(true)}
{/* Mobile section strip — horizontal scroll, hidden on md+. `no-scrollbar` (defined in `globals.css`) suppresses the painted scrollbar at the top of every admin page; the horizontal swipe + keyboard arrow scrolling still work, the bar just doesn't draw. Without this the 13-section strip would render an always-on scrollbar that the maintainer kept mis-attributing to the api-tokens table below it. v1.4.34 IW-G — `snap-x snap-mandatory` plus per-chip `snap-start` lets a swipe-flick land on the next chip's leading edge instead of the in-between dead zone, matching the settings-shell polish. */} {/* v1.18.6.1 — two-row grid: heading in row 1 / col 2, nav in row 2 / col 1, cards in row 2 / col 2. The nav's first item starts the same grid row as the first card, so they align by construction across locales and however the heading wraps — no fixed-height spacer. */}
{/* Heading — desktop only (mobile renders it above the strip). */}
{headingBlock()}
{/* Desktop sticky sidebar — starts on the heading row and spans the two desktop rows. Its top therefore shares the heading's grid line while the content column keeps the natural heading-to-card gap in row 2. The sticky lives on the `
); }