diff --git a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx
index 25634148..18a62b03 100644
--- a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx
+++ b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx
@@ -24,7 +24,7 @@ export function AuthGate() {
} else if (returnUrl) {
// Restore the deep link the user originally opened before re-auth.
router.replace(returnUrl);
- } else if (user.isTenantAdmin || user.isGlobalAdmin || user.role === 'Operator') {
+ } else if (user.isTenantAdmin || user.isGlobalAdmin || user.isGlobalReader || user.role === 'Operator') {
router.push("/dashboard");
} else {
router.push("/progress");
diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx
index 482c8990..4d47414c 100644
--- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx
+++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx
@@ -90,6 +90,13 @@ interface UserInfo {
tenantId: string;
objectId: string;
isGlobalAdmin: boolean;
+ /**
+ * Read-only platform tier: cross-tenant VISIBILITY like a Global Admin but no platform mutations.
+ * ADDITIVE — a user may be both a GlobalReader and their own tenant's Admin (then isTenantAdmin is
+ * also true and they keep edit rights on their own tenant). Use {@link AuthContextType.hasGlobalScope}
+ * for visibility gating and isGlobalAdmin for platform-mutation gating.
+ */
+ isGlobalReader: boolean;
isTenantAdmin: boolean;
role: 'Admin' | 'Operator' | 'Viewer' | null;
canManageBootstrapTokens: boolean;
@@ -101,6 +108,8 @@ interface UserInfo {
interface AuthContextType {
isAuthenticated: boolean;
user: UserInfo | null;
+ /** Platform-wide read scope: Global Admin OR read-only Global Reader. Use for cross-tenant VISIBILITY. */
+ hasGlobalScope: boolean;
isLoading: boolean;
isPreviewBlocked: boolean;
previewMessage: string;
@@ -152,6 +161,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
tenantId: (data.tenantId as string) || account.tenantId || '',
objectId: (data.objectId as string) || account.homeAccountId || '',
isGlobalAdmin: (data.isGlobalAdmin as boolean) || false,
+ isGlobalReader: (data.isGlobalReader as boolean) || false,
isTenantAdmin: (data.isTenantAdmin as boolean) || false,
role: (data.role as 'Admin' | 'Operator' | 'Viewer' | null) || null,
canManageBootstrapTokens: (data.canManageBootstrapTokens as boolean) || false,
@@ -205,6 +215,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGlobalAdmin: false,
+ isGlobalReader: false,
isTenantAdmin: false,
role: null,
canManageBootstrapTokens: false,
@@ -225,6 +236,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
tenantId: data.tenantId || account.tenantId || '',
objectId: data.objectId || account.homeAccountId || '',
isGlobalAdmin: data.isGlobalAdmin || false,
+ isGlobalReader: data.isGlobalReader || false,
isTenantAdmin: data.isTenantAdmin || false,
role: data.role || null,
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
@@ -257,6 +269,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGlobalAdmin: false,
+ isGlobalReader: false,
isTenantAdmin: false,
role: null,
canManageBootstrapTokens: false,
@@ -393,6 +406,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
const value: AuthContextType = {
isAuthenticated,
user,
+ hasGlobalScope: (user?.isGlobalAdmin || user?.isGlobalReader) ?? false,
isLoading,
isPreviewBlocked,
previewMessage,
diff --git a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx
index 22a7f73d..080fab54 100644
--- a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx
+++ b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx
@@ -26,16 +26,20 @@ interface GlobalNotificationContextType {
const GlobalNotificationContext = createContext(undefined);
export function GlobalNotificationProvider({ children }: { children: React.ReactNode }) {
- const { user, getAccessToken } = useAuth();
+ const { user, hasGlobalScope, getAccessToken } = useAuth();
const { connection, isConnected, joinGroup, leaveGroup } = useSignalR();
const [notifications, setNotifications] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const fetchingRef = useRef(false);
- const isGlobal = user?.isGlobalAdmin === true;
+ // View scope (hasGlobalScope = GA OR read-only Global Reader) gates the read surfaces: FETCH/hydrate
+ // (backend global/notifications GET is GlobalReadOrAdmin) AND the SignalR global-admins live-group join
+ // (backend now admits HasGlobalScope — read broadcast, reader only receives). canManageGlobal (real GA)
+ // gates only dismiss/clear, which the backend keeps GlobalAdminOnly.
+ const canManageGlobal = user?.isGlobalAdmin === true;
const fetchNotifications = useCallback(async () => {
- if (!isGlobal || fetchingRef.current) return;
+ if (!hasGlobalScope || fetchingRef.current) return;
fetchingRef.current = true;
try {
@@ -52,43 +56,45 @@ export function GlobalNotificationProvider({ children }: { children: React.React
} finally {
fetchingRef.current = false;
}
- }, [isGlobal, getAccessToken]);
+ }, [hasGlobalScope, getAccessToken]);
// Initial state hydration. Re-fetched on SignalR reconnect to recover any deltas
// pushed during the disconnect window.
useEffect(() => {
- if (!isGlobal) {
+ if (!hasGlobalScope) {
setNotifications([]);
return;
}
setIsLoading(true);
fetchNotifications().finally(() => setIsLoading(false));
- }, [isGlobal, fetchNotifications]);
+ }, [hasGlobalScope, fetchNotifications]);
useEffect(() => {
- if (!connection || !isGlobal) return;
+ if (!connection || !hasGlobalScope) return;
const handler = () => { fetchNotifications(); };
connection.onreconnected(handler);
- }, [connection, isGlobal, fetchNotifications]);
+ }, [connection, hasGlobalScope, fetchNotifications]);
// Re-fetch when globalAdminMode is toggled in localStorage
useEffect(() => {
const handleStorageChange = () => {
- if (isGlobal) {
+ if (hasGlobalScope) {
fetchNotifications();
}
};
window.addEventListener('localStorageChange', handleStorageChange);
return () => window.removeEventListener('localStorageChange', handleStorageChange);
- }, [isGlobal, fetchNotifications]);
+ }, [hasGlobalScope, fetchNotifications]);
- // Group membership: only Global Admins join the global-admins group.
+ // Group membership: any platform scope (GA or read-only Global Reader) joins the global-admins live
+ // group — it is a READ broadcast group (backend now admits HasGlobalScope), so a reader receives live
+ // notification pushes too. Dismiss/clear remain GA-only (guarded above).
useEffect(() => {
- if (!isConnected || !isGlobal) return;
+ if (!isConnected || !hasGlobalScope) return;
const group = 'global-admins';
joinGroup(group);
return () => { leaveGroup(group); };
- }, [isConnected, isGlobal, joinGroup, leaveGroup]);
+ }, [isConnected, hasGlobalScope, joinGroup, leaveGroup]);
// Live-push handlers.
useEffect(() => {
@@ -118,6 +124,9 @@ export function GlobalNotificationProvider({ children }: { children: React.React
}, [connection]);
const dismissNotification = useCallback(async (id: string) => {
+ // Dismiss is GA-only (backend GlobalAdminOnly). A read-only Global Reader must not even optimistically
+ // clear their view. UI dismiss buttons are also hidden for readers.
+ if (!canManageGlobal) return;
setNotifications(prev => prev.filter(n => n.id !== id));
try {
@@ -129,9 +138,10 @@ export function GlobalNotificationProvider({ children }: { children: React.React
} catch {
// Best-effort; backend push will reconcile other tabs if dismiss eventually lands
}
- }, [getAccessToken]);
+ }, [canManageGlobal, getAccessToken]);
const dismissAll = useCallback(async () => {
+ if (!canManageGlobal) return; // GA-only (see dismissNotification)
setNotifications([]);
try {
@@ -143,7 +153,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
} catch {
// Best-effort
}
- }, [getAccessToken]);
+ }, [canManageGlobal, getAccessToken]);
const unreadCount = notifications.length;
diff --git a/src/Web/autopilot-monitor-web/hooks/useAggregatedAdminScope.ts b/src/Web/autopilot-monitor-web/hooks/useAggregatedAdminScope.ts
index f2b74e4f..93e1b355 100644
--- a/src/Web/autopilot-monitor-web/hooks/useAggregatedAdminScope.ts
+++ b/src/Web/autopilot-monitor-web/hooks/useAggregatedAdminScope.ts
@@ -60,10 +60,11 @@ export function useAggregatedAdminScope(opts?: {
urlTenantId?: string;
}): AggregatedAdminScope {
const { tenantId } = useTenant();
- const { user } = useAuth();
+ const { hasGlobalScope } = useAuth();
const { globalAdminMode } = useAdminMode();
- const isGlobalAdmin = Boolean(globalAdminMode && user?.isGlobalAdmin);
+ // Platform scope (GA or read-only Global Reader) — read-only-safe cross-tenant/aggregated view + selector.
+ const isGlobalAdmin = Boolean(globalAdminMode && hasGlobalScope);
const tenants = useTenantList(isGlobalAdmin);
const [selectedTenantId, setSelectedTenantId] = useState("");
diff --git a/src/Web/autopilot-monitor-web/hooks/useCanMutatePlatform.ts b/src/Web/autopilot-monitor-web/hooks/useCanMutatePlatform.ts
new file mode 100644
index 00000000..bc4a464f
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/hooks/useCanMutatePlatform.ts
@@ -0,0 +1,14 @@
+"use client";
+
+import { useAuth } from "@/contexts/AuthContext";
+
+/**
+ * True only for a real platform Global Admin — the single signal that gates every MUTATING control in
+ * the cross-tenant /admin area. A read-only Global Reader reaches these pages (view scope via
+ * hasGlobalScope) but must NOT be able to block/kill devices, trigger maintenance, edit tenants, etc.
+ * The backend also enforces this (GlobalAdminOnly → 403); this hook is the UI read-only contract.
+ */
+export function useCanMutatePlatform(): boolean {
+ const { user } = useAuth();
+ return user?.isGlobalAdmin === true;
+}
diff --git a/src/Web/autopilot-monitor-web/hooks/useGlobalAdminScope.ts b/src/Web/autopilot-monitor-web/hooks/useGlobalAdminScope.ts
index 40b5e358..a3bf9d68 100644
--- a/src/Web/autopilot-monitor-web/hooks/useGlobalAdminScope.ts
+++ b/src/Web/autopilot-monitor-web/hooks/useGlobalAdminScope.ts
@@ -9,7 +9,12 @@ import { useTenantList, type TenantInfo } from "@/hooks/useTenantList";
export type { TenantInfo };
export interface GlobalAdminScope {
- /** True when Global-Admin mode is toggled on AND the user actually is a global admin. */
+ /**
+ * True when global-scope mode is toggled on AND the user has platform scope (Global Admin OR the
+ * read-only Global Reader). This is a VISIBILITY/routing flag — it drives the tenant selector, the
+ * "Global view" banner and the `/global/` endpoint choice, all of which are read-only-safe. Mutating
+ * actions on these pages must gate separately on the real Global-Admin / own-tenant-admin status.
+ */
isGlobalAdmin: boolean;
/** Sorted tenant list for the selector. Empty unless {@link isGlobalAdmin}. */
tenants: TenantInfo[];
@@ -36,10 +41,11 @@ export interface GlobalAdminScope {
*/
export function useGlobalAdminScope(): GlobalAdminScope {
const { tenantId } = useTenant();
- const { user } = useAuth();
+ const { hasGlobalScope } = useAuth();
const { globalAdminMode } = useAdminMode();
- const isGlobalAdmin = Boolean(globalAdminMode && user?.isGlobalAdmin);
+ // Platform scope (GA or read-only Global Reader) — read-only-safe cross-tenant view + selector.
+ const isGlobalAdmin = Boolean(globalAdminMode && hasGlobalScope);
const tenants = useTenantList(isGlobalAdmin);
const [selectedTenantId, setSelectedTenantId] = useState("");
diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx
index 6b351020..6005b9ed 100644
--- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx
+++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx
@@ -101,6 +101,12 @@ export interface ExpandableNavItem {
label: string;
icon: React.ReactNode;
items: ExpandableSubItem[];
+ /**
+ * Item-level visibility within a platform-scope group. Default (undefined) = visible to any platform
+ * scope (Global Admin OR read-only Global Reader). "globalAdminOnly" = real Global Admin only — used for
+ * platform-settings/mutation sub-sections that a read-only Global Reader must not see.
+ */
+ visibility?: "globalAdminOnly";
}
export interface NavGroup {
@@ -250,7 +256,9 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
],
},
{
- id: "ga-settings", label: "Settings", icon: ,
+ // Platform settings are GA-only — a read-only Global Reader must not see them (their secrets
+ // are mutation surfaces; the reader gets redacted/read-only tenant config elsewhere).
+ id: "ga-settings", label: "Settings", icon: , visibility: "globalAdminOnly",
items: [
{ id: "ga-global", label: "Global Settings", href: "/admin/settings/global" },
{ id: "ga-diag-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths" },
@@ -260,7 +268,9 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
],
},
{
- id: "ga-ops", label: "Ops", icon: ,
+ // Ops + Backups + Customs Archive are destructive platform operations — GA-only, hidden from a
+ // read-only Global Reader (routes are also gated via requireGlobalAdmin layouts).
+ id: "ga-ops", label: "Ops", icon: , visibility: "globalAdminOnly",
items: [
{ id: "ga-maintenance", label: "Maintenance", href: "/admin/ops" },
{ id: "ga-session-cleanup", label: "Session Cleanup", href: "/admin/ops/session-cleanup" },
@@ -269,7 +279,9 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
],
},
{
- id: "ga-software", label: "Software", icon: ,
+ // Software mapping is vulnerability-data curation (write-heavy CPE mapping/ignore) — GA-only,
+ // hidden from a read-only Global Reader (route also gated via requireGlobalAdmin layout).
+ id: "ga-software", label: "Software", icon: , visibility: "globalAdminOnly",
items: [
{ id: "ga-software-mapping", label: "Software Mapping", href: "/admin/software" },
],