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:
flyemoji committed 2026-06-30 12:18:51 +09:00
1 parent 563041ada3
commit 50b8487ff5
8 files changed
+277 -9

No files matched your search

+117
View File
@@ -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);
}
+30
View File
@@ -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");
});
});
+29
View File
@@ -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}`;
}