Revert "feat(panel): wire role-switcher into the app shell"

This reverts commit 50b8487ff5.
This commit is contained in:
Lemon-miaow committed 2026-07-01 00:05:51 +08:00
1 parent 29f5341cad
commit 7507cfaae4
8 files changed
+9 -277

No files matched your search

-6
View File
@@ -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 (
<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
@@ -69,7 +64,6 @@ export default function App() {
</Route>
</Routes>
</BrowserRouter>
</ViewModeProvider>
</TierProvider>
</ThemeProvider>
);
+7 -16
View File
@@ -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() {
</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>
-69
View File
@@ -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 (
<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>
);
}
@@ -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"
}
@@ -6,9 +6,5 @@
"admin_servers": "服务器",
"admin_images": "镜像",
"sysadmin_section": "系统管理",
"sysadmin_fleet": "全平台服务器",
"view_switch_label": "当前视图",
"view_user": "用户",
"view_admin": "管理",
"view_ops": "系统管理"
"sysadmin_fleet": "全平台服务器"
}
-117
View File
@@ -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<ViewModeState>({
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<ViewMode | null>(() =>
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<ViewModeState>(
() => ({ view, available, setView }),
[view, available, setView],
);
return (
<ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>
);
}
export function useViewMode() {
return useContext(ViewModeContext);
}
-30
View File
@@ -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");
});
});
-29
View File
@@ -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}`;
}