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

+16 -7
View File
@@ -5,8 +5,11 @@ import { useTranslation } from "react-i18next";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
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 { RoleSwitcher } from "@/components/RoleSwitcher";
import { useTheme } from "@/lib/theme";
import { api } from "@/lib/api";
@@ -138,11 +141,14 @@ function ThemeToggle() {
export function AppShell() {
const { isAdmin } = useTier();
const { view } = useViewMode();
const { t, i18n } = useTranslation("navigation");
// 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);
// 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);
// Sync document metadata with the active language.
useEffect(() => {
@@ -176,10 +182,13 @@ export function AppShell() {
</span>
</div>
{/* 2. Profile Card */}
{/* 2. Role switcher (admin-only; renders nothing for a plain user) */}
<RoleSwitcher />
{/* 3. Profile Card */}
<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">
{t("common:brand_tagline")}
</div>