From 50b8487ff51140b1d2446d7b7af6fd9f742e81cf Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 30 Jun 2026 12:18:51 +0900 Subject: [PATCH] feat(panel): wire role-switcher into the app shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the React consumer over the fail-closed view-mode logic so an admin can view the app as each of the three homes (User/Admin/SysAdmin) and step down to a plain User-Side home. - ViewModeProvider holds the raw requested home (seeded from localStorage, shape-checked only) and resolves it live against is_admin on every render, so a demotion or transient /me failure collapses to the User home with no flash, while an unentitled value is never stored or applied. - RoleSwitcher renders only for admins (availableViewModes > 1); switching re-gates the choice and navigates to the chosen home's root. - AppShell drives its sidebar from sectionsForView(view, isAdmin), which only ever narrows visibleSections — an admin viewing as a user sees a plain user's sidebar and lands on the Dashboard at /. - landingPathForView / viewModeLabelKey added to the logic layer (tested); view_* and view_switch_label i18n keys added for en-US and zh-CN. Placement note: the spec calls for a top-right avatar control, but the panel has no desktop top bar, so the switcher lives in the sidebar foot beside the user strip. Functionally complete; placement is not yet spec-parity. --- panel/src/App.tsx | 6 + panel/src/components/AppShell.tsx | 23 ++-- panel/src/components/RoleSwitcher.tsx | 69 +++++++++++ .../src/i18n/resources/en-US/navigation.json | 6 +- .../src/i18n/resources/zh-CN/navigation.json | 6 +- panel/src/lib/viewmode-store.tsx | 117 ++++++++++++++++++ panel/src/lib/viewmode.test.ts | 30 +++++ panel/src/lib/viewmode.ts | 29 +++++ 8 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 panel/src/components/RoleSwitcher.tsx create mode 100644 panel/src/lib/viewmode-store.tsx diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 6904a38..421a30c 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -1,6 +1,7 @@ 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"; @@ -24,6 +25,10 @@ export default function App() { return ( + {/* 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). */} + {/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE @@ -64,6 +69,7 @@ export default function App() { + ); diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index 5fc3b8d..8744e5f 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -5,8 +5,11 @@ import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; import { useTier } from "@/lib/tier"; -import { visibleSections, type NavSection } from "@/lib/nav"; +import { type NavSection } from "@/lib/nav"; +import { sectionsForView } from "@/lib/viewmode"; +import { useViewMode } from "@/lib/viewmode-store"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; +import { RoleSwitcher } from "@/components/RoleSwitcher"; import { useTheme } from "@/lib/theme"; import { api } from "@/lib/api"; @@ -138,11 +141,14 @@ function ThemeToggle() { export function AppShell() { const { isAdmin } = useTier(); + const { view } = useViewMode(); const { t, i18n } = useTranslation("navigation"); - // 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); + // 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); // Sync document metadata with the active language. useEffect(() => { @@ -176,10 +182,13 @@ export function AppShell() { - {/* 2. Profile Card */} + {/* 2. Role switcher (admin-only; renders nothing for a plain user) */} + + + {/* 3. Profile Card */} - {/* 3. Branding Sign-off (sits tight at the absolute bottom with leading-tight and centered) */} + {/* 4. Branding Sign-off (sits tight at the absolute bottom with leading-tight and centered) */}
{t("common:brand_tagline")}
diff --git a/panel/src/components/RoleSwitcher.tsx b/panel/src/components/RoleSwitcher.tsx new file mode 100644 index 0000000..3189292 --- /dev/null +++ b/panel/src/components/RoleSwitcher.tsx @@ -0,0 +1,69 @@ +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 ( + + ); +} diff --git a/panel/src/i18n/resources/en-US/navigation.json b/panel/src/i18n/resources/en-US/navigation.json index 422926a..9fc0e18 100644 --- a/panel/src/i18n/resources/en-US/navigation.json +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -6,5 +6,9 @@ "admin_servers": "Servers", "admin_images": "Images", "sysadmin_section": "SysAdmin", - "sysadmin_fleet": "Fleet" + "sysadmin_fleet": "Fleet", + "view_switch_label": "Viewing as", + "view_user": "User", + "view_admin": "Admin", + "view_ops": "SysAdmin" } diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json index 99848c7..c35cdbe 100644 --- a/panel/src/i18n/resources/zh-CN/navigation.json +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -6,5 +6,9 @@ "admin_servers": "服务器", "admin_images": "镜像", "sysadmin_section": "系统管理", - "sysadmin_fleet": "全平台服务器" + "sysadmin_fleet": "全平台服务器", + "view_switch_label": "当前视图", + "view_user": "用户", + "view_admin": "管理", + "view_ops": "系统管理" } diff --git a/panel/src/lib/viewmode-store.tsx b/panel/src/lib/viewmode-store.tsx new file mode 100644 index 0000000..af36733 --- /dev/null +++ b/panel/src/lib/viewmode-store.tsx @@ -0,0 +1,117 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useTier } from "./tier"; +import { + availableViewModes, + effectiveViewMode, + parseViewMode, + type ViewMode, +} from "./viewmode"; + +// The React layer over viewmode.ts: it holds the principal's *requested* home and +// hands consumers the *resolved* one, re-gated against the live is_admin flag on +// every render. It mirrors theme.tsx (createContext + Provider + useX hook) but the +// resolution rule is deliberately split into a raw seed and a live gate, because the +// two answer different questions: +// +// * `selected` is the raw intent — whatever home the principal last chose, parsed +// for shape only (parseViewMode), NOT gated. It is seeded from localStorage and +// updated on each switch. It may legitimately hold "ops" even while isAdmin is +// momentarily false (still loading /me, or a transient failure). +// * `view` is the resolved home — effectiveViewMode(selected, isAdmin) recomputed +// every render. THIS is the only value any consumer is allowed to act on, and it +// fails closed: a non-admin, or an admin mid-demotion, always sees "user". +// +// Why split rather than call restoreViewMode once at seed time: restoreViewMode gates +// at the moment it runs, and at boot isAdmin is fail-closed `false` while /me is in +// flight. Gating then would discard an admin's stored "ops" before identity arrives. +// Keeping the raw intent in `selected` and gating live means the admin's chosen home +// is restored the instant isAdmin flips true — and an unentitled value is held below +// "user" the whole time. The persisted value is therefore never trusted on its own; +// it only ever survives as raw intent and is re-gated on every read. + +const VIEW_STORAGE_KEY = "felis.viewmode"; + +type ViewModeState = { + /** The resolved, fail-closed home the principal is actually in. Act on this only. */ + view: ViewMode; + /** The homes this principal may switch into, given the live admin flag. */ + available: ViewMode[]; + /** Request a switch. The next home is re-gated before it is applied or persisted, + * so an unentitled value can never be stored or shown. */ + setView: (next: ViewMode) => void; +}; + +const ViewModeContext = createContext({ + view: "user", + available: ["user"], + setView: () => {}, +}); + +// ---- persistence (SSR- and private-mode-safe, like theme.tsx's matchMedia guards) ---- + +function readStored(): unknown { + if (typeof window === "undefined" || !window.localStorage) return null; + try { + return window.localStorage.getItem(VIEW_STORAGE_KEY); + } catch { + // Storage can throw in private mode or when disabled by policy; treat as absent. + return null; + } +} + +function writeStored(view: ViewMode) { + if (typeof window === "undefined" || !window.localStorage) return; + try { + window.localStorage.setItem(VIEW_STORAGE_KEY, view); + } catch { + // Best-effort: a failed persist just means the choice won't survive a reload. + } +} + +// ---- provider ---- + +export function ViewModeProvider({ children }: { children: ReactNode }) { + const { isAdmin } = useTier(); + + // Seed with the RAW stored intent (shape-checked, not gated) so an admin's stored + // "ops" survives the /me loading window and is restored once isAdmin resolves true. + const [selected, setSelected] = useState(() => + parseViewMode(readStored()), + ); + + // The live gate: resolved every render, so a demotion or transient /me failure + // (isAdmin → false) collapses the home to "user" immediately, with no flash. + const view = effectiveViewMode(selected, isAdmin); + const available = useMemo(() => availableViewModes(isAdmin), [isAdmin]); + + const setView = useCallback( + (next: ViewMode) => { + // Re-gate at the source: only a home the principal is entitled to right now is + // stored or applied, so the app's own writes can never persist an escalation. + const resolved = effectiveViewMode(next, isAdmin); + setSelected(resolved); + writeStored(resolved); + }, + [isAdmin], + ); + + const value = useMemo( + () => ({ view, available, setView }), + [view, available, setView], + ); + + return ( + {children} + ); +} + +export function useViewMode() { + return useContext(ViewModeContext); +} diff --git a/panel/src/lib/viewmode.test.ts b/panel/src/lib/viewmode.test.ts index 9e02971..1e4a232 100644 --- a/panel/src/lib/viewmode.test.ts +++ b/panel/src/lib/viewmode.test.ts @@ -3,9 +3,11 @@ import { VIEW_MODES, availableViewModes, effectiveViewMode, + landingPathForView, parseViewMode, restoreViewMode, sectionsForView, + viewModeLabelKey, type ViewMode, } from "./viewmode"; import { visibleSections } from "./nav"; @@ -162,3 +164,31 @@ describe("sectionsForView (UX ceiling, composed on visibleSections)", () => { } }); }); + +describe("landingPathForView", () => { + it("opens each home at its section root", () => { + expect(landingPathForView("user")).toBe("/"); + expect(landingPathForView("admin")).toBe("/admin"); + expect(landingPathForView("ops")).toBe("/ops"); + }); + + it("falls through to the User home for an out-of-band value", () => { + expect(landingPathForView("nope" as unknown as ViewMode)).toBe("/"); + }); + + it("targets a real, navigable path for every known home", () => { + // Guards against a home being added without a landing route: each must resolve to + // an absolute path the router can reach. + for (const v of VIEW_MODES) { + expect(landingPathForView(v)).toMatch(/^\//); + } + }); +}); + +describe("viewModeLabelKey", () => { + it("derives the navigation i18n key for each home", () => { + expect(viewModeLabelKey("user")).toBe("view_user"); + expect(viewModeLabelKey("admin")).toBe("view_admin"); + expect(viewModeLabelKey("ops")).toBe("view_ops"); + }); +}); diff --git a/panel/src/lib/viewmode.ts b/panel/src/lib/viewmode.ts index e81ea87..cdc8c94 100644 --- a/panel/src/lib/viewmode.ts +++ b/panel/src/lib/viewmode.ts @@ -111,3 +111,32 @@ export function sectionsForView(view: ViewMode, isAdmin: boolean): NavSection[] const ceiling = VIEW_RANK[effectiveViewMode(view, isAdmin)]; return visibleSections(isAdmin).filter((s) => VIEW_RANK[s.id] <= ceiling); } + +/** + * landingPathForView maps a (already-resolved) view to the route its home opens at. + * Switching the role-switcher navigates here so the chosen home shows a meaningful + * page rather than wherever the user happened to be. The admin/ops roots redirect to + * their first concrete view (App.tsx), so naming the section root keeps this list + * short and decoupled from which concrete page is "first". It does NOT gate — callers + * pass a view already resolved by effectiveViewMode — so an out-of-band value falls + * through to the always-safe User home. + */ +export function landingPathForView(view: ViewMode): string { + switch (view) { + case "admin": + return "/admin"; + case "ops": + return "/ops"; + default: + return "/"; + } +} + +/** + * viewModeLabelKey returns the i18n (navigation namespace) key for a view's switcher + * label, keeping the key derivation in one tested place so the menu and any future + * caller never hand-build a key that drifts from the translation files. + */ +export function viewModeLabelKey(view: ViewMode): string { + return `view_${view}`; +}