feat(web): fleet area gate + nav + landing grid for MSP admins [Phase 3b]

Gives a delegated ("MSP") admin a place to land and see the tenants they manage.

- ProtectedRoute: new requireFleetScope (full platform scope OR delegated). The
  backend still enforces the per-tenant bound on every request; this just keeps a
  single-tenant user off the fleet route.
- Nav: a "Fleet" group (Managed Tenants -> /fleet) with a new "fleet" visibility,
  shown to a delegated admin who lacks full platform scope (a GA/Reader manages
  tenants through the richer Global Admin section, so it stays hidden for them).
  GlobalSidebar's isRegularUser now accounts for fleet scope, so a delegated user
  gets the group-filtered nav instead of the minimal regular-user list.
- AuthGate: a delegated-only user (no own-tenant/platform role) lands on /fleet.
- app/fleet: layout (requireFleetScope) + landing page rendering the managed-tenant
  grid. The list comes from config/all (backend-bounded to the caller's subset,
  Phase 2b) and is additionally intersected with the JWT delegatedTenantIds as
  client-side defense in depth.

Cards are read-only overview this phase; per-tenant health (3c) and drill-in (3d)
build on this. Web tsc clean; 476 web tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-25 22:06:03 +02:00
parent 261d5df5e1
commit 23f554f5ac
6 changed files with 112 additions and 9 deletions
@@ -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 <ProtectedRoute requireFleetScope>{children}</ProtectedRoute>;
}
@@ -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 (
<div className="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Fleet</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{myTenants.length} managed tenant{myTenants.length === 1 ? "" : "s"} · read-only monitoring
</p>
</div>
{myTenants.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 p-10 text-center text-gray-500 dark:border-gray-700 dark:text-gray-400">
No managed tenants yet. Once tenants are delegated to you, they appear here.
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{myTenants.map((t) => (
<div
key={t.tenantId}
className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<div className="truncate text-base font-semibold text-gray-900 dark:text-white">
{t.domainName || "Unknown domain"}
</div>
<div className="mt-1 break-all font-mono text-xs text-gray-400 dark:text-gray-500">
{t.tenantId}
</div>
</div>
))}
</div>
)}
</div>
);
}
@@ -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) => (
<>
@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
@@ -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 {
@@ -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: <GlobeAltIcon /> },
],
},
{
id: "monitoring",
label: "Monitoring",