Files
ITWorx_MCP_Hub-Public/apps/web/src/app.tsx
T

243 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}