From 7396119ec8d35a69b1ba933949bc6cd996923056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:29:51 +0200 Subject: [PATCH] feat(01-17): group gated admin navigation - cluster allowed destinations under semantic settings-style headings - preserve fail-closed role gating and mobile target order --- src/components/admin/admin-shell.tsx | 154 +++++++++++++++++++-------- 1 file changed, 107 insertions(+), 47 deletions(-) diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 886935a0b..5fe4ab59c 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -47,8 +47,20 @@ interface AdminSection { /** 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()` @@ -59,21 +71,38 @@ 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 @@ -82,6 +111,7 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ 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. @@ -89,16 +119,19 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ 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). @@ -106,43 +139,38 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ slug: "invites", titleKey: "admin.section.invites.title", icon: Ticket, - }, - { - slug: "api-tokens", - titleKey: "admin.section.api-tokens.title", - icon: KeyRound, + 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, - }, - // v1.4.36 W4e — About / version / update check folded into Admin. - { - slug: "about", - titleKey: "admin.section.about.title", - icon: Info, + group: "yourData", }, ] as const; @@ -175,6 +203,11 @@ export function AdminShell({ active, children }: AdminShellProps) { 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 @@ -205,7 +238,7 @@ export function AdminShell({ active, children }: AdminShellProps) { // 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 (!user || user.role !== "ADMIN") return null; + 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 @@ -274,28 +307,41 @@ export function AdminShell({ active, children }: AdminShellProps) { {t("admin.shell.overview")} - {ADMIN_SECTIONS.map((section) => { + {visibleSections.map((section, index) => { const isActive = section.slug === activeSlug; const Icon = section.icon; + const startsNewGroup = + index > 0 && visibleSections[index - 1].group !== section.group; return ( -
  • - -
  • + + {startsNewGroup ? ( + + ) : null} +
  • + +
  • +
    ); })} @@ -348,25 +394,39 @@ export function AdminShell({ active, children }: AdminShellProps) { {t("admin.shell.overview")} - {ADMIN_SECTIONS.map((section) => { + {visibleSections.map((section, index) => { const isActive = section.slug === activeSlug; const Icon = section.icon; + const startsNewGroup = + index === 0 || + visibleSections[index - 1].group !== section.group; return ( -
  • - -
  • + + {startsNewGroup ? ( +
  • + {t(ADMIN_SECTION_GROUP_LABEL_KEYS[section.group])} +
  • + ) : null} +
  • + +
  • +
    ); })}