Unverified Commit 50b8487f authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 563041ad
Loading
Loading
Loading
Loading
+6 −0
Changes for panel/src/App.tsx: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "@/lib/theme";
import { TierProvider } from "@/lib/tier";
import { ViewModeProvider } from "@/lib/viewmode-store";
import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
@@ -24,6 +25,10 @@ export default function App() {
  return (
    <ThemeProvider>
      <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>
        <Routes>
          {/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
@@ -64,6 +69,7 @@ export default function App() {
          </Route>
        </Routes>
      </BrowserRouter>
      </ViewModeProvider>
    </TierProvider>
  </ThemeProvider>
  );
+16 −7
Changes for panel/src/components/AppShell.tsx: 16 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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>
+69 −0
Changes for panel/src/components/RoleSwitcher.tsx: 69 added lines, 0 removed lines.
Original line number Diff line number Diff line
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>
  );
}
+5 −1
Changes for panel/src/i18n/resources/en-US/navigation.json: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -6,5 +6,9 @@
  "admin_servers": "Servers",
  "admin_images": "Images",
  "sysadmin_section": "SysAdmin",
  "sysadmin_fleet": "Fleet"
  "sysadmin_fleet": "Fleet",
  "view_switch_label": "Viewing as",
  "view_user": "User",
  "view_admin": "Admin",
  "view_ops": "SysAdmin"
}
+5 −1
Changes for panel/src/i18n/resources/zh-CN/navigation.json: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -6,5 +6,9 @@
  "admin_servers": "服务器",
  "admin_images": "镜像",
  "sysadmin_section": "系统管理",
  "sysadmin_fleet": "全平台服务器"
  "sysadmin_fleet": "全平台服务器",
  "view_switch_label": "当前视图",
  "view_user": "用户",
  "view_admin": "管理",
  "view_ops": "系统管理"
}
Loading