fix(global-reader): treat GlobalReader exactly like a GA in the sidebar (read-only aside)

Refines a4171510. A GlobalReader is "GA minus writes" and must see the SAME
sidebar as a real Global Admin:

- Standard monitoring/rules/operations/configuration groups (Fleet Health,
  Usage Metrics, SLA, Geographic, Software, Audit, ...) were gated on
  `adminOrOperator`, so a pure GlobalReader (no own-tenant role) never saw
  them. Open them to any platform scope: `isAdminOrOperator || hasGlobalScope`.
- The (purple) Global Admin section is gated back on the Global View toggle
  (`hasGlobalScope && globalAdminMode`) -- IDENTICAL to a GA: toggle off hides
  it, toggle on shows it. (a4171510 had decoupled it from the toggle; reverted
  so reader == GA exactly.)

Read-only is enforced inside the pages (useCanMutatePlatform + backend) and by
the existing item-level globalAdminOnly filter (Settings/Ops/Software drop out
for a reader), not by hiding the data nav. The "fleet" MSP group stays
delegated-only, so a GA/Reader gets the standard groups with no duplication.

tsc clean; web 488 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-29 12:18:44 +02:00
parent a41715105d
commit 4dddce6326
@@ -9,6 +9,7 @@ import { CollapseState } from "../hooks/useSidebarState";
import { DefaultSectionIcon, BookOpenIcon, RocketLaunchIcon, InformationCircleIcon, DocumentTextIcon, ShieldCheckIcon } from "../lib/sidebarIcons";
import { DASHBOARD_ITEM, NAV_GROUPS, EXPANDABLE_NAV_GROUPS, REGULAR_USER_ITEMS, NavItem, NavGroup, ExpandableNavGroup, ExpandableNavItem } from "../lib/globalNavConfig";
import { PublicSiteNavbar } from "./PublicSiteNavbar";
import { useAdminMode } from "../hooks/useAdminMode";
// Sidebar pixel widths
export const SIDEBAR_PX: Record<CollapseState, number> = {
@@ -45,6 +46,9 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
return () => mql.removeEventListener("change", handler);
}, []);
// Global admin mode toggle — gates the (purple) Global Admin section, identical to a real GA.
const { globalAdminMode } = useAdminMode();
// --- Scroll-spy for page sections ---
const [activeSectionId, setActiveSectionId] = useState("");
const observerRef = useRef<IntersectionObserver | null>(null);
@@ -178,15 +182,17 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
const isGroupVisible = (group: NavGroup | ExpandableNavGroup): boolean => {
switch (group.visibility) {
case "all": return true;
case "adminOrOperator": return isAdminOrOperator;
// Cross-tenant nav is a VISIBILITY surface → any platform scope (GA or read-only GlobalReader).
// Deliberately NOT gated on globalAdminMode: a pure GlobalReader (or a GA with no own-tenant role)
// would otherwise see only "Dashboard" until they discovered the hidden "Global View" toggle. The
// toggle now governs only the DATA scope (own-tenant vs cross-tenant) of the pages, not whether the
// platform nav is reachable. Mutating controls inside these views stay gated separately on isGlobalAdmin.
case "globalAdmin": return hasGlobalScope;
// A GlobalReader is "GA minus writes" — it must see the SAME sidebar as a real GA. The standard
// monitoring/rules/operations/configuration groups are normally tenant-admin scope; open them to any
// platform scope so a pure GlobalReader (no own-tenant role) gets Fleet Health, Usage Metrics, SLA,
// etc. Read-only is enforced inside the pages (useCanMutatePlatform + backend), not by hiding nav.
case "adminOrOperator": return isAdminOrOperator || hasGlobalScope;
// The (purple) Global Admin section is gated on the Global View toggle — IDENTICAL to a real GA:
// toggle off → hidden, toggle on → shown. Item-level globalAdminOnly entries (Settings/Ops/Software)
// still drop out for a read-only reader via the item filter below.
case "globalAdmin": return hasGlobalScope && globalAdminMode;
// Fleet (MSP) nav: shown to a delegated admin who does NOT have full platform scope. A GA/Reader
// manages tenants through the richer Global Admin section instead, so it stays hidden for them.
// gets the standard monitoring groups above instead, so it stays hidden for them (no duplication).
case "fleet": return isDelegated && !hasGlobalScope;
default: return false;
}
@@ -229,7 +235,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
return { ...group, items: filteredItems };
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdminOrOperator, isGlobalAdmin, hasMcpAccess, isAdminLike, canManageBootstrapTokens, bootstrapTokenEnabled, unrestrictedModeEnabled]);
}, [isAdminOrOperator, hasGlobalScope, isGlobalAdmin, globalAdminMode, hasMcpAccess, isAdminLike, canManageBootstrapTokens, bootstrapTokenEnabled, unrestrictedModeEnabled]);
// Auto-expand the group containing the current pathname
useEffect(() => {