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",