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