diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 421a30c..6904a38 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -1,7 +1,6 @@ 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"; @@ -25,10 +24,6 @@ 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 @@ -69,7 +64,6 @@ export default function App() { - ); diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index 8744e5f..5fc3b8d 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -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"; @@ -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(() => { @@ -182,13 +176,10 @@ export function AppShell() { - {/* 2. Role switcher (admin-only; renders nothing for a plain user) */} - - - {/* 3. Profile Card */} + {/* 2. Profile Card */} - {/* 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) */}
{t("common:brand_tagline")}
diff --git a/panel/src/components/RoleSwitcher.tsx b/panel/src/components/RoleSwitcher.tsx deleted file mode 100644 index 3189292..0000000 --- a/panel/src/components/RoleSwitcher.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 9fc0e18..422926a 100644 --- a/panel/src/i18n/resources/en-US/navigation.json +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -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" } diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json index c35cdbe..99848c7 100644 --- a/panel/src/i18n/resources/zh-CN/navigation.json +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -6,9 +6,5 @@ "admin_servers": "服务器", "admin_images": "镜像", "sysadmin_section": "系统管理", - "sysadmin_fleet": "全平台服务器", - "view_switch_label": "当前视图", - "view_user": "用户", - "view_admin": "管理", - "view_ops": "系统管理" + "sysadmin_fleet": "全平台服务器" } diff --git a/panel/src/lib/viewmode-store.tsx b/panel/src/lib/viewmode-store.tsx deleted file mode 100644 index af36733..0000000 --- a/panel/src/lib/viewmode-store.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 1e4a232..9e02971 100644 --- a/panel/src/lib/viewmode.test.ts +++ b/panel/src/lib/viewmode.test.ts @@ -3,11 +3,9 @@ import { VIEW_MODES, availableViewModes, effectiveViewMode, - landingPathForView, parseViewMode, restoreViewMode, sectionsForView, - viewModeLabelKey, type ViewMode, } from "./viewmode"; import { visibleSections } from "./nav"; @@ -164,31 +162,3 @@ 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 cdc8c94..e81ea87 100644 --- a/panel/src/lib/viewmode.ts +++ b/panel/src/lib/viewmode.ts @@ -111,32 +111,3 @@ 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}`; -}