mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user