Loading panel/src/App.tsx +0 −6 Changes for panel/src/App.tsx: 0 added lines, 6 removed lines. Original line number Diff line number Diff line import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { ThemeProvider } from "@/lib/theme"; import { TierProvider } from "@/lib/tier"; import { ViewModeProvider } from "@/lib/viewmode-store"; import { AppShell } from "@/components/AppShell"; import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAuth } from "@/components/RequireAuth"; Loading @@ -25,10 +24,6 @@ export default function App() { return ( <ThemeProvider> <TierProvider> {/* ViewModeProvider sits inside TierProvider (it reads the live is_admin flag) and outside BrowserRouter (it holds no route state; the switcher navigates via useNavigate from within the router). */} <ViewModeProvider> <BrowserRouter> <Routes> {/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE Loading Loading @@ -69,7 +64,6 @@ export default function App() { </Route> </Routes> </BrowserRouter> </ViewModeProvider> </TierProvider> </ThemeProvider> ); Loading panel/src/components/AppShell.tsx +7 −16 Changes for panel/src/components/AppShell.tsx: 7 added lines, 16 removed lines. Original line number Diff line number Diff line Loading @@ -5,11 +5,8 @@ import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; import { useTier } from "@/lib/tier"; import { type NavSection } from "@/lib/nav"; import { sectionsForView } from "@/lib/viewmode"; import { useViewMode } from "@/lib/viewmode-store"; import { visibleSections, type NavSection } from "@/lib/nav"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { RoleSwitcher } from "@/components/RoleSwitcher"; import { useTheme } from "@/lib/theme"; import { api } from "@/lib/api"; Loading Loading @@ -141,14 +138,11 @@ function ThemeToggle() { export function AppShell() { const { isAdmin } = useTier(); const { view } = useViewMode(); const { t, i18n } = useTranslation("navigation"); // Sections are derived from is_admin AND the chosen home: visibleSections drops // every admin-gated section for a non-admin (fail-closed — false while /me loads or // on failure), then the role-switcher's view narrows further to the home the admin // is currently viewing. sectionsForView can only ever narrow, never widen, so an // admin who steps down to the User-home sees a plain user's sidebar. const sections = sectionsForView(view, isAdmin); // Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin // only for admins. isAdmin is fail-closed (false while /me loads or on failure), // so admin sections appear only once identity is confirmed. const sections = visibleSections(isAdmin); // Sync document metadata with the active language. useEffect(() => { Loading Loading @@ -182,13 +176,10 @@ export function AppShell() { </span> </div> {/* 2. Role switcher (admin-only; renders nothing for a plain user) */} <RoleSwitcher /> {/* 3. Profile Card */} {/* 2. Profile Card */} <UserStrip /> {/* 4. Branding Sign-off (sits tight at the absolute bottom with leading-tight and centered) */} {/* 3. Branding Sign-off (sits tight at the absolute bottom with leading-tight and centered) */} <div className="px-1 text-[10px] text-muted-foreground/50 leading-tight text-center"> {t("common:brand_tagline")} </div> Loading panel/src/components/RoleSwitcher.tsxdeleted 100644 → 0 +0 −69 Changes for panel/src/components/RoleSwitcher.tsx: 0 added lines, 69 removed lines. Original line number Diff line number Diff line import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Eye } from "lucide-react"; import { useTier } from "@/lib/tier"; import { useViewMode } from "@/lib/viewmode-store"; import { availableViewModes, effectiveViewMode, landingPathForView, viewModeLabelKey, type ViewMode, } from "@/lib/viewmode"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; // The avatar role-switcher (DESIGN-WEB-3SIDES): an admin can move between the three // homes — User-Side, Admin-Side, SysAdmin-Side — viewing the app as each. The control // is admin-only and renders nothing for everyone else: availableViewModes returns just // ["user"] for a non-admin (and during the fail-closed loading window), so there is // no home to switch into and the whole widget collapses. This is UX, not a gate — the // sidebar narrowing it drives only declutters; every /admin and /ops call is 403-gated // server-side regardless of the chosen view. export function RoleSwitcher() { const { isAdmin } = useTier(); const { view, setView } = useViewMode(); const navigate = useNavigate(); const { t } = useTranslation("navigation"); const modes = availableViewModes(isAdmin); // Only an admin has more than one home; everyone else gets no switcher at all. if (modes.length <= 1) return null; function onChange(raw: string) { // The menu only offers entitled homes, but re-gate anyway: the value crosses a // string boundary and effectiveViewMode is the single authority on what is allowed. const next = effectiveViewMode(raw as ViewMode, isAdmin); setView(next); // Land on the chosen home's root so switching shows a meaningful page rather than // whatever route the principal happened to be on. navigate(landingPathForView(next)); } return ( <Select value={view} onValueChange={onChange}> <SelectTrigger className="h-8 gap-2 px-2.5 text-[13px]" aria-label={t("view_switch_label")} title={t("view_switch_label")} > <span className="flex min-w-0 items-center gap-2"> <Eye className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <SelectValue /> </span> </SelectTrigger> <SelectContent align="start"> {modes.map((m) => ( <SelectItem key={m} value={m}> {t(viewModeLabelKey(m))} </SelectItem> ))} </SelectContent> </Select> ); } panel/src/i18n/resources/en-US/navigation.json +1 −5 Changes for panel/src/i18n/resources/en-US/navigation.json: 1 added line, 5 removed lines. Original line number Diff line number Diff line Loading @@ -6,9 +6,5 @@ "admin_servers": "Servers", "admin_images": "Images", "sysadmin_section": "SysAdmin", "sysadmin_fleet": "Fleet", "view_switch_label": "Viewing as", "view_user": "User", "view_admin": "Admin", "view_ops": "SysAdmin" "sysadmin_fleet": "Fleet" } panel/src/i18n/resources/zh-CN/navigation.json +1 −5 Changes for panel/src/i18n/resources/zh-CN/navigation.json: 1 added line, 5 removed lines. Original line number Diff line number Diff line Loading @@ -6,9 +6,5 @@ "admin_servers": "服务器", "admin_images": "镜像", "sysadmin_section": "系统管理", "sysadmin_fleet": "全平台服务器", "view_switch_label": "当前视图", "view_user": "用户", "view_admin": "管理", "view_ops": "系统管理" "sysadmin_fleet": "全平台服务器" } Loading
panel/src/App.tsx +0 −6 Changes for panel/src/App.tsx: 0 added lines, 6 removed lines. Original line number Diff line number Diff line import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { ThemeProvider } from "@/lib/theme"; import { TierProvider } from "@/lib/tier"; import { ViewModeProvider } from "@/lib/viewmode-store"; import { AppShell } from "@/components/AppShell"; import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAuth } from "@/components/RequireAuth"; Loading @@ -25,10 +24,6 @@ export default function App() { return ( <ThemeProvider> <TierProvider> {/* ViewModeProvider sits inside TierProvider (it reads the live is_admin flag) and outside BrowserRouter (it holds no route state; the switcher navigates via useNavigate from within the router). */} <ViewModeProvider> <BrowserRouter> <Routes> {/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE Loading Loading @@ -69,7 +64,6 @@ export default function App() { </Route> </Routes> </BrowserRouter> </ViewModeProvider> </TierProvider> </ThemeProvider> ); Loading
panel/src/components/AppShell.tsx +7 −16 Changes for panel/src/components/AppShell.tsx: 7 added lines, 16 removed lines. Original line number Diff line number Diff line Loading @@ -5,11 +5,8 @@ import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; import { useTier } from "@/lib/tier"; import { type NavSection } from "@/lib/nav"; import { sectionsForView } from "@/lib/viewmode"; import { useViewMode } from "@/lib/viewmode-store"; import { visibleSections, type NavSection } from "@/lib/nav"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { RoleSwitcher } from "@/components/RoleSwitcher"; import { useTheme } from "@/lib/theme"; import { api } from "@/lib/api"; Loading Loading @@ -141,14 +138,11 @@ function ThemeToggle() { export function AppShell() { const { isAdmin } = useTier(); const { view } = useViewMode(); const { t, i18n } = useTranslation("navigation"); // Sections are derived from is_admin AND the chosen home: visibleSections drops // every admin-gated section for a non-admin (fail-closed — false while /me loads or // on failure), then the role-switcher's view narrows further to the home the admin // is currently viewing. sectionsForView can only ever narrow, never widen, so an // admin who steps down to the User-home sees a plain user's sidebar. const sections = sectionsForView(view, isAdmin); // Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin // only for admins. isAdmin is fail-closed (false while /me loads or on failure), // so admin sections appear only once identity is confirmed. const sections = visibleSections(isAdmin); // Sync document metadata with the active language. useEffect(() => { Loading Loading @@ -182,13 +176,10 @@ export function AppShell() { </span> </div> {/* 2. Role switcher (admin-only; renders nothing for a plain user) */} <RoleSwitcher /> {/* 3. Profile Card */} {/* 2. Profile Card */} <UserStrip /> {/* 4. Branding Sign-off (sits tight at the absolute bottom with leading-tight and centered) */} {/* 3. Branding Sign-off (sits tight at the absolute bottom with leading-tight and centered) */} <div className="px-1 text-[10px] text-muted-foreground/50 leading-tight text-center"> {t("common:brand_tagline")} </div> Loading
panel/src/components/RoleSwitcher.tsxdeleted 100644 → 0 +0 −69 Changes for panel/src/components/RoleSwitcher.tsx: 0 added lines, 69 removed lines. Original line number Diff line number Diff line import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Eye } from "lucide-react"; import { useTier } from "@/lib/tier"; import { useViewMode } from "@/lib/viewmode-store"; import { availableViewModes, effectiveViewMode, landingPathForView, viewModeLabelKey, type ViewMode, } from "@/lib/viewmode"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; // The avatar role-switcher (DESIGN-WEB-3SIDES): an admin can move between the three // homes — User-Side, Admin-Side, SysAdmin-Side — viewing the app as each. The control // is admin-only and renders nothing for everyone else: availableViewModes returns just // ["user"] for a non-admin (and during the fail-closed loading window), so there is // no home to switch into and the whole widget collapses. This is UX, not a gate — the // sidebar narrowing it drives only declutters; every /admin and /ops call is 403-gated // server-side regardless of the chosen view. export function RoleSwitcher() { const { isAdmin } = useTier(); const { view, setView } = useViewMode(); const navigate = useNavigate(); const { t } = useTranslation("navigation"); const modes = availableViewModes(isAdmin); // Only an admin has more than one home; everyone else gets no switcher at all. if (modes.length <= 1) return null; function onChange(raw: string) { // The menu only offers entitled homes, but re-gate anyway: the value crosses a // string boundary and effectiveViewMode is the single authority on what is allowed. const next = effectiveViewMode(raw as ViewMode, isAdmin); setView(next); // Land on the chosen home's root so switching shows a meaningful page rather than // whatever route the principal happened to be on. navigate(landingPathForView(next)); } return ( <Select value={view} onValueChange={onChange}> <SelectTrigger className="h-8 gap-2 px-2.5 text-[13px]" aria-label={t("view_switch_label")} title={t("view_switch_label")} > <span className="flex min-w-0 items-center gap-2"> <Eye className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <SelectValue /> </span> </SelectTrigger> <SelectContent align="start"> {modes.map((m) => ( <SelectItem key={m} value={m}> {t(viewModeLabelKey(m))} </SelectItem> ))} </SelectContent> </Select> ); }
panel/src/i18n/resources/en-US/navigation.json +1 −5 Changes for panel/src/i18n/resources/en-US/navigation.json: 1 added line, 5 removed lines. Original line number Diff line number Diff line Loading @@ -6,9 +6,5 @@ "admin_servers": "Servers", "admin_images": "Images", "sysadmin_section": "SysAdmin", "sysadmin_fleet": "Fleet", "view_switch_label": "Viewing as", "view_user": "User", "view_admin": "Admin", "view_ops": "SysAdmin" "sysadmin_fleet": "Fleet" }
panel/src/i18n/resources/zh-CN/navigation.json +1 −5 Changes for panel/src/i18n/resources/zh-CN/navigation.json: 1 added line, 5 removed lines. Original line number Diff line number Diff line Loading @@ -6,9 +6,5 @@ "admin_servers": "服务器", "admin_images": "镜像", "sysadmin_section": "系统管理", "sysadmin_fleet": "全平台服务器", "view_switch_label": "当前视图", "view_user": "用户", "view_admin": "管理", "view_ops": "系统管理" "sysadmin_fleet": "全平台服务器" }