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}`;
-}