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
@@ -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,
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user