diff --git a/src/Web/autopilot-monitor-web/app/fleet/layout.tsx b/src/Web/autopilot-monitor-web/app/fleet/layout.tsx new file mode 100644 index 00000000..938a04e6 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet/layout.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { ProtectedRoute } from "@/components/ProtectedRoute"; + +/** + * The Fleet (MSP) area. Gated on fleet scope: a delegated ("MSP") admin who manages a subset of tenants, + * OR full platform scope (GA/Reader). The backend enforces the per-tenant bound on every request; this + * gate only keeps a single-tenant user with no fleet off the page. + */ +export default function FleetLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/Web/autopilot-monitor-web/app/fleet/page.tsx b/src/Web/autopilot-monitor-web/app/fleet/page.tsx new file mode 100644 index 00000000..1ada77c9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useMemo } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTenantList } from "@/hooks/useTenantList"; + +/** + * Fleet landing — the managed-tenant overview for a delegated ("MSP") admin. + * + * The tenant list comes from config/all, which the backend bounds to the caller's managed tenants + * (GlobalReadOrDelegatedSubset tier, Phase 2b). We additionally intersect with the JWT-surfaced + * delegatedTenantIds as client-side defense in depth, so the grid can never render a tenant outside scope + * even if a future endpoint change widened the response. + * + * Phase 3b renders the read-only overview grid. Per-tenant health metrics (3c) and drill-in into a managed + * tenant's dashboards (3d) build on this. + */ +export default function FleetPage() { + const { user } = useAuth(); + const isDelegated = user?.isDelegated ?? false; + + const allowed = useMemo( + () => new Set((user?.delegatedTenantIds ?? []).map((t) => t.toLowerCase())), + [user?.delegatedTenantIds] + ); + const tenants = useTenantList(isDelegated); + const myTenants = useMemo( + () => tenants.filter((t) => allowed.has(t.tenantId.toLowerCase())), + [tenants, allowed] + ); + + return ( +
+
+

Fleet

+

+ {myTenants.length} managed tenant{myTenants.length === 1 ? "" : "s"} · read-only monitoring +

+
+ + {myTenants.length === 0 ? ( +
+ No managed tenants yet. Once tenants are delegated to you, they appear here. +
+ ) : ( +
+ {myTenants.map((t) => ( +
+
+ {t.domainName || "Unknown domain"} +
+
+ {t.tenantId} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx index 786f743b..9d797215 100644 --- a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx +++ b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx @@ -33,7 +33,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) { mobileDrawerOpen, setMobileDrawerOpen, } = useSidebar(); - const { isAuthenticated, user, hasGlobalScope } = useAuth(); + const { isAuthenticated, user, hasGlobalScope, hasFleetScope } = useAuth(); const pathname = usePathname(); // Track desktop breakpoint @@ -177,6 +177,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) { const isOperator = user?.role === "Operator"; const isAdminOrOperator = isTenantAdmin || isOperator; const isGlobalAdmin = user?.isGlobalAdmin ?? false; + const isDelegated = user?.isDelegated ?? false; const isGroupVisible = (group: NavGroup | ExpandableNavGroup): boolean => { switch (group.visibility) { @@ -185,6 +186,9 @@ export function GlobalSidebar({ children }: { children: ReactNode }) { // Cross-tenant nav is a VISIBILITY surface → any platform scope (GA or read-only GlobalReader). // Mutating controls inside these views are gated separately on isGlobalAdmin. 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. + case "fleet": return isDelegated && !hasGlobalScope; default: return false; } }; @@ -392,8 +396,9 @@ export function GlobalSidebar({ children }: { children: ReactNode }) { const visibleGroups = NAV_GROUPS.filter(isGroupVisible); const hasPageSections = pageSections.length > 0; - // Regular users see minimal nav. A read-only Global Reader has platform scope → full nav. - const isRegularUser = !isAdminOrOperator && !hasGlobalScope; + // Regular users see minimal nav. A read-only Global Reader has platform scope, and a delegated MSP admin + // has fleet scope → both get the (group-filtered) nav rather than the minimal regular-user list. + const isRegularUser = !isAdminOrOperator && !hasFleetScope; const renderNavContent = (isMobile = false) => ( <> diff --git a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx index 8b94ba71..0f747a64 100644 --- a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx +++ b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx @@ -15,14 +15,21 @@ interface ProtectedRouteProps { * backend-enforced 403/redaction). */ requireGlobalScope?: boolean; + /** + * Require FLEET scope: full platform scope (Global Admin / Reader) OR a delegated ("MSP") admin who + * manages a subset of tenants. Use for the fleet area, which a delegated admin may VIEW bounded to its + * managed tenants. The backend enforces the per-tenant bound on every request; this only gates the + * client route so a single-tenant user with no fleet doesn't land on an empty fleet page. + */ + requireFleetScope?: boolean; } /** - * Protects routes by requiring authentication - * Optionally requires Global Admin role, or platform scope (Global Admin or read-only Global Reader). + * Protects routes by requiring authentication. Optionally requires Global Admin, platform scope + * (Global Admin or read-only Global Reader), or fleet scope (platform scope OR a delegated MSP admin). */ -export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGlobalScope = false }: ProtectedRouteProps) { - const { isAuthenticated, user, hasGlobalScope, isLoading, login } = useAuth(); +export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGlobalScope = false, requireFleetScope = false }: ProtectedRouteProps) { + const { isAuthenticated, user, hasGlobalScope, hasFleetScope, isLoading, login } = useAuth(); const router = useRouter(); // Once authenticated, remember it so transient auth-state flips (e.g. MSAL @@ -80,7 +87,8 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGl // requireGlobalAdmin → real Global Admin only; requireGlobalScope → Global Admin OR Global Reader. const platformDenied = (requireGlobalAdmin && user && !user.isGlobalAdmin) || - (requireGlobalScope && user && !hasGlobalScope); + (requireGlobalScope && user && !hasGlobalScope) || + (requireFleetScope && user && !hasFleetScope); if (platformDenied) { return (
diff --git a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx index 18a62b03..a0f2027e 100644 --- a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx @@ -24,6 +24,9 @@ export function AuthGate() { } else if (returnUrl) { // Restore the deep link the user originally opened before re-auth. router.replace(returnUrl); + } else if (user.isDelegated && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== 'Operator') { + // A delegated ("MSP") admin with no own-tenant/platform role manages a fleet → land on /fleet. + router.push("/fleet"); } else if (user.isTenantAdmin || user.isGlobalAdmin || user.isGlobalReader || user.role === 'Operator') { router.push("/dashboard"); } else { diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index afd6e221..4b3af0a8 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -113,7 +113,7 @@ export interface NavGroup { id: string; label: string; items: NavItem[]; - visibility: "all" | "adminOrOperator" | "globalAdmin"; + visibility: "all" | "adminOrOperator" | "globalAdmin" | "fleet"; style?: "global"; } @@ -138,6 +138,17 @@ export const DASHBOARD_ITEM: NavItem = { // --- Global navigation groups (flat items) --- export const NAV_GROUPS: NavGroup[] = [ + { + // Delegated ("MSP") admins manage a subset of tenants. This is their primary entry point; full + // platform admins (GA/Reader) use the richer Global Admin section instead, so it is hidden for them + // (see GlobalSidebar "fleet" visibility = delegated && !platform scope). + id: "fleet", + label: "Fleet", + visibility: "fleet", + items: [ + { id: "fleet-tenants", label: "Managed Tenants", href: "/fleet", icon: }, + ], + }, { id: "monitoring", label: "Monitoring",