243 lines
12 KiB
TypeScript
243 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import type { ClientOnboardingResult, DashboardSnapshot, ManagementApiClient, ManagementRole, SessionStatus } from "./contracts";
|
||
import { routeTitle } from "./contracts";
|
||
import { ApiError, createApiClient, safeApiMessage } from "./api";
|
||
import { RbacProvider } from "./rbac-context";
|
||
import { can } from "./rbac";
|
||
import { basePath, connectorDetailId, resourceKind, usePath } from "./routing";
|
||
import { Brand } from "./components/Brand";
|
||
import { CommandMenu } from "./components/CommandMenu";
|
||
import { ErrorPanel } from "./components/ErrorPanel";
|
||
import { Icon } from "./components/Icon";
|
||
import { LoadingPanel } from "./components/LoadingPanel";
|
||
import { SessionMenu } from "./components/SessionMenu";
|
||
import { navigation } from "./navigation";
|
||
import { AuthGateway } from "./pages/AuthGateway";
|
||
import { Dashboard } from "./pages/Dashboard";
|
||
import { ForbiddenPanel } from "./pages/ForbiddenPanel";
|
||
import { Operations } from "./pages/Operations";
|
||
import { ProjectDetail } from "./pages/ProjectDetail";
|
||
import { ResourcePage } from "./pages/ResourcePage";
|
||
import { SettingsPage } from "./pages/SettingsPage";
|
||
import { ConnectorDetail } from "./pages/ConnectorDetail";
|
||
import { focusableElements, isolateOutside, lockBodyScroll, trapTab } from "./components/modal-focus";
|
||
|
||
export function App({ api: apiProp }: { api?: ManagementApiClient }) {
|
||
const [api] = useState<ManagementApiClient>(() => apiProp ?? createApiClient());
|
||
const [path, navigate] = usePath();
|
||
const [session, setSession] = useState<SessionStatus>("checking");
|
||
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("loading");
|
||
const [error, setError] = useState("The control API is unavailable.");
|
||
const [roles, setRoles] = useState<ManagementRole[]>([]);
|
||
const [subject, setSubject] = useState<string | null>(null);
|
||
const [navOpen, setNavOpen] = useState(false);
|
||
const [commandOpen, setCommandOpen] = useState(false);
|
||
const [createdClient, setCreatedClient] = useState<ClientOnboardingResult["client"] | null>(null);
|
||
const identitySubject = useRef<string | null>(null);
|
||
identitySubject.current = session === "authenticated" ? subject : null;
|
||
const navTriggerRef = useRef<HTMLButtonElement>(null);
|
||
const navCloseRef = useRef<HTMLButtonElement>(null);
|
||
const navSidebarRef = useRef<HTMLElement>(null);
|
||
const navBackdropRef = useRef<HTMLButtonElement>(null);
|
||
const [compactNavigation, setCompactNavigation] = useState(() => window.matchMedia("(max-width: 840px)").matches);
|
||
const title = useMemo(() => connectorDetailId(path) ? "Connector detail" : routeTitle(basePath(path)), [path]);
|
||
|
||
const clearIdentity = () => {
|
||
setRoles([]);
|
||
setSnapshot(null);
|
||
setSubject(null);
|
||
setCreatedClient(null);
|
||
};
|
||
|
||
const closeNavigation = () => setNavOpen(false);
|
||
|
||
const load = async () => {
|
||
setSession("checking");
|
||
setLoadState("loading");
|
||
let current;
|
||
try {
|
||
current = await api.session();
|
||
} catch (caught) {
|
||
if (caught instanceof ApiError && caught.status === 401) {
|
||
clearIdentity();
|
||
setSession("unauthenticated");
|
||
setLoadState("idle");
|
||
return;
|
||
}
|
||
clearIdentity();
|
||
setError(safeApiMessage(caught));
|
||
setLoadState("error");
|
||
return;
|
||
}
|
||
if (!current.authenticated) {
|
||
clearIdentity();
|
||
setSession("unauthenticated");
|
||
setLoadState("idle");
|
||
return;
|
||
}
|
||
setSession("authenticated");
|
||
setRoles(current.session?.roles ?? []);
|
||
setSubject(current.session?.subject ?? null);
|
||
try {
|
||
setSnapshot(await api.dashboard());
|
||
setLoadState("idle");
|
||
} catch (caught) {
|
||
if (caught instanceof ApiError && caught.status === 401) {
|
||
clearIdentity();
|
||
setSession("expired");
|
||
setError(caught.message);
|
||
} else {
|
||
setError(safeApiMessage(caught));
|
||
}
|
||
setLoadState("error");
|
||
}
|
||
};
|
||
|
||
useEffect(() => { void load(); }, []);
|
||
useEffect(() => { setNavOpen(false); }, [path]);
|
||
useEffect(() => {
|
||
const media = window.matchMedia("(max-width: 840px)");
|
||
const update = () => {
|
||
setCompactNavigation(media.matches);
|
||
if (!media.matches) setNavOpen(false);
|
||
};
|
||
media.addEventListener("change", update);
|
||
return () => media.removeEventListener("change", update);
|
||
}, []);
|
||
useEffect(() => {
|
||
const sidebar = navSidebarRef.current;
|
||
if (!navOpen || !compactNavigation || !sidebar) return;
|
||
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||
const backdrop = navBackdropRef.current;
|
||
const restoreIsolation = isolateOutside(sidebar, backdrop ? [backdrop] : []);
|
||
const restoreScroll = lockBodyScroll();
|
||
const focusables = () => focusableElements(sidebar);
|
||
const onFocusIn = (event: FocusEvent) => {
|
||
if (event.target instanceof Node && sidebar.contains(event.target)) return;
|
||
(focusables()[0] ?? sidebar).focus();
|
||
};
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setNavOpen(false);
|
||
} else {
|
||
trapTab(event, sidebar);
|
||
}
|
||
};
|
||
navCloseRef.current?.focus();
|
||
document.addEventListener("focusin", onFocusIn, true);
|
||
document.addEventListener("keydown", onKeyDown);
|
||
return () => {
|
||
document.removeEventListener("focusin", onFocusIn, true);
|
||
document.removeEventListener("keydown", onKeyDown);
|
||
restoreIsolation();
|
||
restoreScroll();
|
||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||
};
|
||
}, [navOpen, compactNavigation]);
|
||
useEffect(() => {
|
||
if (session !== "authenticated") return;
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||
event.preventDefault();
|
||
setCommandOpen((current) => {
|
||
if (!current && document.querySelector('[role="dialog"]')) return current;
|
||
return !current;
|
||
});
|
||
}
|
||
};
|
||
document.addEventListener("keydown", onKeyDown);
|
||
return () => document.removeEventListener("keydown", onKeyDown);
|
||
}, [session]);
|
||
|
||
const kind = resourceKind(path);
|
||
const detail = basePath(path).startsWith("/projects/");
|
||
const connectorId = connectorDetailId(path);
|
||
const scopeParams = new URLSearchParams(path.split("?")[1] ?? "");
|
||
const scopeKey = `${scopeParams.get("project") ?? ""}:${scopeParams.get("environment") ?? ""}`;
|
||
const canReadAudit = can(roles, "audit.read");
|
||
const active = (href: string) => basePath(path) === href || (href !== "/" && basePath(path).startsWith(`${href}/`));
|
||
const authenticated = session === "authenticated";
|
||
|
||
const content = session === "checking" || loadState === "loading"
|
||
? <LoadingPanel />
|
||
: session === "unauthenticated" && loadState === "idle"
|
||
? <AuthGateway />
|
||
: loadState === "error"
|
||
? <ErrorPanel message={error} expired={session === "expired"} retry={() => void load()} />
|
||
: detail
|
||
? <ProjectDetail key={basePath(path)} path={path} navigate={navigate} api={api} />
|
||
: connectorId
|
||
? <ConnectorDetail key={`${connectorId}:${scopeKey}`} path={path} navigate={navigate} api={api} />
|
||
: kind
|
||
? <ResourcePage key={`${kind}:${scopeKey}`} path={path} kind={kind} api={api} navigate={navigate} onClientCreated={(client) => { if (subject !== null && identitySubject.current === subject) setCreatedClient(client); }} />
|
||
: snapshot && basePath(path) === "/"
|
||
? <Dashboard snapshot={snapshot} api={api} reload={() => void load()} navigate={navigate} />
|
||
: basePath(path) === "/operations"
|
||
? <Operations path={path} navigate={navigate} snapshot={snapshot} api={api} />
|
||
: basePath(path) === "/settings"
|
||
? <SettingsPage path={path} navigate={navigate} />
|
||
: <ForbiddenPanel />;
|
||
|
||
return (
|
||
<RbacProvider roles={roles}>
|
||
<a className="skip-link" href="#main-content">Skip to main content</a>
|
||
<div className={authenticated ? "app-shell" : "preauth-shell"}>
|
||
{authenticated ? <>
|
||
{navOpen ? <button ref={navBackdropRef} type="button" className="nav-backdrop" onClick={closeNavigation} aria-label="Dismiss navigation" aria-hidden="true" tabIndex={-1} /> : null}
|
||
<aside
|
||
ref={navSidebarRef}
|
||
className={navOpen ? "sidebar sidebar-open" : "sidebar"}
|
||
id="primary-navigation-panel"
|
||
aria-label="Application navigation"
|
||
role={compactNavigation && navOpen ? "dialog" : undefined}
|
||
aria-modal={compactNavigation && navOpen ? true : undefined}
|
||
aria-hidden={compactNavigation && !navOpen ? true : undefined}
|
||
inert={compactNavigation && !navOpen}
|
||
>
|
||
<div className="sidebar-head">
|
||
<a href="/" onClick={(event) => { event.preventDefault(); navigate("/"); }}><Brand /></a>
|
||
<button ref={navCloseRef} type="button" className="nav-close" aria-label="Close navigation" onClick={closeNavigation}>×</button>
|
||
</div>
|
||
<nav aria-label="Primary navigation">
|
||
{navigation.map((group) => {
|
||
const items = group.items.filter((item) => !item.auditOnly || canReadAudit);
|
||
return items.length ? <section className="nav-group" aria-label={group.label} key={group.label}>
|
||
<p>{group.label}</p>
|
||
{items.map((item) => <a
|
||
aria-current={active(item.href) ? "page" : undefined}
|
||
className={active(item.href) ? "nav-link active" : "nav-link"}
|
||
href={item.href}
|
||
key={item.href}
|
||
onClick={(event) => { event.preventDefault(); navigate(item.href); }}
|
||
><Icon name={item.icon} size={17} /><span>{item.label}</span></a>)}
|
||
</section> : null;
|
||
})}
|
||
</nav>
|
||
<div className="sidebar-foot">
|
||
<span><span className="secure-dot" aria-hidden="true" />Policy boundary active</span>
|
||
<small>Same-origin · server-managed</small>
|
||
</div>
|
||
</aside>
|
||
</> : null}
|
||
<main className="main-content" id="main-content" tabIndex={-1}>
|
||
{authenticated ? <header className="topbar">
|
||
<button ref={navTriggerRef} type="button" className="nav-trigger" aria-label="Open navigation" aria-expanded={navOpen} aria-controls="primary-navigation-panel" onClick={() => setNavOpen(true)}><span aria-hidden="true" /><span aria-hidden="true" /><span aria-hidden="true" /></button>
|
||
<div className="topbar-title"><p className="eyebrow">Governance control plane</p><h1>{title}</h1></div>
|
||
<div className="topbar-actions">
|
||
<button className="command-trigger" type="button" onClick={() => setCommandOpen(true)} aria-label="Open quick jump">
|
||
<Icon name="search" size={16} /><span>Quick jump</span><kbd>Ctrl K</kbd>
|
||
</button>
|
||
<SessionMenu subject={subject} roles={roles} />
|
||
</div>
|
||
</header> : null}
|
||
{authenticated && createdClient ? <div className="notice notice-info client-created-notice" role="status"><span>Pending client {createdClient.name} created for {createdClient.project} / {createdClient.environment}.</span><a href={`/clients?onboard=${encodeURIComponent(createdClient.id)}`} onClick={(event) => { event.preventDefault(); navigate(`/clients?onboard=${encodeURIComponent(createdClient.id)}`); setCreatedClient(null); }}>Resume onboarding</a><button type="button" aria-label="Dismiss client creation notice" onClick={() => setCreatedClient(null)}>Dismiss</button></div> : null}
|
||
{content}
|
||
</main>
|
||
</div>
|
||
{authenticated ? <CommandMenu open={commandOpen} api={api} canReadAudit={canReadAudit} navigate={navigate} onClose={() => setCommandOpen(false)} /> : null}
|
||
</RbacProvider>
|
||
);
|
||
}
|