feat(panel): wire role-switcher into the app shell
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.
This commit is contained in:
8 files changed
+277
-9
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { ThemeProvider } from "@/lib/theme";
|
import { ThemeProvider } from "@/lib/theme";
|
||||||
import { TierProvider } from "@/lib/tier";
|
import { TierProvider } from "@/lib/tier";
|
||||||
|
import { ViewModeProvider } from "@/lib/viewmode-store";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { RequireAdmin } from "@/components/RequireAdmin";
|
import { RequireAdmin } from "@/components/RequireAdmin";
|
||||||
import { RequireAuth } from "@/components/RequireAuth";
|
import { RequireAuth } from "@/components/RequireAuth";
|
||||||
@@ -24,6 +25,10 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<TierProvider>
|
<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>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
|
{/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
|
||||||
@@ -64,6 +69,7 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</ViewModeProvider>
|
||||||
</TierProvider>
|
</TierProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTier } from "@/lib/tier";
|
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 { Select, SelectContent, SelectItem } from "@/components/ui/select";
|
||||||
|
import { RoleSwitcher } from "@/components/RoleSwitcher";
|
||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
@@ -138,11 +141,14 @@ function ThemeToggle() {
|
|||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { isAdmin } = useTier();
|
const { isAdmin } = useTier();
|
||||||
|
const { view } = useViewMode();
|
||||||
const { t, i18n } = useTranslation("navigation");
|
const { t, i18n } = useTranslation("navigation");
|
||||||
// Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin
|
// Sections are derived from is_admin AND the chosen home: visibleSections drops
|
||||||
// only for admins. isAdmin is fail-closed (false while /me loads or on failure),
|
// every admin-gated section for a non-admin (fail-closed — false while /me loads or
|
||||||
// so admin sections appear only once identity is confirmed.
|
// on failure), then the role-switcher's view narrows further to the home the admin
|
||||||
const sections = visibleSections(isAdmin);
|
// 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.
|
// Sync document metadata with the active language.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -176,10 +182,13 @@ export function AppShell() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Profile Card */}
|
{/* 2. Role switcher (admin-only; renders nothing for a plain user) */}
|
||||||
|
<RoleSwitcher />
|
||||||
|
|
||||||
|
{/* 3. Profile Card */}
|
||||||
<UserStrip />
|
<UserStrip />
|
||||||
|
|
||||||
{/* 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) */}
|
||||||
<div className="px-1 text-[10px] text-muted-foreground/50 leading-tight text-center">
|
<div className="px-1 text-[10px] text-muted-foreground/50 leading-tight text-center">
|
||||||
{t("common:brand_tagline")}
|
{t("common:brand_tagline")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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,5 +6,9 @@
|
|||||||
"admin_servers": "Servers",
|
"admin_servers": "Servers",
|
||||||
"admin_images": "Images",
|
"admin_images": "Images",
|
||||||
"sysadmin_section": "SysAdmin",
|
"sysadmin_section": "SysAdmin",
|
||||||
"sysadmin_fleet": "Fleet"
|
"sysadmin_fleet": "Fleet",
|
||||||
|
"view_switch_label": "Viewing as",
|
||||||
|
"view_user": "User",
|
||||||
|
"view_admin": "Admin",
|
||||||
|
"view_ops": "SysAdmin"
|
||||||
}
|
}
|
||||||
@@ -6,5 +6,9 @@
|
|||||||
"admin_servers": "服务器",
|
"admin_servers": "服务器",
|
||||||
"admin_images": "镜像",
|
"admin_images": "镜像",
|
||||||
"sysadmin_section": "系统管理",
|
"sysadmin_section": "系统管理",
|
||||||
"sysadmin_fleet": "全平台服务器"
|
"sysadmin_fleet": "全平台服务器",
|
||||||
|
"view_switch_label": "当前视图",
|
||||||
|
"view_user": "用户",
|
||||||
|
"view_admin": "管理",
|
||||||
|
"view_ops": "系统管理"
|
||||||
}
|
}
|
||||||
@@ -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<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);
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
VIEW_MODES,
|
VIEW_MODES,
|
||||||
availableViewModes,
|
availableViewModes,
|
||||||
effectiveViewMode,
|
effectiveViewMode,
|
||||||
|
landingPathForView,
|
||||||
parseViewMode,
|
parseViewMode,
|
||||||
restoreViewMode,
|
restoreViewMode,
|
||||||
sectionsForView,
|
sectionsForView,
|
||||||
|
viewModeLabelKey,
|
||||||
type ViewMode,
|
type ViewMode,
|
||||||
} from "./viewmode";
|
} from "./viewmode";
|
||||||
import { visibleSections } from "./nav";
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,3 +111,32 @@ export function sectionsForView(view: ViewMode, isAdmin: boolean): NavSection[]
|
|||||||
const ceiling = VIEW_RANK[effectiveViewMode(view, isAdmin)];
|
const ceiling = VIEW_RANK[effectiveViewMode(view, isAdmin)];
|
||||||
return visibleSections(isAdmin).filter((s) => VIEW_RANK[s.id] <= ceiling);
|
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}`;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user