Revert "feat(panel): wire role-switcher into the app shell"
This reverts commit 50b8487ff5.
This commit is contained in:
8 files changed
+9
-277
No files matched your search
@@ -1,7 +1,6 @@
|
|||||||
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";
|
||||||
@@ -25,10 +24,6 @@ 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
|
||||||
@@ -69,7 +64,6 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ViewModeProvider>
|
|
||||||
</TierProvider>
|
</TierProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ 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 { type NavSection } from "@/lib/nav";
|
import { visibleSections, 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";
|
||||||
|
|
||||||
@@ -141,14 +138,11 @@ 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 from is_admin AND the chosen home: visibleSections drops
|
// Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin
|
||||||
// every admin-gated section for a non-admin (fail-closed — false while /me loads or
|
// only for admins. isAdmin is fail-closed (false while /me loads or on failure),
|
||||||
// on failure), then the role-switcher's view narrows further to the home the admin
|
// so admin sections appear only once identity is confirmed.
|
||||||
// is currently viewing. sectionsForView can only ever narrow, never widen, so an
|
const sections = visibleSections(isAdmin);
|
||||||
// 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(() => {
|
||||||
@@ -182,13 +176,10 @@ export function AppShell() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Role switcher (admin-only; renders nothing for a plain user) */}
|
{/* 2. Profile Card */}
|
||||||
<RoleSwitcher />
|
|
||||||
|
|
||||||
{/* 3. Profile Card */}
|
|
||||||
<UserStrip />
|
<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">
|
<div className="px-1 text-[10px] text-muted-foreground/50 leading-tight text-center">
|
||||||
{t("common:brand_tagline")}
|
{t("common:brand_tagline")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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_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,9 +6,5 @@
|
|||||||
"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": "系统管理"
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,9 @@ 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";
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -111,32 +111,3 @@ 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