Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:36:21 +02:00
commit cb5be898ea
663 files changed
+82674

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Applications
Reserved for independently deployable Hub applications. WP-000 intentionally
adds no runtime application.
+15
View File
@@ -0,0 +1,15 @@
import tsParser from "@typescript-eslint/parser";
export default [
{
ignores: ["dist/**", "coverage/**"],
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: { parser: tsParser },
rules: {
"no-console": "error",
"no-unused-vars": ["error", { args: "none" }],
},
},
];
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f131b" />
<meta name="color-scheme" content="dark light" />
<meta name="description" content="ITWorx MCP Hub governance and operations control plane." />
<title>ITWorx MCP Hub</title>
<link rel="icon" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@itworx-mcp-hub/web",
"version": "0.1.0",
"private": true,
"description": "Authenticated management shell and operational dashboard for ITWorx MCP Hub.",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"test": "vitest run --coverage",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource/inter": "5.3.0",
"@fontsource/jetbrains-mono": "5.3.0",
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@axe-core/playwright": "4.11.0",
"@playwright/test": "^1.62.1",
"@types/node": "^26.1.2",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@typescript-eslint/parser": "8.39.0",
"@vitejs/plugin-react": "4.7.0",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.35.0",
"eslint-plugin-react-hooks": "5.2.0",
"typescript": "5.9.2",
"vite": "7.1.3",
"vitest": "3.2.4"
}
}
+54
View File
@@ -0,0 +1,54 @@
import path from "node:path";
import { defineConfig } from "@playwright/test";
const WEB_DIR = process.cwd();
const REPO_ROOT = path.resolve(WEB_DIR, "..", "..");
const PYTHON = process.env.WP230E2E_PYTHON ?? path.join(REPO_ROOT, ".venv", "Scripts", "python.exe");
const DIST = path.join(WEB_DIR, "dist");
const WEB_PORT = 8780;
const CONTROL_PORT = 8781;
const OIDC_PORT = 8782;
export default defineConfig({
testDir: "./tests/e2e",
testMatch: /.*\.spec\.ts/,
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
use: {
baseURL: `https://127.0.0.1:${WEB_PORT}`,
ignoreHTTPSErrors: true,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
// Playwright starts array entries in order and waits for each `url` to
// answer before starting the next -- required here because control-api
// reads the CA file the oidc process writes at its own startup.
webServer: [
{
command: `"${PYTHON}" -m tests.e2e.harness oidc --port ${OIDC_PORT}`,
url: `https://127.0.0.1:${OIDC_PORT}/.well-known/openid-configuration`,
ignoreHTTPSErrors: true,
reuseExistingServer: false,
timeout: 90_000,
cwd: REPO_ROOT,
},
{
command: `"${PYTHON}" -m tests.e2e.harness control-api --port ${CONTROL_PORT} --oidc-port ${OIDC_PORT} --web-port ${WEB_PORT}`,
url: `http://127.0.0.1:${CONTROL_PORT}/ready`,
reuseExistingServer: false,
timeout: 90_000,
cwd: REPO_ROOT,
},
{
command: `"${PYTHON}" -m tests.e2e.harness web --port ${WEB_PORT} --control-port ${CONTROL_PORT} --dist "${DIST}"`,
url: `https://127.0.0.1:${WEB_PORT}/ready`,
ignoreHTTPSErrors: true,
reuseExistingServer: false,
timeout: 90_000,
cwd: REPO_ROOT,
},
],
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#1b1815"/><circle cx="32" cy="32" r="20" fill="none" stroke="#e8a33d" stroke-width="4"/><circle cx="32" cy="32" r="8.5" fill="#e8a33d"/></svg>

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+41
View File
@@ -0,0 +1,41 @@
import type { CatalogPreview, ClientActionResult, ClientOnboardingResult, DashboardSnapshot, ManagementApiClient, ManagementRole, OnboardingClientKind, OperationsCheck, OperationsSummary, PageQuery, PolicySimulation, ProjectDetailInfo, ResourceKind, ResourcePage, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "./contracts";
export class ApiError extends Error {
readonly status: number;
constructor(status: number) { super(status === 401 ? "Your management session has expired." : "The control API is unavailable."); this.name = "ApiError"; this.status = status; }
}
async function postJson<T>(path: string, body: unknown, csrfToken: string | null): Promise<T> { if (!csrfToken) throw new ApiError(403); const response = await fetch(path, { method: "POST", credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, body: JSON.stringify(body) }); if (!response.ok) throw new ApiError(response.status); return (await response.json()) as T; }
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path, { credentials: "same-origin", headers: { Accept: "application/json" } });
if (!response.ok) throw new ApiError(response.status);
return (await response.json()) as T;
}
export async function logoutSession(): Promise<void> {
const session = await getJson<{ csrf_token?: string }>("/auth/session");
const csrfToken = session.csrf_token;
if (!csrfToken) throw new ApiError(403);
const response = await fetch("/auth/logout", {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json", "X-CSRF-Token": csrfToken },
});
if (!response.ok) throw new ApiError(response.status);
}
export function createApiClient(): ManagementApiClient { let csrfToken: string | null = null; return {
session: async () => { const raw = await getJson<{ authenticated: boolean; csrf_token?: string; session?: { subject: string; client_id: string | null; kind: string; roles: string[]; expires_at: string } }>("/auth/session"); csrfToken = raw.csrf_token ?? null; return { authenticated: raw.authenticated, csrf_token: raw.csrf_token, session: raw.session ? { subject: raw.session.subject, clientId: raw.session.client_id, kind: raw.session.kind as "management_user" | "machine_client", roles: raw.session.roles as ManagementRole[], expiresAt: raw.session.expires_at } : undefined }; },
dashboard: () => getJson<DashboardSnapshot>("/api/dashboard"),
resource: (kind: ResourceKind, query: PageQuery) => { const params = new URLSearchParams({ q: query.filter, limit: String(query.limit) }); if (query.cursor) params.set("cursor", query.cursor); return getJson<ResourcePage>(`/api/${kind}?${params.toString()}`); },
projectDetail: (slug) => getJson<ProjectDetailInfo>(`/api/projects/${encodeURIComponent(slug)}`),
simulatePolicy: (input) => postJson<PolicySimulation>("/api/policies/simulate", input, csrfToken),
testTool: (input) => postJson<ToolReadinessResult>("/api/tools/test", input, csrfToken),
toolDefinition: (canonicalName) => getJson<ToolDefinition>(`/api/tool-definitions/${encodeURIComponent(canonicalName)}`),
invokeTool: (input) => postJson<ToolInvocationResult>("/api/tools/invoke", input, csrfToken),
operationsSummary: () => getJson<OperationsSummary>("/api/operations/summary"),
runOperationsCheck: () => postJson<OperationsCheck>("/api/operations/check", {}, csrfToken),
previewCatalog: (input) => postJson<CatalogPreview>("/api/catalog/preview", input, csrfToken),
applyCatalog: (input) => postJson<ClientActionResult>("/api/catalog/apply", input, csrfToken),
clientAction: async (id, action) => { await postJson(`/api/client-management/${encodeURIComponent(id)}/${action}`, {}, csrfToken); return { accepted: true, message: `Client ${action} completed.` }; },
createClient: (input: { name: string; project: string; environment: string; kind: OnboardingClientKind }) => postJson<ClientOnboardingResult>("/api/client-management", input, csrfToken),
generatedConfig: (id) => getJson<{ clientId: string; endpoint: string; scopes: string[] }>(`/api/client-management/${encodeURIComponent(id)}/config`),
}; }
export function safeApiMessage(error: unknown): string { return error instanceof ApiError ? error.message : "The control API returned no usable data."; }
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { 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, 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";
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 navTriggerRef = useRef<HTMLButtonElement>(null);
const title = useMemo(() => routeTitle(basePath(path)), [path]);
const clearIdentity = () => {
setRoles([]);
setSnapshot(null);
setSubject(null);
};
const closeNavigation = (restoreFocus = true) => {
setNavOpen(false);
if (restoreFocus) window.setTimeout(() => navTriggerRef.current?.focus(), 0);
};
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(() => {
if (!navOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeNavigation();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [navOpen]);
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 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 path={path} navigate={navigate} api={api} />
: kind
? <ResourcePage path={path} kind={kind} api={api} navigate={navigate} />
: snapshot && basePath(path) === "/"
? <Dashboard snapshot={snapshot} reload={() => void load()} navigate={navigate} />
: basePath(path) === "/operations"
? <Operations path={path} navigate={navigate} snapshot={snapshot} />
: 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 type="button" className="nav-backdrop" onClick={() => closeNavigation()} aria-label="Dismiss navigation" /> : null}
<aside className={navOpen ? "sidebar sidebar-open" : "sidebar"} aria-label="Application sidebar">
<div className="sidebar-head">
<a href="/" onClick={(event) => { event.preventDefault(); navigate("/"); }}><Brand /></a>
<button 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} 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}
{content}
</main>
</div>
{authenticated ? <CommandMenu open={commandOpen} canReadAudit={canReadAudit} navigate={navigate} onClose={() => setCommandOpen(false)} /> : null}
</RbacProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
export const workspaceName = "ITWorx MCP Hub" as const;
export function bootstrapMessage(): string {
return `${workspaceName} web workspace is ready for WP-120.`;
}
+14
View File
@@ -0,0 +1,14 @@
export function Brand({ compact = false }: { compact?: boolean }) {
return (
<span className={compact ? "brand preauth-brand" : "brand"}>
<span className="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none">
<path d="M5.8 9.5 12 5.9l6.2 3.6v7L12 20.1l-6.2-3.6v-7Z" stroke="currentColor" strokeWidth="1.5" />
<circle cx="12" cy="13" r="2.7" fill="currentColor" />
<path d="M12 6v4.2M6 9.7l3.7 2.1M18 9.7l-3.7 2.1" stroke="currentColor" strokeWidth="1.4" />
</svg>
</span>
<span><strong>ITWorx</strong><small>MCP Hub</small></span>
</span>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { routeTitle } from "../contracts";
import { basePath } from "../routing";
export function Breadcrumbs({ path, navigate }: { path: string; navigate: (next: string) => void }) { return <nav className="breadcrumbs" aria-label="Breadcrumb"><a href="/" onClick={(event) => { event.preventDefault(); navigate("/"); }}>Overview</a><span aria-hidden="true">/</span><strong>{routeTitle(basePath(path))}</strong></nav>; }
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { availableNavigation } from "../navigation";
import { Icon } from "./Icon";
import { Modal } from "./Modal";
export function CommandMenu({
open,
canReadAudit,
navigate,
onClose,
}: {
open: boolean;
canReadAudit: boolean;
navigate: (next: string) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const items = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]);
const results = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return items;
return items.filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(needle));
}, [items, query]);
useEffect(() => {
if (!open) return;
setQuery("");
const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(focusTimer);
}, [open]);
const select = (href: string) => {
onClose();
navigate(href);
};
return (
<Modal open={open} title="Quick jump" onClose={onClose} className="command-modal">
<label className="command-search">
<span className="sr-only">Find a workspace</span>
<Icon name="search" size={17} />
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Find projects, catalog, policies…"
autoComplete="off"
/>
<kbd>Esc</kbd>
</label>
<div className="command-results" aria-live="polite">
{results.length ? results.map((item) => (
<button type="button" className="command-result" key={item.href} onClick={() => select(item.href)}>
<span className="command-result-icon"><Icon name={item.icon} /></span>
<span><strong>{item.label}</strong><small>{item.description}</small></span>
<Icon name="arrow" size={16} />
</button>
)) : (
<div className="command-empty"><strong>No workspace found</strong><span>Try a broader name or capability.</span></div>
)}
</div>
<p className="command-hint">Navigate without leaving the keyboard. Access-controlled destinations remain hidden.</p>
</Modal>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Fragment, useState } from "react";
import type { ResourceKind, ResourcePage, ResourceRecord } from "../contracts";
import { Modal } from "./Modal";
import { StatusPill } from "./StatusPill";
export type Density = "comfortable" | "compact";
export type ColumnKey = "name" | "status" | "project" | "summary" | "risk" | "decision" | "actions";
const ALL_COLUMNS: Record<ResourceKind, ColumnKey[]> = {
projects: ["name", "status", "project", "summary"],
servers: ["name", "status", "project", "summary"],
tools: ["name", "status", "project", "summary", "risk", "actions"],
connectors: ["name", "status", "project", "summary"],
clients: ["name", "status", "project", "summary", "actions"],
policies: ["name", "status", "project", "summary"],
audit: ["name", "status", "project", "summary", "decision"],
};
export function columnsFor(kind: ResourceKind): ColumnKey[] { return ALL_COLUMNS[kind]; }
export function DataTable({
data, kind, density, visibleColumns, onClientAction, onConfig, onTest,
}: {
data: ResourcePage;
kind: ResourceKind;
density: Density;
visibleColumns: ColumnKey[];
onClientAction: (id: string, action: "disable" | "revoke") => void;
onConfig: (id: string) => void;
onTest: (row: ResourceRecord) => void;
}) {
const show = (column: ColumnKey) => visibleColumns.includes(column);
const [confirmingAction, setConfirmingAction] = useState<{ id: string; action: "disable" | "revoke"; name: string } | null>(null);
const actions = (row: ResourceRecord) => {
if (kind === "clients" && show("actions")) return <div className="inline-actions"><button className="button button-small" onClick={() => setConfirmingAction({ id: row.id, action: "disable", name: row.name })} disabled={row.status !== "healthy"}>Disable</button><button className="button button-small button-danger" onClick={() => setConfirmingAction({ id: row.id, action: "revoke", name: row.name })} disabled={row.status === "unavailable"}>Revoke</button><button className="button button-small button-secondary" onClick={() => onConfig(row.id)}>Config</button></div>;
if (kind === "tools" && show("actions")) return <div className="inline-actions"><button className="button button-small button-secondary" onClick={() => onTest(row)}>Readiness</button></div>;
return null;
};
return (
<Fragment>
<div className={`table-wrap desktop-data-table table-kind-${kind} density-${density}`} role="region" tabIndex={0} aria-label={`${kind} records table`}>
<table>
<caption className="sr-only">{kind} records</caption>
<thead>
<tr>
{show("name") && <th>Name</th>}
{show("status") && <th>Status</th>}
{show("project") && <th>Project</th>}
{show("summary") && <th>Summary</th>}
{kind === "tools" && show("risk") && <th>Risk</th>}
{kind === "audit" && show("decision") && <th>Decision / trace</th>}
{(kind === "clients" || kind === "tools") && show("actions") && <th>Actions</th>}
</tr>
</thead>
<tbody>
{data.rows.map((row) => (
<tr key={row.id}>
{show("name") && <td><strong className="long-name">{row.name}</strong><small>{row.id}</small></td>}
{show("status") && <td><StatusPill value={row.status} /></td>}
{show("project") && <td>{row.project ?? "—"}</td>}
{show("summary") && <td>{row.summary}</td>}
{kind === "tools" && show("risk") && <td>{row.risk ?? "R0"}</td>}
{kind === "audit" && show("decision") && <td>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</td>}
{(kind === "clients" || kind === "tools") && show("actions") && <td>{actions(row)}</td>}
</tr>
))}
</tbody>
</table>
</div>
<div className="mobile-record-list" aria-label={`${kind} records`}>
{data.rows.map((row) => <article className="mobile-record" key={row.id}><div className="mobile-record-heading"><div><strong>{row.name}</strong><small>{row.id}</small></div>{show("status") && <StatusPill value={row.status} />}</div><dl>{show("project") && <><dt>Project</dt><dd>{row.project ?? "—"}</dd></>}{show("summary") && <><dt>Summary</dt><dd>{row.summary}</dd></>}{kind === "tools" && show("risk") && <><dt>Risk</dt><dd><span className="risk-label" title="Risk classification">{row.risk ?? "R0"}</span></dd></>}{kind === "audit" && show("decision") && <><dt>Decision / trace</dt><dd>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</dd></>}</dl>{actions(row)}</article>)}
</div>
<Modal
open={confirmingAction !== null}
title={`${confirmingAction?.action === "disable" ? "Disable" : "Revoke"} ${confirmingAction?.name}?`}
onClose={() => setConfirmingAction(null)}
>
<p>This action is logged and takes effect immediately.</p>
<div className="state-actions">
<button
className="button"
data-testid="confirm-action"
onClick={() => {
if (!confirmingAction) return;
onClientAction(confirmingAction.id, confirmingAction.action);
setConfirmingAction(null);
}}
>
Confirm
</button>
<button className="button button-secondary" onClick={() => setConfirmingAction(null)}>Cancel</button>
</div>
</Modal>
</Fragment>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Icon } from "./Icon";
export function ErrorPanel({ message, expired, retry }: { message: string; expired?: boolean; retry: () => void }) {
return <section className="panel state-panel error-state" role="alert">
<div className="state-icon" aria-hidden="true">!</div>
<p className="eyebrow">Recoverable interruption</p>
<h2>{expired ? "Session expired" : "Management data unavailable"}</h2>
<p>{message}</p>
<div className="state-actions"><button className="button" onClick={retry}><Icon name="refresh" size={14} />{expired ? "Check session again" : "Retry connection"}</button>{expired ? <a className="button button-secondary" href="/auth/login">Re-authenticate</a> : null}</div>
<small className="state-footnote">No cached management data is shown while the control-plane response is untrusted.</small>
</section>;
}
+49
View File
@@ -0,0 +1,49 @@
import type { ReactNode } from "react";
export type IconName =
| "overview"
| "projects"
| "servers"
| "catalog"
| "connectors"
| "clients"
| "policies"
| "audit"
| "operations"
| "settings"
| "search"
| "refresh"
| "arrow";
const paths: Record<IconName, ReactNode> = {
overview: <><path d="M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-16v4h6V4h-6Z" /></>,
projects: <><path d="M3.5 7.5h6l1.8 2H20.5v8.8a1.7 1.7 0 0 1-1.7 1.7H5.2a1.7 1.7 0 0 1-1.7-1.7V7.5Z" /><path d="M5.5 7.5V5.7A1.7 1.7 0 0 1 7.2 4h4.2l1.7 2h4.7a1.7 1.7 0 0 1 1.7 1.7v1.8" /></>,
servers: <><rect x="4" y="4" width="16" height="6" rx="1.5" /><rect x="4" y="14" width="16" height="6" rx="1.5" /><path d="M8 7h.01M8 17h.01M12 7h5M12 17h5" /></>,
catalog: <><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H10v16H5.5A1.5 1.5 0 0 1 4 18.5v-13ZM14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14V4Z" /><path d="M7 8h1M16 8h1" /></>,
connectors: <><circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="6" r="2.5" /><circle cx="18" cy="18" r="2.5" /><path d="m8.2 10.9 7.6-3.8M8.2 13.1l7.6 3.8" /></>,
clients: <><circle cx="9" cy="8" r="3" /><path d="M3.8 19c.5-3.4 2.2-5 5.2-5s4.7 1.6 5.2 5M16 10h4M18 8v4" /></>,
policies: <><path d="M12 3.5 19 6v5.2c0 4.4-2.3 7.4-7 9.3-4.7-1.9-7-4.9-7-9.3V6l7-2.5Z" /><path d="m8.5 12 2.2 2.2 4.8-5" /></>,
audit: <><path d="M6 3.5h9L19 7v13.5H6V3.5Z" /><path d="M15 3.5V7h4M9 11h6M9 15h6" /><circle cx="6" cy="18" r="2.5" /></>,
operations: <><path d="M4 17.5h3l2.2-5 3.1 7 2.5-11 2 6H20" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4v-.2a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.6v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>,
search: <><circle cx="10.5" cy="10.5" r="6" /><path d="m15 15 5 5" /></>,
refresh: <><path d="M20 6v5h-5M4 18v-5h5" /><path d="M18.2 9A7 7 0 0 0 6.1 6.2L4 11M5.8 15A7 7 0 0 0 17.9 17.8L20 13" /></>,
arrow: <><path d="M5 12h14M14 7l5 5-5 5" /></>,
};
export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
return (
<svg
aria-hidden="true"
className="icon"
fill="none"
height={size}
viewBox="0 0 24 24"
width={size}
>
<g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6">
{paths[name]}
</g>
</svg>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function LoadingPanel() {
return <section className="panel state-panel loading-state" aria-live="polite" aria-busy="true">
<div className="state-orbit" aria-hidden="true"><span /><span /><span /></div>
<div><p className="eyebrow">Synchronizing control plane</p><h2>Loading operational data</h2><p>Checking the authenticated session and refreshing bounded management views.</p></div>
<div className="loading-skeleton" aria-hidden="true"><span /><span /><span /></div>
</section>;
}
+135
View File
@@ -0,0 +1,135 @@
import type { Health } from "../contracts";
type TopologyProps = {
hub?: Health;
gateway?: Health;
connectors?: Array<{ name: string; state: Health }>;
compact?: boolean;
};
const labels: Record<Health, string> = {
healthy: "healthy",
degraded: "degraded",
unavailable: "unavailable",
unknown: "unknown",
};
function shortConnectorName(name: string): string {
const normalized = name.toLowerCase();
if (normalized.includes("gitea")) return "Gitea";
if (normalized.includes("unraid")) return "Unraid";
if (normalized.includes("knowledge")) return "Knowledge";
if (normalized.includes("mobility")) return "Mobility";
return name.length > 13 ? `${name.slice(0, 12)}…` : name;
}
export function McpTopology({ hub = "healthy", gateway = "healthy", connectors = [], compact = false }: TopologyProps) {
const targets = connectors.length
? connectors.slice(0, 3).map((connector) => ({ label: shortConnectorName(connector.name), state: connector.state }))
: [
{ label: "Gitea", state: "healthy" as const },
{ label: "Knowledge", state: "healthy" as const },
{ label: "Unraid", state: "healthy" as const },
];
const hiddenTargets = Math.max(0, connectors.length - targets.length);
const description = `Client requests pass through a ${labels[hub]} policy boundary and a ${labels[gateway]} MCP gateway to ${connectors.length || 3} scoped connector${(connectors.length || 3) === 1 ? "" : "s"}.`;
return (
<figure className={compact ? "topology topology-compact" : "topology"} aria-label="Governed MCP request path">
<div className="topology-head">
<span><span className={`signal-dot signal-${hub}`} aria-hidden="true" />Governed request path</span>
<small>{connectors.length ? `${connectors.length} live routes` : "Deny by default"}</small>
</div>
<div className="topology-canvas" aria-hidden="true">
<svg viewBox="0 0 620 244" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="flow-gradient" x1="0" x2="1">
<stop offset="0" stopColor="currentColor" stopOpacity="0.18" />
<stop offset="0.5" stopColor="currentColor" stopOpacity="0.9" />
<stop offset="1" stopColor="currentColor" stopOpacity="0.18" />
</linearGradient>
</defs>
<g className="topology-links">
<path d="M118 122H202" />
<path d="M318 122H390" />
<path d="M494 122H524V48H542" />
<path d="M494 122H542" />
<path d="M494 122H524V196H542" />
</g>
<g className="topology-flow">
<path d="M118 122H202" />
<path d="M318 122H390" />
<path d="M494 122H524V48H542" />
<path d="M494 122H542" />
<path d="M494 122H524V196H542" />
</g>
<g className="topology-node topology-node-client">
<rect x="20" y="92" width="98" height="60" rx="10" />
<circle cx="45" cy="115" r="8" />
<path d="M34 140c2-8 6-11 11-11s9 3 11 11" />
<text x="67" y="116">Client</text>
<text className="topology-sub" x="67" y="135">Codex / MCP</text>
</g>
<g className={`topology-node topology-node-${hub}`}>
<rect x="202" y="84" width="116" height="76" rx="10" />
<path d="M228 105.5 240 110v9c0 8-4 13-12 17-8-4-12-9-12-17v-9l12-4.5Z" />
<path d="m222 120 4 4 8-9" />
<text x="250" y="112">Policy gate</text>
<text className="topology-sub" x="250" y="132">Scope + identity</text>
<circle className="topology-state" cx="300" cy="99" r="4" />
</g>
<g className={`topology-node topology-node-${gateway}`}>
<rect x="390" y="84" width="104" height="76" rx="10" />
<circle cx="416" cy="122" r="13" />
<circle cx="416" cy="122" r="4" />
<text x="438" y="116">Gateway</text>
<text className="topology-sub" x="438" y="136">ContextForge</text>
<circle className="topology-state" cx="476" cy="99" r="4" />
</g>
{targets.map((target, index) => {
const y = [28, 102, 176][index] ?? 102;
return (
<g className={`topology-target topology-node-${target.state}`} key={`${target.label}-${index}`}>
<rect x="542" y={y} width="70" height="40" rx="8" />
<circle className="topology-state" cx="555" cy={y + 20} r="4" />
<text x="565" y={y + 24}>{target.label}</text>
</g>
);
})}
</svg>
</div>
<div className="topology-mobile-flow" aria-hidden="true">
<div className="topology-mobile-node">
<span className="topology-mobile-mark">C</span>
<span><strong>Client</strong><small>Codex / MCP</small></span>
</div>
<span className="topology-mobile-link" />
<div className={`topology-mobile-node topology-node-${hub}`}>
<span className="topology-mobile-mark">P</span>
<span><strong>Policy gate</strong><small>Scope + identity</small></span>
<i className="topology-state" />
</div>
<span className="topology-mobile-link" />
<div className={`topology-mobile-node topology-node-${gateway}`}>
<span className="topology-mobile-mark">G</span>
<span><strong>Gateway</strong><small>ContextForge</small></span>
<i className="topology-state" />
</div>
<span className="topology-mobile-link" />
<div className="topology-mobile-targets">
{targets.map((target, index) => <span className={`topology-node-${target.state}`} key={`${target.label}-mobile-${index}`}><i className="topology-state" />{target.label}</span>)}
{hiddenTargets > 0 ? <span>+{hiddenTargets} routes</span> : null}
</div>
</div>
<figcaption>
<span>{description}</span>
{hiddenTargets > 0 ? <small>+{hiddenTargets} additional scoped route{hiddenTargets === 1 ? "" : "s"}</small> : null}
</figcaption>
</figure>
);
}
+4
View File
@@ -0,0 +1,4 @@
import type { Health } from "../contracts";
import { StatusPill } from "./StatusPill";
export function MetricCard({ label, value, detail, status }: { label: string; value: string; detail: string; status?: Health }) { return <article className="metric-card"><div className="metric-heading"><span>{label}</span>{status && <StatusPill value={status} />}</div><strong>{value}</strong><p>{detail}</p></article>; }
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
export function Modal({ open, title, onClose, children, className = "" }: { open: boolean; title: string; onClose: () => void; children: ReactNode; className?: string }) {
const dialogRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
const titleId = useId();
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return;
// Kept in its own effect, keyed only on `open` (not `onClose`): if this
// ran on every `onClose` identity change (callers commonly pass a fresh
// inline arrow each render), it would re-capture document.activeElement
// after focus had already moved into the dialog, overwriting the real
// trigger element with the dialog itself.
const previouslyFocused = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialogRef.current?.focus();
return () => {
document.body.style.overflow = previousOverflow;
previouslyFocused?.focus();
};
}, [open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCloseRef.current();
return;
}
if (event.key !== "Tab" || !dialogRef.current) return;
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)).filter((element) => !element.hasAttribute("hidden"));
if (!focusable.length) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
className={`modal ${className}`.trim()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={dialogRef}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<h2 id={titleId}>{title}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close dialog">×</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useState } from "react";
import { logoutSession, safeApiMessage } from "../api";
import type { ManagementRole } from "../contracts";
export function SessionMenu({ subject, roles }: { subject: string | null; roles: ManagementRole[] }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const initial = subject?.trim().charAt(0).toUpperCase() || "•";
const signOut = async () => {
setBusy(true);
setError("");
try {
await logoutSession();
window.location.assign("/");
} catch (caught) {
setError(safeApiMessage(caught));
setBusy(false);
}
};
return <div className="session-menu"><span className="tag">Authenticated</span><div className="session-identity"><span className="avatar" aria-hidden="true">{initial}</span><span><strong>{subject ?? "Authenticated user"}</strong><small>{roles.join(" · ") || "management"}</small></span></div><button className="button button-small button-secondary" type="button" disabled={busy} onClick={() => void signOut()}>{busy ? "Signing out…" : "Sign out"}</button>{error ? <span className="session-error" role="alert">{error}</span> : null}</div>;
}
+4
View File
@@ -0,0 +1,4 @@
import type { Health } from "../contracts";
import { healthLabel } from "../format";
export function StatusPill({ value }: { value: Health }) { return <span className={`status status-${value}`} aria-label={`Status: ${healthLabel(value)}`}><span aria-hidden="true" />{healthLabel(value)}</span>; }
+73
View File
@@ -0,0 +1,73 @@
export type Health = "healthy" | "degraded" | "unavailable" | "unknown";
export type SessionStatus = "checking" | "authenticated" | "unauthenticated" | "expired";
export type ManagementRole = "viewer" | "auditor" | "operator" | "admin";
export type PrincipalKind = "management_user" | "machine_client";
export interface SessionMetadata { subject: string; clientId: string | null; kind: PrincipalKind; roles: ManagementRole[]; expiresAt: string; }
export interface SessionInfo { authenticated: boolean; csrf_token?: string; session?: SessionMetadata; }
export interface ProjectStatus { slug: string; name: string; environment: string; health: Health; detail: string; }
export interface ConnectorStatus { name: string; project: string; state: Health; detail: string; }
export interface DashboardSnapshot {
generatedAt: string; staleAfterSeconds: number;
health: { hub: Health; gateway: Health; detail: string };
projects: ProjectStatus[]; connectors: ConnectorStatus[];
denials: { count: number; window: string; latestTool?: string };
drift: { count: number; projects: string[]; known: boolean; detail: string }; activeClients: number;
backup: { state: Health; lastSuccessfulAt: string | null; detail: string; restoreVerified?: boolean; artifactCount?: number };
version: { application: string; schema: string };
}
export interface ApiClient { session(): Promise<SessionInfo>; dashboard(): Promise<DashboardSnapshot>; }
export type ResourceKind = "projects" | "servers" | "tools" | "connectors" | "clients" | "policies" | "audit";
export interface PageQuery { filter: string; cursor?: string; limit: number; }
export interface PageInfo { nextCursor?: string; total: number; }
export interface ResourceRecord { id: string; name: string; status: Health; summary: string; project?: string; environment?: string; risk?: string; decision?: string; traceId?: string; canonicalName?: string; }
export interface ResourcePage { kind: ResourceKind; generatedAt: string; rows: ResourceRecord[]; page: PageInfo; }
export type PolicyDecision = "allow" | "allow_with_preview" | "allow_with_approval" | "allow_if_conditions" | "enforce" | "deny" | "deny_and_alert";
export interface PolicyTraceStep { phase: string; rule: string | null; matched: boolean; reason: string; }
export interface PolicySimulation { decision: PolicyDecision; allowed: boolean; matchedPolicies: string[]; reasons: string[]; reasonCode: string; traceId: string; requiredScopes: string[]; steps: PolicyTraceStep[]; }
export interface ToolReadinessResult {
ready: boolean; tool: string; project: string; environment: string; note: string;
checks: {
catalog: { valid: boolean; readOnly: boolean; connectorType: string };
input: { valid: boolean; errors: string[] };
binding: { valid: boolean; detail: string };
policy: { allowed: boolean; decision: PolicyDecision; reasonCode: string; traceId: string };
connector: { reachable: boolean; status: string; detail: string };
};
}
export interface ToolDefinition { canonicalName: string; description: string; readOnly: boolean; riskClass: string; inputSchema: { type?: string; required?: string[]; properties?: Record<string, { type?: string | string[]; title?: string; description?: string; default?: unknown; enum?: unknown[]; minimum?: number; maximum?: number; minLength?: number; maxLength?: number }> }; limits: { timeoutMs?: number; maxOutputBytes?: number; maxItems?: number }; }
export interface ToolInvocationResult { ok: boolean; traceId: string; tool: string; content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown; warnings?: string[]; error?: { code: string; message: string }; }
export interface OperationsSummary { generatedAt: string; backup: DashboardSnapshot["backup"]; audit24h: { success: number; denied: number; error: number; rate_limited: number }; auditTruncated: boolean; trend: Array<{ hour: string; success: number; denied: number; error: number; rate_limited: number }>; alerts: Array<{ severity: "info" | "warning"; code: string; message: string }>; }
export interface OperationsCheck { generatedAt: string; healthy: boolean; connectors: Record<string, { reachable: boolean; status: string; detail: string }>; }
export interface CatalogPreview { valid: boolean; changes: string[]; errors: string[]; }
export interface ClientActionResult { accepted: boolean; message: string; }
export type OnboardingClientKind = "native-2026" | "compatibility-2025" | "stdio-bridge" | "inspector";
export interface ClientOnboardingResult { client: { id: string; name: string; project: string; environment: string; status: string }; bootstrap: { reference: string; delivery: string; expiresAt: string }; config: { clientId: string; kind: OnboardingClientKind; protocolVersion: string; endpoint: string; transport: string; scopes: string[]; credentialReference: string; environmentVariable?: string; bridgeCommand?: string }; provisioning: { status: string; subject: string | null; issuer: string; oauthUsername: string; tokenEndpoint: string; oauthClientId: string; steps: string[] }; }
export interface ProjectEnvironment { name: string; classification: string | null; }
export interface ProjectConnectorBinding { ref: string; connectorType: string | null; environments: string[]; verification: string | null; }
export interface ProjectDetailInfo { slug: string; name: string; description: string; environments: ProjectEnvironment[]; capabilityPacks: string[]; connectorBindings: ProjectConnectorBinding[]; virtualServerCount: number; clientCount: number; activeClientCount: number; }
export interface ManagementApiClient extends ApiClient {
resource(kind: ResourceKind, query: PageQuery): Promise<ResourcePage>;
projectDetail(slug: string): Promise<ProjectDetailInfo>;
simulatePolicy(input: { project: string; environment: string; tool: string; clientId?: string; capability?: string; scopes?: string[]; bindingPresent?: boolean; clientEnabled?: boolean }): Promise<PolicySimulation>;
testTool(input: { project: string; environment: string; tool: string; arguments: Record<string, unknown> }): Promise<ToolReadinessResult>;
toolDefinition(canonicalName: string): Promise<ToolDefinition>;
invokeTool(input: { project: string; environment: string; tool: string; arguments: Record<string, unknown> }): Promise<ToolInvocationResult>;
operationsSummary(): Promise<OperationsSummary>;
runOperationsCheck(): Promise<OperationsCheck>;
previewCatalog(input: { project: string }): Promise<CatalogPreview>;
applyCatalog(input: { project: string }): Promise<ClientActionResult>;
clientAction(id: string, action: "disable" | "revoke"): Promise<ClientActionResult>;
createClient(input: { name: string; project: string; environment: string; kind: OnboardingClientKind }): Promise<ClientOnboardingResult>;
generatedConfig(id: string): Promise<{ clientId: string; endpoint: string; scopes: string[] }>;
}
export function isSnapshotStale(snapshot: DashboardSnapshot, now = Date.now()): boolean {
const generated = Date.parse(snapshot.generatedAt);
return !Number.isFinite(generated) || now - generated > snapshot.staleAfterSeconds * 1000;
}
export function routeTitle(pathname: string): string {
const route = pathname.replace(/\/$/, "") || "/";
if (route === "/") return "Overview";
if (route.startsWith("/projects/")) return "Project detail";
return ({ "/projects": "Projects", "/servers": "MCP Servers", "/tools": "Catalog", "/connectors": "Connectors", "/clients": "Clients", "/policies": "Policies", "/operations": "Operations", "/audit": "Audit", "/settings": "Platform" } as Record<string, string>)[route] ?? "Not found";
}
+8
View File
@@ -0,0 +1,8 @@
export const BRUSSELS_TIME_ZONE = "Europe/Brussels";
export function formatBrusselsTime(value: string | null): string {
if (!value) return "Never recorded";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Unknown time";
return new Intl.DateTimeFormat("en-GB", { timeZone: BRUSSELS_TIME_ZONE, dateStyle: "medium", timeStyle: "short" }).format(date);
}
export function healthLabel(value: string): string { return value.replace(/(^|-)([a-z])/g, (_, separator: string, letter: string) => `${separator}${letter.toUpperCase()}`); }
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient } from "./contracts";
/** Real registered projects, fetched once per mount -- replaces every
* hardcoded "ludarium"/"prod" default that used to appear regardless of
* what was actually registered in the catalog. */
export function useProjectOptions(api: ManagementApiClient) {
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
useEffect(() => { let active = true; void api.resource("projects", { filter: "", limit: 100 }).then((page) => { if (active) setProjects(page.rows.map((row) => ({ id: row.id, name: row.name }))); }).catch(() => { if (active) setProjects([]); }); return () => { active = false; }; }, [api]);
return projects;
}
/** The registered environments for whichever project is currently selected
* -- re-fetched every time `project` changes, empty while nothing is
* selected or the lookup is in flight. */
export function useProjectEnvironments(api: ManagementApiClient, project: string) {
const [environments, setEnvironments] = useState<string[]>([]);
useEffect(() => { if (!project) { setEnvironments([]); return; } let active = true; void api.projectDetail(project).then((detail) => { if (active) setEnvironments(detail.environments.map((item) => item.name)); }).catch(() => { if (active) setEnvironments([]); }); return () => { active = false; }; }, [api, project]);
return environments;
}
+12
View File
@@ -0,0 +1,12 @@
import { createRoot } from "react-dom/client";
import { createElement } from "react";
import { App } from "./app";
import "./styles.css";
const storedTheme = localStorage.getItem("hub-theme");
if (storedTheme === "light" || storedTheme === "dark") {
document.documentElement.setAttribute("data-theme", storedTheme);
}
const root = document.querySelector<HTMLDivElement>("#root");
if (root) createRoot(root).render(createElement(App));
+50
View File
@@ -0,0 +1,50 @@
import type { IconName } from "./components/Icon";
export type NavigationItem = {
href: string;
label: string;
description: string;
icon: IconName;
auditOnly?: boolean;
};
export type NavigationGroup = {
label: string;
items: NavigationItem[];
};
export const navigation: NavigationGroup[] = [
{
label: "Overview",
items: [{ href: "/", label: "Overview", description: "Live platform signal and exceptions", icon: "overview" }],
},
{
label: "Build",
items: [
{ href: "/projects", label: "Projects", description: "Project environments and bindings", icon: "projects" },
{ href: "/servers", label: "MCP Servers", description: "Governed virtual MCP endpoints", icon: "servers" },
{ href: "/tools", label: "Catalog", description: "Discover and validate available tools", icon: "catalog" },
{ href: "/connectors", label: "Connectors", description: "Source integrations and health", icon: "connectors" },
],
},
{
label: "Govern",
items: [
{ href: "/clients", label: "Clients", description: "Scoped machine identities and config", icon: "clients" },
{ href: "/policies", label: "Policies", description: "Explain effective authorization", icon: "policies" },
{ href: "/audit", label: "Audit", description: "Trace governed requests and decisions", icon: "audit", auditOnly: true },
],
},
{
label: "Operate",
items: [{ href: "/operations", label: "Operations", description: "Checks, alerts, drift and recovery", icon: "operations" }],
},
{
label: "Admin",
items: [{ href: "/settings", label: "Platform", description: "Session and platform preferences", icon: "settings" }],
},
];
export function availableNavigation(canReadAudit: boolean): NavigationItem[] {
return navigation.flatMap((group) => group.items).filter((item) => !item.auditOnly || canReadAudit);
}
+37
View File
@@ -0,0 +1,37 @@
import { Brand } from "../components/Brand";
import { Icon } from "../components/Icon";
import { McpTopology } from "../components/McpTopology";
const trustPoints = [
["Project scoped", "Only assigned capabilities are visible"],
["Policy checked", "Identity and environment are evaluated per call"],
["Fully audited", "Every governed request receives a trace ID"],
] as const;
export function AuthGateway() {
return (
<section className="auth-layout" aria-labelledby="auth-heading">
<div className="auth-copy">
<Brand compact />
<p className="eyebrow">Secure management access</p>
<h1 id="auth-heading">Inspect your governed MCP estate.</h1>
<p className="auth-lead">One operational view for project tools, connector health, policy decisions and audited client access.</p>
<div className="auth-actions">
<a className="button auth-button" href="/auth/login">Continue to sign in <Icon name="arrow" size={16} /></a>
<span><span className="secure-dot" aria-hidden="true" />Same-origin Authentik session</span>
</div>
<small className="auth-help">Use your approved ITWorx identity. Tokens remain in the server-managed session and are never exposed to this browser.</small>
<dl className="auth-trust-list">
{trustPoints.map(([title, detail]) => <div key={title}><dt>{title}</dt><dd>{detail}</dd></div>)}
</dl>
</div>
<div className="auth-visual" aria-label="How governed MCP access works">
<div className="auth-visual-intro"><p className="eyebrow">Live control path</p><span className="tag">Read-only v1</span></div>
<h2>Capability access without infrastructure exposure.</h2>
<p>Requests move through explicit identity, policy and project boundaries before reaching a purpose-built connector.</p>
<McpTopology compact />
<div className="auth-boundary"><strong>Infrastructure stays behind the boundary</strong><span>No Docker socket, broad filesystem, unrestricted SQL or reusable source credentials reach the client.</span></div>
</div>
</section>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import type { CatalogPreview, ManagementApiClient } from "../contracts";
import { safeApiMessage } from "../api";
import { useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context";
import { Modal } from "../components/Modal";
export function CatalogWorkflow({ api }: { api: ManagementApiClient }) {
const canApply = useCan("catalog.apply");
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const [preview, setPreview] = useState<CatalogPreview | null>(null);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
const run = async () => { if (!project) return; setBusy(true); setMessage(""); try { setPreview(await api.previewCatalog({ project })); } catch (caught) { setMessage(safeApiMessage(caught)); } finally { setBusy(false); } };
const apply = async () => { if (!project) return; setBusy(true); try { const result = await api.applyCatalog({ project }); setMessage(result.message); } catch (caught) { setMessage(safeApiMessage(caught)); } finally { setBusy(false); } };
return <section className="panel workflow-panel"><div className="panel-heading"><div><p className="eyebrow">Safe configuration</p><h3>Catalog validation and preview</h3></div></div><p className="muted">Preview is read-only. Apply remains disabled until validation reports no errors and the server authorizes the change.</p><div className="form-grid"><label>Project<select value={project} onChange={(event) => { setProject(event.target.value); setPreview(null); setMessage(""); }} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></div><button className="button button-secondary" onClick={() => void run()} disabled={busy || !project}>{busy ? "Validating…" : "Validate and preview"}</button>{preview && <div className="diff-box"><strong>{preview.valid ? "Valid preview" : "Validation blocked"}</strong>{preview.changes.map((change) => <div key={change} className="diff-add">+ {change}</div>)}{preview.errors.map((error) => <div key={error} className="diff-error">! {error}</div>)}{canApply ? <button className="button button-small" onClick={() => setConfirming(true)} disabled={!preview.valid || busy} title={!preview.valid ? "Resolve validation errors before applying" : "Server authorization is still required"}>Apply approved catalog</button> : <p className="muted">Applying requires the admin role.</p>}</div>}{message && <p className="error-text" role="status">{message}</p>}<Modal open={confirming} title="Apply catalog changes?" onClose={() => setConfirming(false)}><p>This reconciles the catalog for <strong>{project}</strong> against the previewed changes above. This action is logged and cannot be undone.</p><div className="state-actions"><button className="button" onClick={() => { setConfirming(false); void apply(); }}>Confirm apply</button><button className="button button-secondary" onClick={() => setConfirming(false)}>Cancel</button></div></Modal></section>;
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient, OnboardingClientKind } from "../contracts";
import { safeApiMessage } from "../api";
import { Icon } from "../components/Icon";
import { useProjectEnvironments, useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context";
export function ClientOnboarding({ api }: { api: ManagementApiClient }) {
const canCreate = useCan("client.create");
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState("");
const [name, setName] = useState("");
const [kind, setKind] = useState<OnboardingClientKind>("stdio-bridge");
const [result, setResult] = useState<Awaited<ReturnType<ManagementApiClient["createClient"]>> | null>(null);
const [error, setError] = useState("");
const [copyStatus, setCopyStatus] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
const normalizedName = name.trim();
const ready = normalizedName.length >= 1 && project !== "" && environment !== "";
if (!canCreate) return <section className="panel workflow-panel"><p className="eyebrow">Machine access</p><h3>Onboard a project client</h3><div className="notice notice-info">Client creation requires the admin role.</div></section>;
const create = async () => {
if (!ready) return;
setBusy(true);
setError("");
setCopyStatus("");
setResult(null);
try {
setResult(await api.createClient({ name: normalizedName, project, environment, kind }));
} catch (caught) {
setError(safeApiMessage(caught));
} finally {
setBusy(false);
}
};
const copyConfig = async () => {
if (!result) return;
try {
await navigator.clipboard.writeText(JSON.stringify({
name: `itworx-${result.client.project}-${result.client.environment}`,
endpoint: result.config.endpoint,
hubClientId: result.client.id,
authentik: {
issuer: result.provisioning.issuer,
tokenEndpoint: result.provisioning.tokenEndpoint,
oauthClientId: result.provisioning.oauthClientId,
username: result.provisioning.oauthUsername,
},
installScript: "scripts/install_codex_mcp.ps1",
credential: "operator-owned user-only secret file",
}, null, 2));
setCopyStatus("Configuration copied");
} catch {
setCopyStatus("Clipboard access is unavailable. Copy the non-secret fields manually.");
}
};
return <section className="panel workflow-panel onboarding-panel">
<div className="panel-heading">
<div><p className="eyebrow">Machine access</p><h3>Onboard a project client</h3></div>
<span className="tag">Independent binding</span>
</div>
<p className="muted">Create a disabled-by-default Hub identity, then complete its explicit Authentik and Codex hand-off. Access starts only after the real external subject is bound.</p>
<ol className="onboarding-steps" aria-label="Client onboarding stages">
<li className="active"><span>1</span><div><strong>Define</strong><small>Profile and scope</small></div></li>
<li><span>2</span><div><strong>Provision</strong><small>External identity</small></div></li>
<li><span>3</span><div><strong>Verify</strong><small>Discovery and audit</small></div></li>
</ol>
<div className="form-grid onboarding-form">
<label>Name<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Codex workstation" minLength={1} maxLength={128} required aria-describedby="client-name-help" /><small id="client-name-help">A recognizable operator-owned device or workflow name.</small></label>
<label>Client profile<select value={kind} onChange={(event) => setKind(event.target.value as OnboardingClientKind)}><option value="stdio-bridge">Codex · automatic token refresh</option><option value="native-2026">Native MCP 2026 · bearer token</option><option value="compatibility-2025">Compatibility 2025</option><option value="inspector">LAN Inspector</option></select></label>
<label>Project<select value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>Environment<select value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
</div>
{!normalizedName ? <p className="form-hint">Enter a client name to continue.</p> : null}
<button className="button" onClick={() => void create()} disabled={busy || !ready}>{busy ? "Creating pending client…" : <>Create pending client <Icon name="arrow" size={15} /></>}</button>
<p className="external-provisioning-note"><span aria-hidden="true">i</span><span><strong>External identity provisioning is a separate administrator task.</strong> The Hub produces an exact plan but never creates or displays the credential in this browser.</span></p>
{error ? <p className="error-text" role="alert">{error}</p> : null}
{result ? <div className="onboarding-result" role="status">
<div className="panel-heading"><div><p className="eyebrow">Provisioning plan ready</p><h3>Client created: {result.client.name}</h3><p className="muted">Pending client created; it has no access yet.</p></div><button className="button button-small button-secondary" onClick={() => void copyConfig()}>Copy client config</button></div>
<dl><div><dt>Authentik account</dt><dd>{result.provisioning.oauthUsername}</dd></div><div><dt>Subject</dt><dd>{result.provisioning.subject ?? "Assigned during Authentik provisioning"}</dd></div><div><dt>Endpoint</dt><dd>{result.config.endpoint}</dd></div><div><dt>Scopes</dt><dd>{result.config.scopes.join(", ")}</dd></div><div><dt>Status</dt><dd>{result.provisioning.status}</dd></div></dl>
<ol>{result.provisioning.steps.map((step) => <li key={step}>{step}</li>)}</ol>
<p className="muted">No bearer credential, app password or client secret is returned to this browser.</p>
{copyStatus ? <p className="copy-status">{copyStatus}</p> : null}
</div> : null}
</section>;
}
+100
View File
@@ -0,0 +1,100 @@
import type { MouseEvent } from "react";
import type { DashboardSnapshot, Health } from "../contracts";
import { isSnapshotStale } from "../contracts";
import { formatBrusselsTime } from "../format";
import { Icon } from "../components/Icon";
import { McpTopology } from "../components/McpTopology";
import { MetricCard } from "../components/MetricCard";
import { StatusPill } from "../components/StatusPill";
import { useCan } from "../rbac-context";
function healthLabel(value: Health) {
return ({ healthy: "Healthy", degraded: "Degraded", unavailable: "Unavailable", unknown: "Unknown" } as const)[value];
}
type Exception = { title: string; detail: string; status: Health; href: string };
function exceptionsFor(snapshot: DashboardSnapshot, stale: boolean, canReadAudit: boolean): Exception[] {
const exceptions: Exception[] = [];
if (stale) exceptions.push({ title: "Operational data is stale", detail: `Last generated ${formatBrusselsTime(snapshot.generatedAt)}. Refresh before acting on health information.`, status: "degraded", href: "/operations" });
if (snapshot.health.hub !== "healthy") exceptions.push({ title: "Hub health needs investigation", detail: snapshot.health.detail, status: snapshot.health.hub, href: "/operations" });
if (snapshot.health.gateway !== "healthy") exceptions.push({ title: "Gateway health needs investigation", detail: "Trusted ContextForge route availability is not healthy.", status: snapshot.health.gateway, href: "/operations" });
for (const connector of snapshot.connectors.filter((item) => item.state !== "healthy")) {
exceptions.push({ title: `${connector.name} connector is ${healthLabel(connector.state).toLowerCase()}`, detail: `${connector.project} · ${connector.detail}`, status: connector.state, href: "/connectors" });
}
if (!snapshot.drift.known || snapshot.drift.count > 0) exceptions.push({ title: "Catalog reconciliation needs attention", detail: snapshot.drift.known ? `${snapshot.drift.count} drifted project(s): ${snapshot.drift.projects.join(", ") || "details available in Operations"}` : snapshot.drift.detail, status: snapshot.drift.known ? "degraded" : "unknown", href: "/operations" });
if (snapshot.backup.state !== "healthy") exceptions.push({ title: "Backup freshness needs attention", detail: snapshot.backup.detail, status: snapshot.backup.state, href: "/operations" });
if (snapshot.denials.count > 0) exceptions.push({ title: "Recent authorization denials", detail: `${snapshot.denials.count} in ${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}${canReadAudit ? "" : " · detailed audit access requires the auditor role"}`, status: "unknown", href: canReadAudit ? "/audit" : "/operations" });
return exceptions;
}
export function Dashboard({ snapshot, reload, navigate }: { snapshot: DashboardSnapshot; reload: () => void; navigate: (next: string) => void }) {
const stale = isSnapshotStale(snapshot);
const exceptions = exceptionsFor(snapshot, stale, useCan("audit.read"));
const healthyConnectors = snapshot.connectors.filter((connector) => connector.state === "healthy").length;
const allCurrent = !stale && exceptions.length === 0;
const go = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
event.preventDefault();
navigate(href);
};
return <div className="dashboard">
<section className="hero dashboard-hero">
<div className="hero-content">
<div className={allCurrent ? "hero-signal hero-signal-healthy" : "hero-signal hero-signal-attention"}>
<span aria-hidden="true" />{allCurrent ? "All reported signals current" : `${exceptions.length} signal${exceptions.length === 1 ? "" : "s"} need review`}
</div>
<p className="eyebrow">Operational overview</p>
<h2>Governed MCP, with the current signal first.</h2>
<p className="hero-copy">See the complete path from authenticated client to policy, gateway and scoped connector—then act on exceptions without searching across systems.</p>
<div className="hero-actions">
<a className="button" href="/operations" onClick={(event) => go(event, "/operations")}>Review operations <Icon name="arrow" size={15} /></a>
<a className="button button-secondary" href="/tools" onClick={(event) => go(event, "/tools")}>Explore catalog</a>
</div>
<div className="hero-meta"><span>Last synchronized</span><strong>{formatBrusselsTime(snapshot.generatedAt)}</strong></div>
</div>
<McpTopology
hub={stale ? "degraded" : snapshot.health.hub}
gateway={stale ? "degraded" : snapshot.health.gateway}
connectors={snapshot.connectors.map((connector) => ({ name: connector.name, state: stale ? "degraded" : connector.state }))}
/>
</section>
<section className="metric-grid primary-metrics" aria-label="Aggregate health">
<MetricCard label="Hub control plane" value={stale ? "Stale" : healthLabel(snapshot.health.hub)} detail={snapshot.health.detail} status={stale ? "degraded" : snapshot.health.hub} />
<MetricCard label="Gateway route" value={stale ? "Stale" : healthLabel(snapshot.health.gateway)} detail="Trusted ContextForge route availability" status={stale ? "degraded" : snapshot.health.gateway} />
<MetricCard label="Project environments" value={`${snapshot.projects.length}`} detail={`${new Set(snapshot.projects.map((project) => project.slug)).size} governed projects`} />
<MetricCard label="Connector routes" value={`${healthyConnectors} / ${snapshot.connectors.length}`} detail="Healthy of registered integrations" status={healthyConnectors === snapshot.connectors.length ? "healthy" : "degraded"} />
</section>
<section className="panel exception-panel" aria-labelledby="attention-heading">
<div className="panel-heading">
<div><p className="eyebrow">Attention queue</p><h3 id="attention-heading">{exceptions.length ? `${exceptions.length} actionable signal${exceptions.length === 1 ? "" : "s"}` : "No active exceptions"}</h3></div>
<button className="button button-small button-secondary refresh-button" onClick={reload}><Icon name="refresh" size={14} />Refresh</button>
</div>
{exceptions.length === 0
? <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>Everything reported is healthy.</strong><small>Hub, gateway, connectors, drift and recovery data are current.</small></span></div>
: <div className="exception-list">{exceptions.map((exception) => <a className="exception-item" href={exception.href} key={`${exception.title}-${exception.detail}`} onClick={(event) => go(event, exception.href)}><div><strong>{exception.title}</strong><small>{exception.detail}</small></div><span className="exception-action"><StatusPill value={exception.status} /><Icon name="arrow" size={15} /></span></a>)}</div>}
</section>
<section className="dashboard-columns">
<article className="panel">
<div className="panel-heading"><div><p className="eyebrow">Projects</p><h3>Project status</h3></div><a href="/projects" onClick={(event) => go(event, "/projects")}>View all <Icon name="arrow" size={14} /></a></div>
{snapshot.projects.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No projects have been registered.</span></div> : <div className="table-wrap" role="region" tabIndex={0} aria-label="Project status table"><table><caption className="sr-only">Project status</caption><thead><tr><th>Project</th><th>Environment</th><th>Health</th><th>Detail</th></tr></thead><tbody>{snapshot.projects.slice(0, 6).map((project) => <tr key={`${project.slug}-${project.environment}`}><td><a className="record-link" href={`/projects/${project.slug}`} onClick={(event) => go(event, `/projects/${project.slug}`)}><strong>{project.name}</strong><small>{project.slug}</small></a></td><td>{project.environment}</td><td><StatusPill value={stale ? "degraded" : project.health} /></td><td>{project.detail}</td></tr>)}</tbody></table></div>}
</article>
<article className="panel connector-panel">
<div className="panel-heading"><div><p className="eyebrow">Connectors</p><h3>Integration signal</h3></div><a href="/connectors" onClick={(event) => go(event, "/connectors")}>View all <Icon name="arrow" size={14} /></a></div>
<div className="connector-score"><strong>{healthyConnectors}</strong><span>healthy routes<small>of {snapshot.connectors.length} registered</small></span></div>
<div className="stack-list">{snapshot.connectors.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No connectors registered.</span></div> : snapshot.connectors.slice(0, 5).map((connector) => <div className="stack-item" key={`${connector.project}-${connector.name}`}><div><strong>{connector.name}</strong><small>{connector.project} · {connector.detail}</small></div><StatusPill value={stale ? "degraded" : connector.state} /></div>)}</div>
</article>
</section>
<section className="metric-grid secondary-grid">
<MetricCard label="Active clients" value={`${snapshot.activeClients}`} detail="Enabled management and MCP identities" />
<MetricCard label="Recent denials" value={`${snapshot.denials.count}`} detail={`${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}`} />
<MetricCard label="Reconcile drift" value={snapshot.drift.known ? `${snapshot.drift.count}` : "Unknown"} detail={snapshot.drift.known ? (snapshot.drift.projects.length ? snapshot.drift.projects.join(", ") : "No drift reported") : snapshot.drift.detail} status={!snapshot.drift.known ? "unknown" : snapshot.drift.count ? "degraded" : "healthy"} />
<MetricCard label="Backup freshness" value={snapshot.backup.lastSuccessfulAt ? formatBrusselsTime(snapshot.backup.lastSuccessfulAt) : "Not recorded"} detail={snapshot.backup.detail} status={stale ? "degraded" : snapshot.backup.state} />
<MetricCard label="Release" value={snapshot.version.application} detail={`Schema ${snapshot.version.schema}`} />
</section>
</div>;
}
+1
View File
@@ -0,0 +1 @@
export function ForbiddenPanel() { return <section className="panel state-panel" role="alert"><div className="state-icon" aria-hidden="true">!</div><h2>Management route not permitted</h2><p>This path is not part of the authorized v1 management surface.</p><a className="button" href="/">Return to overview</a></section>; }
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { createApiClient, safeApiMessage } from "../api";
import type { DashboardSnapshot, OperationsCheck, OperationsSummary } from "../contracts";
import { formatBrusselsTime } from "../format";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { Icon } from "../components/Icon";
import { MetricCard } from "../components/MetricCard";
import { StatusPill } from "../components/StatusPill";
import { useCan } from "../rbac-context";
function SummaryLoading() {
return <div className="inline-loading" role="status"><span className="spinner spinner-small" aria-hidden="true" /><span>Loading operational summary…</span></div>;
}
export function Operations({ path, navigate, snapshot }: { path: string; navigate: (next: string) => void; snapshot: DashboardSnapshot | null }) {
const [api] = useState(() => createApiClient());
const canCheck = useCan("operations.check");
const [summary, setSummary] = useState<OperationsSummary | null>(null);
const [check, setCheck] = useState<OperationsCheck | null>(null);
const [busy, setBusy] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setSummaryLoading(true);
void Promise.all([api.session(), api.operationsSummary()])
.then(([, value]) => { if (active) setSummary(value); })
.catch((caught) => { if (active) setError(safeApiMessage(caught)); })
.finally(() => { if (active) setSummaryLoading(false); });
return () => { active = false; };
}, [api]);
const runCheck = async () => {
setBusy(true);
setError("");
try {
setCheck(await api.runOperationsCheck());
setSummary(await api.operationsSummary());
} catch (caught) {
setError(safeApiMessage(caught));
} finally {
setBusy(false);
}
};
if (!snapshot) return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate} /><section className="panel resource-page"><SummaryLoading /></section></div>;
const audit = summary?.audit24h;
const totalCalls = (audit?.success ?? 0) + (audit?.denied ?? 0) + (audit?.error ?? 0) + (audit?.rate_limited ?? 0);
const maximum = Math.max(1, ...(summary?.trend.map((item) => item.success + item.denied + item.error + item.rate_limited) ?? [1]));
return <div className="resource-stack operations-page">
<Breadcrumbs path={path} navigate={navigate} />
<section className="panel resource-page operations-overview">
<div className="panel-heading"><div><p className="eyebrow">Operational health</p><h2>Operations</h2><p className="resource-description">Validate the live connector path, reconcile signal, governed call volume and recovery posture.</p></div>{canCheck ? <button className="button button-small" onClick={() => void runCheck()} disabled={busy}>{busy ? <><span className="spinner spinner-small" aria-hidden="true" />Checking live routes…</> : <><Icon name="refresh" size={14} />Run live checks</>}</button> : null}</div>
<div className="detail-grid"><MetricCard label="Reconcile drift" value={snapshot.drift.known ? `${snapshot.drift.count}` : "Unknown"} detail={snapshot.drift.known ? (snapshot.drift.projects.length ? snapshot.drift.projects.join(", ") : "No drift reported") : snapshot.drift.detail} status={!snapshot.drift.known ? "unknown" : snapshot.drift.count ? "degraded" : "healthy"} /><MetricCard label="Backup freshness" value={snapshot.backup.lastSuccessfulAt ? formatBrusselsTime(snapshot.backup.lastSuccessfulAt) : "Not recorded"} detail={`${snapshot.backup.detail}${snapshot.backup.restoreVerified ? ` · ${snapshot.backup.artifactCount ?? 0} artifacts` : ""}`} status={snapshot.backup.state} /><MetricCard label="Governed calls · 24h" value={`${totalCalls}`} detail={summaryLoading ? "Loading call totals…" : `${audit?.success ?? 0} success · ${audit?.denied ?? 0} denied · ${audit?.error ?? 0} errors · ${audit?.rate_limited ?? 0} limited`} /></div>
{error ? <div className="notice notice-danger" role="alert">{error}</div> : null}
{check ? <div className={check.healthy ? "live-check-summary live-check-healthy" : "live-check-summary live-check-attention"}><div><StatusPill value={check.healthy ? "healthy" : "degraded"} /><strong>{check.healthy ? "All checked connector paths responded" : "One or more connector paths need attention"}</strong><small>Checked {formatBrusselsTime(check.generatedAt)}</small></div><div className="connector-checks" role="status">{Object.entries(check.connectors).map(([name, item]) => <div key={name}><StatusPill value={item.reachable ? "healthy" : "unavailable"} /><strong>{name}</strong><span>{item.status} · {item.detail}</span></div>)}</div></div> : null}
</section>
<section className="operations-grid">
<article className="panel attention-panel">
<div className="panel-heading"><div><p className="eyebrow">Notifications</p><h3>Current attention queue</h3></div>{summary ? <span className="tag">{summary.alerts.length} active</span> : null}</div>
{summaryLoading ? <SummaryLoading /> : summary?.alerts.length ? <div className="alert-list">{summary.alerts.map((alert) => <div className={`notice ${alert.severity === "warning" ? "notice-danger" : "notice-info"}`} key={alert.code}><strong>{alert.code.replaceAll("_", " ")}</strong><span>{alert.message}</span></div>)}</div> : <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>No active operational alerts</strong><small>Current summaries report no condition requiring intervention.</small></span></div>}
</article>
<article className="panel trend-panel">
<div className="panel-heading"><div><p className="eyebrow">Last 24 hours</p><h3>Governed-call trend</h3></div>{summary ? <small className="generated-time">Updated {formatBrusselsTime(summary.generatedAt)}</small> : null}</div>
<div className="chart-legend" aria-label="Chart legend"><span><i className="legend-success" />Success</span><span><i className="legend-denied" />Denied</span><span><i className="legend-error" />Error</span><span><i className="legend-limited" />Rate limited</span></div>
{summaryLoading ? <SummaryLoading /> : summary?.trend.length ? <div className="trend-chart" role="img" aria-label={`Hourly governed calls. ${audit?.success ?? 0} successful, ${audit?.denied ?? 0} denied, ${audit?.error ?? 0} errors and ${audit?.rate_limited ?? 0} rate limited in the last 24 hours.`}>{summary.trend.map((point) => {
const total = point.success + point.denied + point.error + point.rate_limited;
const height = Math.max(4, (total / maximum) * 100);
return <div className="trend-column" key={point.hour} title={`${formatBrusselsTime(point.hour)} · ${total} calls`}><span className="trend-total">{total || ""}</span><span className="trend-stack" style={{ height: `${height}%` }}>{total ? <><i className="trend-success" style={{ flexGrow: point.success }} /><i className="trend-denied" style={{ flexGrow: point.denied }} /><i className="trend-error" style={{ flexGrow: point.error }} /><i className="trend-limited" style={{ flexGrow: point.rate_limited }} /></> : <i className="trend-empty" />}</span><small>{new Date(point.hour).getHours().toString().padStart(2, "0")}</small></div>;
})}</div> : <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No governed calls in this window</strong><small>The chart will populate when audited traffic is recorded.</small></span></div>}
</article>
</section>
<section className="panel approvals-panel"><div><p className="eyebrow">Controlled changes</p><h3>Approvals</h3><p className="muted">Approval workflow is not yet part of the authorized v1 management surface.</p></div><span className="boundary-badge">Intentionally unavailable</span></section>
</div>;
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient, PolicySimulation } from "../contracts";
import { safeApiMessage } from "../api";
import { StatusPill } from "../components/StatusPill";
import { useProjectEnvironments, useProjectOptions } from "../hooks";
export function PolicySimulator({ api }: { api: ManagementApiClient }) {
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState("");
const [tools, setTools] = useState<{ id: string; name: string }[]>([]);
const [tool, setTool] = useState("");
const [result, setResult] = useState<PolicySimulation | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
useEffect(() => { let active = true; void api.resource("tools", { filter: "", limit: 100 }).then((page) => { if (!active) return; const options = page.rows.map((row) => ({ id: row.canonicalName ?? row.id, name: row.name })); setTools(options); setTool((current) => options.some((item) => item.id === current) ? current : options[0]?.id ?? ""); }).catch(() => { if (active) setTools([]); }); return () => { active = false; }; }, [api]);
const ready = project !== "" && environment !== "" && tool !== "";
const run = async () => { if (!ready) return; setBusy(true); setError(""); try { setResult(await api.simulatePolicy({ project, environment, tool })); } catch (caught) { setError(safeApiMessage(caught)); } finally { setBusy(false); } };
return <section className="panel workflow-panel"><div className="panel-heading"><div><p className="eyebrow">Decision visibility</p><h3>Policy simulator</h3></div><span className="tag">Known entities only</span></div><div className="form-grid"><label>Project<select value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label><label>Environment<select value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((name) => <option key={name} value={name}>{name}</option>)}</select></label><label>Tool<select value={tool} onChange={(event) => setTool(event.target.value)} disabled={tools.length === 0}>{tools.length === 0 ? <option value="">No registered tools</option> : tools.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></div><button className="button" onClick={() => void run()} disabled={busy || !ready}>{busy ? "Evaluating…" : "Explain decision"}</button>{error && <p className="error-text" role="alert">{error}</p>}{result && <div className="decision-card" role="status"><div><StatusPill value={result.allowed ? "healthy" : "degraded"}/><strong>{result.decision}</strong></div><p>{result.reasons.join(" ")}</p><small>Matched: {result.matchedPolicies.join(", ") || "none"} · Trace {result.traceId}</small></div>}{result && result.steps.length > 0 && <div className="policy-trace"><p className="eyebrow">Decision logic trace</p><ol>{result.steps.map((step, index) => <li key={`${step.phase}-${index}`} className={step.matched ? "trace-step trace-step-matched" : "trace-step trace-step-skipped"}><div className="trace-step-head"><span className="trace-step-icon" aria-hidden="true">{step.matched ? "✓" : "–"}</span><strong>{step.phase}</strong>{step.rule && <span className="tag trace-rule-chip">{step.rule}</span>}</div><p>{step.reason}</p></li>)}</ol></div>}</section>;
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient, ProjectDetailInfo, ResourceKind, ResourceRecord } from "../contracts";
import { ApiError, safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { ErrorPanel } from "../components/ErrorPanel";
import { LoadingPanel } from "../components/LoadingPanel";
import { MetricCard } from "../components/MetricCard";
import { basePath } from "../routing";
import { useCan } from "../rbac-context";
const projectTabs = ["Summary", "Environments", "MCP Servers", "Capabilities", "Connectors", "Knowledge Sources", "Clients", "Audit"] as const;
function DetailTable({ label, headers, rows }: { label: string; headers: string[]; rows: { key: string; values: string[] }[] }) {
return <><div className="table-wrap desktop-data-table" role="region" tabIndex={0} aria-label={`${label} table`}><table><caption className="sr-only">{label}</caption><thead><tr>{headers.map((header) => <th key={header}>{header}</th>)}</tr></thead><tbody>{rows.map((row) => <tr key={row.key}>{row.values.map((value, index) => <td key={`${row.key}-${headers[index]}`}>{value}</td>)}</tr>)}</tbody></table></div><div className="mobile-record-list" aria-label={label}>{rows.map((row) => <article className="mobile-record" key={row.key}><dl>{row.values.map((value, index) => <><dt key={`${row.key}-dt-${headers[index]}`}>{headers[index]}</dt><dd key={`${row.key}-dd-${headers[index]}`}>{value}</dd></>)}</dl></article>)}</div></>;
}
export function ProjectDetail({ path, navigate, api }: { path: string; navigate: (next: string) => void; api: ManagementApiClient }) {
const slug = basePath(path).split("/").pop() ?? "project";
const requestedTab = new URLSearchParams(path.split("?")[1] ?? "").get("tab") ?? "Summary";
const canReadAudit = useCan("audit.read");
const tabs = projectTabs.filter((item) => item !== "Audit" || canReadAudit);
const tab = tabs.includes(requestedTab as typeof tabs[number]) ? requestedTab : "Summary";
const [detail, setDetail] = useState<ProjectDetailInfo | null>(null);
const [state, setState] = useState<"loading" | "error" | "notFound">("loading");
const [error, setError] = useState("");
const [scopedRows, setScopedRows] = useState<ResourceRecord[] | null>(null);
const load = async () => { setState("loading"); try { setDetail(await api.projectDetail(slug)); setState("loading"); } catch (caught) { if (caught instanceof ApiError && caught.status === 404) setState("notFound"); else { setError(safeApiMessage(caught)); setState("error"); } } };
useEffect(() => { void load(); }, [slug]);
useEffect(() => { if (tab !== "Clients" && tab !== "Audit") { setScopedRows(null); return; } const kind: ResourceKind = tab === "Clients" ? "clients" : "audit"; let active = true; void api.resource(kind, { filter: "", limit: 100 }).then((page) => { if (active) setScopedRows(page.rows.filter((row) => row.project === slug)); }).catch(() => { if (active) setScopedRows([]); }); return () => { active = false; }; }, [api, tab, slug]);
if (state === "notFound") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><div className="notice notice-danger" role="alert">Project <strong>{slug}</strong> is not registered.</div></section></div>;
if (state === "error") return <ErrorPanel message={error} retry={() => void load()}/>;
if (!detail) return <LoadingPanel />;
const scopedTable = scopedRows === null ? <LoadingPanel /> : scopedRows.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No {tab.toLowerCase()} records for this project.</span></div> : <DetailTable label={tab} headers={["Name", "Status", "Summary"]} rows={scopedRows.map((row) => ({ key: row.id, values: [row.name, row.status, row.summary] }))} />;
return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><p className="eyebrow">Project workspace</p><h2>{detail.name}</h2><p className="muted">{detail.description || "Scoped inspection for this project. Reads are bounded by project and environment context."}</p><nav className="tabs" aria-label="Project detail sections">{tabs.map((item) => <a key={item} aria-current={tab === item ? "page" : undefined} className={tab === item ? "tab active" : "tab"} href={`/projects/${slug}?tab=${encodeURIComponent(item)}`} onClick={(event) => { event.preventDefault(); navigate(`/projects/${slug}?tab=${encodeURIComponent(item)}`); }}>{item}</a>)}</nav>{tab === "Summary" && <div className="detail-grid"><MetricCard label="Environments" value={`${detail.environments.length}`} detail={detail.environments.map((item) => item.name).join(", ") || "None registered"} status={detail.environments.length ? "healthy" : "unknown"}/><MetricCard label="Clients" value={`${detail.activeClientCount} / ${detail.clientCount}`} detail="Active of total registered clients"/><MetricCard label="Virtual servers" value={`${detail.virtualServerCount}`} detail="Read-only virtual servers"/></div>}{tab === "Environments" && (detail.environments.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No environments registered for this project.</span></div> : <DetailTable label="Environments" headers={["Environment", "Classification"]} rows={detail.environments.map((item) => ({ key: item.name, values: [item.name, item.classification ?? "unclassified"] }))} />)}{tab === "Capabilities" && (detail.capabilityPacks.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No capability packs registered.</span></div> : <div className="stack-list">{detail.capabilityPacks.map((pack) => <div className="stack-item" key={pack}><strong>{pack}</strong></div>)}</div>)}{tab === "Connectors" && (detail.connectorBindings.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No connector bindings registered.</span></div> : <DetailTable label="Connector bindings" headers={["Ref", "Type", "Environments", "Verification"]} rows={detail.connectorBindings.map((item) => ({ key: item.ref, values: [item.ref, item.connectorType ?? "unknown", item.environments.join(", ") || "—", item.verification ?? "unverified"] }))} />)}{tab === "MCP Servers" && <div className="notice notice-info">Per-server inspection is not yet part of the authorized v1 read surface. {detail.virtualServerCount} virtual server(s) are provisioned for this project.</div>}{tab === "Knowledge Sources" && <div className="notice notice-info">Knowledge source listing is not yet part of the authorized v1 read surface.</div>}{(tab === "Clients" || tab === "Audit") && scopedTable}</section></div>;
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from "react";
import type { ManagementApiClient, ResourceKind, ResourcePage as ResourcePageRecord, ResourceRecord } from "../contracts";
import { routeTitle } from "../contracts";
import { safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
import type { Density } from "../components/DataTable";
import { columnsFor, DataTable } from "../components/DataTable";
import { ErrorPanel } from "../components/ErrorPanel";
import { Icon } from "../components/Icon";
import { LoadingPanel } from "../components/LoadingPanel";
import { basePath } from "../routing";
import { useCan } from "../rbac-context";
import { CatalogWorkflow } from "./CatalogWorkflow";
import { ClientOnboarding } from "./ClientOnboarding";
import { PolicySimulator } from "./PolicySimulator";
import { ToolReadinessModal } from "./ToolReadinessModal";
const descriptions: Record<ResourceKind, string> = {
projects: "Inspect health, environments and bounded capabilities across registered projects.",
servers: "Review project-scoped virtual MCP endpoints published through the trusted gateway.",
tools: "Find governed tools, verify their route and run safe readiness checks before invocation.",
connectors: "Monitor purpose-built source integrations without exposing their underlying credentials.",
clients: "Manage project-bound machine identities and generate secret-free client configuration.",
policies: "Inspect effective policy records and explain decisions against known entities.",
audit: "Trace governed requests by project, client, tool, decision and non-secret trace identifier.",
};
type Feedback = { tone: "info" | "danger"; message: string } | null;
export function ResourcePage({ path, kind, api, navigate }: { path: string; kind: ResourceKind; api: ManagementApiClient; navigate: (next: string) => void }) {
const canDisableClients = useCan("client.disable");
const canTestTools = useCan("tool.test");
const params = new URLSearchParams(path.split("?")[1] ?? "");
const urlFilter = params.get("q") ?? "";
const [filterInput, setFilterInput] = useState(urlFilter);
const [data, setData] = useState<ResourcePageRecord | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
const [feedback, setFeedback] = useState<Feedback>(null);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([undefined]);
const [density, setDensity] = useState<Density>("comfortable");
const [testingTool, setTestingTool] = useState<ResourceRecord | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const cursor = cursorStack[cursorStack.length - 1];
const hasCurrentData = data?.kind === kind;
const isRefreshing = state === "loading" && hasCurrentData;
const visibleColumns = useMemo(() => {
const hideActions = (kind === "clients" && !canDisableClients) || (kind === "tools" && !canTestTools);
return hideActions ? columnsFor(kind).filter((column) => column !== "actions") : columnsFor(kind);
}, [kind, canDisableClients, canTestTools]);
useEffect(() => { setFilterInput(urlFilter); }, [urlFilter]);
useEffect(() => {
const normalizedFilter = filterInput.trim();
if (normalizedFilter === urlFilter) return;
const timer = window.setTimeout(() => {
navigate(normalizedFilter ? `/${kind}?q=${encodeURIComponent(normalizedFilter)}` : `/${kind}`);
}, 260);
return () => window.clearTimeout(timer);
}, [filterInput, kind, navigate, urlFilter]);
useEffect(() => { setCursorStack([undefined]); }, [kind, urlFilter]);
useEffect(() => {
let active = true;
setState("loading");
setError("");
void api.resource(kind, { filter: urlFilter, cursor, limit: 25 })
.then((value) => {
if (!active) return;
setData(value);
setState("ready");
})
.catch((caught) => {
if (!active) return;
setError(safeApiMessage(caught));
setState("error");
});
return () => { active = false; };
}, [api, cursor, kind, refreshKey, urlFilter]);
const nextPage = () => {
if (data?.page.nextCursor) setCursorStack((stack) => [...stack, data.page.nextCursor]);
};
const previousPage = () => setCursorStack((stack) => (stack.length > 1 ? stack.slice(0, -1) : stack));
const retry = () => setRefreshKey((value) => value + 1);
const action = async (id: string, verb: "disable" | "revoke") => {
setFeedback({ tone: "info", message: "Submitting server-authorized client action…" });
try {
const result = await api.clientAction(id, verb);
setFeedback({ tone: "info", message: result.message });
retry();
} catch (caught) {
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
}
};
const config = async (id: string) => {
try {
const value = await api.generatedConfig(id);
setFeedback({ tone: "info", message: `Generated configuration for ${value.clientId}: ${value.endpoint} · scopes ${value.scopes.join(", ")}. Secret material is write-only and is not displayed.` });
} catch (caught) {
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
}
};
if (!hasCurrentData && state === "error") return <ErrorPanel message={error} retry={retry} />;
if (!hasCurrentData || !data) return <LoadingPanel />;
return <div className="resource-stack">
<Breadcrumbs path={path} navigate={navigate} />
<section className="panel resource-page" aria-busy={isRefreshing}>
<div className="panel-heading resource-heading">
<div><p className="eyebrow">Management workspace</p><h2>{routeTitle(basePath(path))}</h2><p className="resource-description">{descriptions[kind]}</p></div>
<span className="record-count"><strong>{data.page.total}</strong><small>total · server paginated</small></span>
</div>
<div className="resource-toolbar">
<label className="filter-field">
<span>Filter records</span>
<span className="filter-input-wrap"><Icon name="search" size={16} /><input value={filterInput} onChange={(event) => setFilterInput(event.target.value)} placeholder={kind === "audit" ? "Project, client, tool or trace" : "Name, project or capability"} />{filterInput ? <button type="button" className="filter-clear" onClick={() => setFilterInput("")} aria-label="Clear filter">×</button> : null}</span>
</label>
<div className="table-controls" role="group" aria-label="Table density">
<span>Density</span>
<button className={density === "comfortable" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "comfortable"} onClick={() => setDensity("comfortable")}>Comfortable</button>
<button className={density === "compact" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "compact"} onClick={() => setDensity("compact")}>Compact</button>
</div>
</div>
<div className={isRefreshing ? "resource-results results-refreshing" : "resource-results"}>
{isRefreshing ? <span className="refresh-indicator" role="status"><span className="spinner spinner-small" aria-hidden="true" />Updating results…</span> : null}
{state === "error" ? <div className="notice notice-danger" role="alert"><span>{error}</span><button type="button" onClick={retry}>Retry</button></div> : null}
{data.rows.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No matching records</strong><small>{urlFilter ? "Clear or broaden the current filter." : "Nothing has been registered in this workspace yet."}</small></span></div>
: <DataTable data={data} kind={kind} density={density} visibleColumns={visibleColumns} onClientAction={action} onConfig={config} onTest={setTestingTool} />}
</div>
<div className="pagination">
<span>Showing {data.rows.length} of {data.page.total}</span>
<div><button className="button button-small button-secondary" aria-label="Previous page" onClick={previousPage} disabled={cursorStack.length <= 1} title={cursorStack.length <= 1 ? "No previous page" : "Load previous page"}>Previous</button><button className="button button-small button-secondary" aria-label="Next page" onClick={nextPage} disabled={!data.page.nextCursor} title={!data.page.nextCursor ? "No next page" : "Load next page"}>Next <Icon name="arrow" size={13} /></button></div>
</div>
{feedback ? <div className={`notice notice-${feedback.tone}`} role={feedback.tone === "danger" ? "alert" : "status"}>{feedback.message}</div> : null}
</section>
{kind === "tools" ? <CatalogWorkflow api={api} /> : null}
{kind === "policies" ? <PolicySimulator api={api} /> : null}
{kind === "clients" ? <><ClientOnboarding api={api} /><section className="panel workflow-panel client-safety"><div><p className="eyebrow">Client safety</p><h3>Write-only credentials</h3><p className="muted">Generated configuration includes endpoint, identity reference and scopes only. Client secrets are never returned by the API or rendered into the DOM.</p></div><span className="boundary-badge">Secret-free browser surface</span></section></> : null}
{testingTool ? <ToolReadinessModal tool={testingTool} api={api} onClose={() => setTestingTool(null)} /> : null}
</div>;
}
+15
View File
@@ -0,0 +1,15 @@
import { useState } from "react";
import { MetricCard } from "../components/MetricCard";
import { Breadcrumbs } from "../components/Breadcrumbs";
// Unset localStorage means the CSS falls back to prefers-color-scheme, whose
// default in this design is dark -- so an absent stored value is treated as
// "dark" here too, keeping the toggle's active button in sync with what's
// actually on screen on first load.
function currentTheme(): "light" | "dark" { return localStorage.getItem("hub-theme") === "light" ? "light" : "dark"; }
export function SettingsPage({ path, navigate }: { path: string; navigate: (next: string) => void }) {
const [theme, setThemeState] = useState<"light" | "dark">(currentTheme);
const applyTheme = (next: "light" | "dark") => { document.documentElement.setAttribute("data-theme", next); localStorage.setItem("hub-theme", next); setThemeState(next); };
return <section className="panel resource-page"><Breadcrumbs path={path} navigate={navigate}/><p className="eyebrow">Control-plane preferences</p><h2>Platform</h2><p className="muted">Platform settings are server-managed and scoped by management authorization. Sensitive values are write-only.</p><div className="detail-grid"><MetricCard label="Session" value="Same-origin" detail="Authentik session checked by the BFF" status="healthy"/><MetricCard label="Audit" value="Immutable" detail="Corrections append linked events"/><MetricCard label="Credentials" value="Write-only" detail="Secrets are never read back"/></div><div className="theme-toggle"><span>Appearance</span><button className={theme === "dark" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={theme === "dark"} onClick={() => applyTheme("dark")}>Dark</button><button className={theme === "light" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={theme === "light"} onClick={() => applyTheme("light")}>Light</button></div><div className="notice notice-info" role="status">No writable platform settings are available in this browser until the control API publishes an authorized configuration schema.</div></section>;
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from "react";
import { safeApiMessage } from "../api";
import { Modal } from "../components/Modal";
import { StatusPill } from "../components/StatusPill";
import type { ManagementApiClient, ResourceRecord, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "../contracts";
import { useProjectEnvironments, useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context";
type Property = NonNullable<ToolDefinition["inputSchema"]["properties"]>[string];
function defaultArguments(definition: ToolDefinition | null): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [name, property] of Object.entries(definition?.inputSchema.properties ?? {})) {
if (property.default !== undefined) result[name] = property.default;
}
return result;
}
function fieldValue(value: unknown): string {
if (value === undefined || value === null) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function parseField(raw: string, property: Property): unknown {
const type = Array.isArray(property.type) ? property.type.find((item) => item !== "null") : property.type;
if (raw === "") return undefined;
if (type === "integer" || type === "number") return Number(raw);
if (type === "boolean") return raw === "true";
if (type === "array" || type === "object") return JSON.parse(raw);
return raw;
}
function recordInvocation(metadata: Record<string, unknown>): void {
try {
const parsed: unknown = JSON.parse(sessionStorage.getItem("itworx-tool-history-v1") ?? "[]");
const history = Array.isArray(parsed) ? parsed : [];
sessionStorage.setItem(
"itworx-tool-history-v1",
JSON.stringify([metadata, ...history].slice(0, 20)),
);
} catch {
// Invocation succeeded even when browser storage is disabled or corrupt.
}
}
export function ToolReadinessModal({ tool, api, onClose }: { tool: ResourceRecord; api: ManagementApiClient; onClose: () => void }) {
const canInvoke = useCan("tool.invoke");
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState("");
const [definition, setDefinition] = useState<ToolDefinition | null>(null);
const [argumentsValue, setArgumentsValue] = useState<Record<string, unknown>>({});
const [result, setResult] = useState<ToolReadinessResult | null>(null);
const [invocation, setInvocation] = useState<ToolInvocationResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState<"readiness" | "invoke" | "">("");
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [project, projects]);
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
useEffect(() => { const canonicalName = tool.canonicalName; if (!canonicalName) return; let active = true; void api.toolDefinition(canonicalName).then((value) => { if (active) { setDefinition(value); setArgumentsValue(defaultArguments(value)); } }).catch((caught) => { if (active) setError(safeApiMessage(caught)); }); return () => { active = false; }; }, [api, tool.canonicalName]);
const required = useMemo(() => new Set(definition?.inputSchema.required ?? []), [definition]);
const selectable = Boolean(tool.canonicalName && project && environment && definition?.readOnly);
const update = (name: string, raw: string, property: Property) => {
try {
const parsed = parseField(raw, property);
setError("");
setArgumentsValue((current) => { const next = { ...current }; if (parsed === undefined) delete next[name]; else next[name] = parsed; return next; });
} catch { setError(`${name} must contain valid JSON.`); }
};
const readiness = async () => {
if (!selectable || !tool.canonicalName) return;
setBusy("readiness"); setError(""); setResult(null); setInvocation(null);
try { setResult(await api.testTool({ project, environment, tool: tool.canonicalName, arguments: argumentsValue })); }
catch (caught) { setError(safeApiMessage(caught)); }
finally { setBusy(""); }
};
const invoke = async () => {
if (!selectable || !tool.canonicalName || !result?.ready) return;
setBusy("invoke"); setError(""); setInvocation(null);
try {
const response = await api.invokeTool({ project, environment, tool: tool.canonicalName, arguments: argumentsValue });
setInvocation(response);
recordInvocation({ tool: tool.canonicalName, project, environment, ok: response.ok, traceId: response.traceId, at: new Date().toISOString() });
} catch (caught) { setError(safeApiMessage(caught)); }
finally { setBusy(""); }
};
const copyResult = async () => { if (invocation) await navigator.clipboard.writeText(JSON.stringify(invocation.structuredContent ?? invocation.content ?? invocation.error, null, 2)); };
return <Modal open title={`Tool console · ${tool.name}`} onClose={onClose}>
<p className="muted">Run a schema-validated readiness check, then invoke this cataloged read-only tool through the audited operator path.</p>
<div className="form-grid readiness-form">
<label>Project<select value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>Environment<select value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((name) => <option key={name} value={name}>{name}</option>)}</select></label>
{Object.entries(definition?.inputSchema.properties ?? {}).map(([name, property]) => <label key={name}>{property.title ?? name}{required.has(name) ? " *" : ""}{property.enum ? <select value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)}><option value="">Select…</option>{property.enum.map((option) => <option key={String(option)} value={String(option)}>{String(option)}</option>)}</select> : property.type === "boolean" ? <select value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)}><option value="">Select…</option><option value="true">Yes</option><option value="false">No</option></select> : <input type={property.type === "integer" || property.type === "number" ? "number" : "text"} min={property.minimum} max={property.maximum} minLength={property.minLength} maxLength={property.maxLength} value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)} placeholder={property.description ?? name} />}</label>)}
</div>
<div className="tool-console-actions"><button className="button button-secondary" onClick={() => void readiness()} disabled={Boolean(busy) || !selectable} aria-busy={busy === "readiness"}>{busy === "readiness" ? "Checking…" : "Run readiness check"}</button><button className="button" onClick={() => void invoke()} disabled={Boolean(busy) || !canInvoke || !result?.ready} title={!canInvoke ? "Operator or admin role required" : !result?.ready ? "Run a successful readiness check first" : "Invoke read-only tool"}>{busy === "invoke" ? "Running…" : "Run tool"}</button></div>
{error ? <p className="error-text" role="alert">{error}</p> : null}
{result ? <div className="readiness-result" role="status"><div className="readiness-summary"><StatusPill value={result.ready ? "healthy" : "degraded"}/><strong>{result.ready ? "Ready" : "Not ready"}</strong><span>{result.project} / {result.environment}</span></div><dl className="readiness-checks"><div><dt>Input</dt><dd><StatusPill value={result.checks.input.valid ? "healthy" : "degraded"}/>{result.checks.input.valid ? "Schema valid" : result.checks.input.errors.join(" · ")}</dd></div><div><dt>Policy</dt><dd><StatusPill value={result.checks.policy.allowed ? "healthy" : "degraded"}/>{result.checks.policy.decision} · {result.checks.policy.reasonCode}</dd></div><div><dt>Connector</dt><dd><StatusPill value={result.checks.connector.reachable ? "healthy" : "unavailable"}/>{result.checks.connector.status} · {result.checks.connector.detail}</dd></div></dl><p className="muted">{result.note}</p><small>Readiness trace {result.checks.policy.traceId}</small></div> : null}
{invocation ? <div className="tool-result" role="status"><div className="panel-heading"><div><p className="eyebrow">Invocation result</p><h3>{invocation.ok ? "Completed" : "Controlled error"}</h3></div><button className="button button-small button-secondary" onClick={() => void copyResult()}>Copy result</button></div><pre>{JSON.stringify(invocation.structuredContent ?? invocation.content ?? invocation.error, null, 2)}</pre><small>Audit trace {invocation.traceId}</small></div> : null}
</Modal>;
}
+14
View File
@@ -0,0 +1,14 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ManagementAction, ManagementRole } from "./rbac";
import { can } from "./rbac";
const RolesContext = createContext<readonly ManagementRole[]>([]);
export function RbacProvider({ roles, children }: { roles: readonly ManagementRole[]; children: ReactNode }) {
return <RolesContext.Provider value={roles}>{children}</RolesContext.Provider>;
}
export function useCan(action: ManagementAction): boolean {
const roles = useContext(RolesContext);
return can(roles, action);
}
+44
View File
@@ -0,0 +1,44 @@
import type { ManagementRole } from "./contracts";
export type { ManagementRole };
export type ManagementAction =
| "dashboard.read"
| "resource.read"
| "audit.read"
| "policy.simulate"
| "catalog.preview"
| "tool.test"
| "tool.invoke"
| "operations.check"
| "client.read"
| "client.bootstrap"
| "client.disable"
| "client.revoke"
| "client.create"
| "catalog.apply";
/** Mirrors services/control_api/authz.py's ENDPOINT_PERMISSIONS exactly.
* This is a client-side hide-only affordance, never an authorization
* boundary -- services/control_api/authz.py's check_role() is what
* actually enforces every one of these actions server-side. */
const PERMISSIONS: Record<ManagementAction, readonly ManagementRole[]> = {
"dashboard.read": ["viewer", "auditor", "operator", "admin"],
"resource.read": ["viewer", "auditor", "operator", "admin"],
"audit.read": ["auditor", "operator", "admin"],
"policy.simulate": ["viewer", "auditor", "operator", "admin"],
"catalog.preview": ["viewer", "auditor", "operator", "admin"],
"tool.test": ["operator", "admin"],
"tool.invoke": ["operator", "admin"],
"operations.check": ["operator", "admin"],
"client.read": ["viewer", "auditor", "operator", "admin"],
"client.bootstrap": ["operator", "admin"],
"client.disable": ["operator", "admin"],
"client.revoke": ["operator", "admin"],
"client.create": ["admin"],
"catalog.apply": ["admin"],
};
export function can(roles: readonly ManagementRole[], action: ManagementAction): boolean {
const allowed = PERMISSIONS[action];
return roles.some((role) => allowed.includes(role));
}
+16
View File
@@ -0,0 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import type { ResourceKind } from "./contracts";
const resourceRoutes: Record<string, ResourceKind> = { "/projects": "projects", "/servers": "servers", "/tools": "tools", "/connectors": "connectors", "/clients": "clients", "/policies": "policies", "/audit": "audit" };
export function usePath() {
const [path, setPath] = useState(window.location.pathname + window.location.search);
useEffect(() => { const onPopState = () => setPath(window.location.pathname + window.location.search); window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); }, []);
const navigate = useCallback((next: string) => {
window.history.pushState({}, "", next);
setPath(next);
}, []);
return [path, navigate] as const;
}
export function basePath(path: string) { return (path.split("?")[0] ?? "").replace(/\/$/, "") || "/"; }
export function resourceKind(path: string): ResourceKind | null { const base = basePath(path); if (base.startsWith("/projects/") && base !== "/projects") return "projects"; return resourceRoutes[base] ?? null; }
+862
View File
@@ -0,0 +1,862 @@
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "@fontsource/inter/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
/* Tokens -- derived from stitch_itworx_mcp_control_plane's
itworx_mcp_hub_design_system/DESIGN.md ("Cyber-Neutral" dark-first
observability palette). Token *names* are unchanged from the prior
iteration so no component markup needed to change, only values. */
:root {
--surface-0: #0f131b;
--surface-1: #181c24;
--surface-2: #1c2028;
--surface-2-hover: #262a32;
--line: #3b494c;
--line-soft: #262a32;
--paper: #dfe2ee;
--paper-bright: #f3f5fa;
--ink-dim: #bac9cc;
--ink-faint: #849396;
--accent: #00b0fe;
--accent-bright: #00e5ff;
--accent-dim: #00404a;
--status-success: #34d399;
--status-success-dim: #113328;
--status-danger: #ffb4ab;
--status-danger-dim: #4a1410;
--status-info: #8dcdff;
--status-info-dim: #1a3245;
--status-unknown: #849396;
--status-unknown-dim: #262a32;
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--space-5: 20px; --space-6: 28px; --space-7: 36px; --space-8: 48px;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-pill: 9999px;
--shadow-1: 0 1px 3px -1px #0008;
--shadow-2: 0 4px 16px -6px #0009;
--motion-fast: 120ms; --motion-base: 200ms;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace;
color: var(--paper);
background: var(--surface-0);
font-family: var(--font-sans);
font-synthesis: none;
-webkit-font-smoothing: antialiased;
}
:root[data-theme="light"] {
--surface-0: #f8fafc; --surface-1: #ffffff; --surface-2: #f1f5f9;
--surface-2-hover: #e2e8f0; --line: #dbe3e8; --line-soft: #e8eef1;
--paper: #0f172a; --paper-bright: #020617;
/* #5b6b80, not the Tailwind slate-400 #94a3b8 this token used at first:
slate-400 on this surface measures ~2.45:1, well under WCAG AA's 4.5:1
for normal text -- and --ink-faint is real text (table headers,
breadcrumbs, filter hints), not decoration. #5b6b80 measures ~5.2:1. */
--ink-dim: #475569; --ink-faint: #5b6b80;
--accent: #006875; --accent-bright: #00838f; --accent-dim: #d6f1f5;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--surface-0: #f8fafc; --surface-1: #ffffff; --surface-2: #f1f5f9;
--surface-2-hover: #e2e8f0; --line: #dbe3e8; --line-soft: #e8eef1;
--paper: #0f172a; --paper-bright: #020617;
--ink-dim: #475569; --ink-faint: #5b6b80;
--accent: #006875; --accent-bright: #00838f; --accent-dim: #d6f1f5;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--surface-0); background-image: radial-gradient(1200px 640px at 14% -8%, var(--surface-2) 0%, transparent 55%); background-attachment: fixed; }
.skip-link { position: fixed; z-index: 1000; top: 10px; left: 10px; transform: translateY(-180%); padding: 9px 12px; border-radius: 3px; background: var(--paper-bright); color: var(--surface-0); font-weight: 700; text-decoration: none; box-shadow: var(--shadow-2); }
.skip-link:focus { transform: translateY(0); }
button, a, input, select { font: inherit; }
a { color: inherit; }
button { font-family: var(--font-sans); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Shell */
.app-shell { display: flex; min-height: 100vh; }
.preauth-shell { min-height: 100vh; display: grid; place-items: start center; }
.sidebar { width: 240px; background: var(--surface-1); border-right: 1px solid var(--line); padding: 28px 16px; display: flex; flex-direction: column; flex-shrink: 0; position: relative; }
.sidebar::after { content: ""; position: absolute; top: 0; right: -1px; bottom: 0; width: 1px; background: linear-gradient(180deg, transparent, var(--accent-dim) 18%, var(--accent-dim) 82%, transparent); opacity: 0.55; }
.brand { display: flex; gap: 12px; align-items: center; text-decoration: none; padding: 0 10px 30px; }
.brand strong, .brand small { display: block; }
.brand strong { font-family: var(--font-sans); font-weight: 700; letter-spacing: 0.01em; color: var(--paper-bright); }
.brand small { color: var(--ink-dim); margin-top: 3px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }
.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; color: var(--surface-0); background: linear-gradient(160deg, var(--accent-bright), var(--accent)); box-shadow: 0 0 0 1px var(--accent-dim), 0 2px 10px -2px #000a; flex-shrink: 0; }
.brand-mark svg { display: block; }
nav[aria-label="Primary navigation"] { display: grid; gap: 3px; }
.nav-group { display: grid; gap: 3px; margin: 0 0 14px; }
.nav-group > p { margin: 8px 12px 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.nav-link { display: flex; align-items: center; gap: 10px; color: var(--ink-dim); padding: 10px 12px; border-radius: 7px; text-decoration: none; font-size: 13.5px; font-weight: 500; position: relative; transition: color 120ms ease, background-color 120ms ease; }
.nav-link::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--line); flex-shrink: 0; transition: background-color 120ms ease, box-shadow 120ms ease; }
.nav-link:hover, .nav-link:focus-visible { color: var(--paper-bright); background: var(--surface-2-hover); }
.nav-link.active { color: var(--paper-bright); background: var(--surface-2); font-weight: 600; }
.nav-link.active::before { background: var(--accent); box-shadow: 0 0 6px 1px #00b0fe99; }
.sidebar-foot { margin-top: auto; padding: 16px 12px 0; border-top: 1px solid var(--line); color: var(--ink-dim); font-size: 12px; line-height: 1.6; }
.sidebar-foot small { color: var(--ink-faint); }
.secure-dot { display: inline-block; width: 7px; height: 7px; background: var(--status-success); border-radius: 50%; margin-right: 7px; box-shadow: 0 0 5px 0 #34d39999; }
/* Main */
.main-content { flex: 1; min-width: 0; padding: 32px clamp(20px, 4vw, 60px) 56px; }
.topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 34px; }
.topbar h1, .hero h2, .panel h2, .panel h3 { margin: 0; color: var(--paper-bright); letter-spacing: -0.01em; font-family: var(--font-sans); font-weight: 700; }
.topbar h1 { font-size: 27px; }
.eyebrow { text-transform: uppercase; letter-spacing: 0.11em; font-size: 12px; font-weight: 600; color: var(--accent); margin: 0 0 9px; font-family: var(--font-mono); }
.topbar-actions { display: flex; align-items: center; gap: 16px; }
.session-label { color: var(--ink-dim); font-size: 12px; font-family: var(--font-mono); }
.avatar { display: grid; place-items: center; width: 34px; height: 34px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 50%; color: var(--paper); text-decoration: none; font-weight: 700; font-family: var(--font-sans); }
.avatar:hover, .avatar:focus-visible { border-color: var(--accent-dim); }
/* Hero */
.hero { display: flex; justify-content: space-between; gap: 24px; padding: 28px 30px; border: 1px solid var(--line); border-radius: 4px; background: linear-gradient(150deg, var(--surface-2), var(--surface-1)); margin-bottom: 20px; position: relative; overflow: hidden; }
.hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 2px; background: linear-gradient(90deg, var(--accent-dim), var(--accent) 30%, var(--accent-dim) 60%, transparent); opacity: 0.7; }
.hero h2 { font-size: clamp(22px, 3vw, 31px); }
.hero-copy { color: var(--ink-dim); margin: 10px 0 0; max-width: 46ch; }
.hero-meta { min-width: 190px; color: var(--ink-faint); font-size: 11px; text-align: right; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; }
.hero-meta strong { display: block; color: var(--paper); margin-top: 8px; font-size: 15px; letter-spacing: 0.02em; font-variant-numeric: tabular-nums; text-transform: none; }
/* Cards + panels */
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
.metric-card, .panel { border: 1px solid var(--line); background: var(--surface-1); border-radius: 4px; }
.metric-card { min-height: 134px; padding: 19px; position: relative; }
.metric-card::before { content: ""; position: absolute; top: 0; left: 16px; right: 16px; height: 2px; background: var(--line-soft); }
.metric-heading, .panel-heading { display: flex; justify-content: space-between; gap: 10px; align-items: center; }
.metric-heading span:first-child { color: var(--ink-dim); font-size: 12px; font-weight: 500; letter-spacing: 0.02em; }
.metric-card strong { display: block; font-size: 26px; color: var(--paper-bright); margin: 18px 0 8px; font-weight: 700; font-variant-numeric: tabular-nums; }
.metric-card p, .panel p { color: var(--ink-dim); font-size: 12px; margin: 0; line-height: 1.55; }
.dashboard-columns { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 18px; margin-bottom: 20px; }
.panel { padding: 23px; }
.panel-heading { align-items: flex-start; margin-bottom: 19px; }
.panel-heading h3 { font-size: 16.5px; }
.panel-heading a { color: var(--accent); font-size: 12px; text-decoration: none; font-weight: 600; }
.panel-heading a:hover { text-decoration: underline; }
.exception-panel { margin-bottom: 20px; }
.exception-list { display: grid; gap: 4px; }
.exception-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line-soft); text-decoration: none; }
.exception-item:last-child { border-bottom: 0; }
.exception-item:hover strong, .exception-item:focus-visible strong { color: var(--accent-bright); }
.exception-item strong, .exception-item small { display: block; }
.exception-item strong { color: var(--paper); font-size: 12.5px; }
.exception-item small { max-width: 78ch; margin-top: 3px; color: var(--ink-faint); font-size: 11px; line-height: 1.45; }
.table-wrap { overflow-x: auto; }
.mobile-record-list { display: none; }
table { border-collapse: collapse; width: 100%; font-size: 12.5px; text-align: left; }
th { color: var(--ink-faint); font-weight: 600; padding: 9px 11px; border-bottom: 1px solid var(--line); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; font-family: var(--font-mono); }
td { color: var(--ink-dim); padding: 13px 11px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
td strong, td small, .stack-item strong, .stack-item small { display: block; }
td strong, .stack-item strong { color: var(--paper); font-family: var(--font-sans); font-weight: 600; letter-spacing: 0.01em; }
td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-family: var(--font-mono); font-size: 11px; }
.stack-list { display: grid; gap: 4px; }
.stack-item { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 13px 0; border-bottom: 1px solid var(--line-soft); }
.stack-item strong, .stack-item small { font-size: 12.5px; }
/* Signal lamps */
.status { display: inline-flex; align-items: center; gap: 7px; font-size: 11px; color: var(--ink-dim); white-space: nowrap; font-weight: 600; background: var(--surface-2); border: 1px solid var(--line-soft); border-radius: var(--radius-pill); padding: 4px 10px 4px 8px; }
.status > span { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-faint); box-shadow: inset 0 1px 1px #0006; position: relative; }
.status > span::after { content: ""; position: absolute; top: 1px; left: 1px; width: 2.5px; height: 2.5px; border-radius: 50%; background: #ffffff55; }
.status-healthy > span { background: var(--status-success); box-shadow: 0 0 6px 1px #34d39980, inset 0 1px 1px #ffffff40; }
.status-degraded > span { background: var(--accent); box-shadow: 0 0 6px 1px #00b0fe80, inset 0 1px 1px #ffffff40; }
.status-unavailable > span { background: var(--status-danger); box-shadow: 0 0 6px 1px #ffb4ab80, inset 0 1px 1px #ffffff40; }
.status-unknown > span { background: var(--status-unknown); box-shadow: inset 0 1px 1px #ffffff20; }
.notice { border-radius: 3px; padding: 12px 15px; color: var(--paper); font-size: 12.5px; margin-bottom: 16px; border-left: 3px solid; background: var(--surface-2); }
.notice-warning { border-left-color: var(--accent); }
.notice-danger { border-left-color: var(--status-danger); margin-top: 16px; }
.notice-info { border-left-color: var(--status-info); }
.notice button { color: var(--accent); background: transparent; border: 0; cursor: pointer; padding: 0 0 0 8px; text-decoration: underline; font-weight: 600; }
.secondary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
/* State panels */
.state-panel, .auth-panel, .resource-page { max-width: 720px; margin: 10vh auto 0; text-align: center; padding: 50px 32px; }
.state-panel h2, .auth-panel h2, .auth-panel h1, .resource-page h2 { margin-bottom: 12px; font-size: 22px; }
.state-panel p, .auth-panel p, .resource-page p { max-width: 500px; margin: 0 auto 22px; color: var(--ink-dim); }
.preauth-brand { display: inline-flex; margin-bottom: 30px; padding-bottom: 0; text-align: left; }
.auth-help { display: block; max-width: 390px; margin: 21px auto 0; color: var(--ink-faint); font-size: 11px; line-height: 1.5; }
.state-icon { display: grid; place-items: center; width: 44px; height: 44px; margin: 0 auto 18px; border-radius: 50%; background: var(--surface-2); border: 1px solid var(--accent-dim); color: var(--accent); font-size: 21px; font-family: var(--font-sans); }
.spinner { width: 26px; height: 26px; margin: 0 auto 20px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.state-actions { display: flex; justify-content: center; gap: 10px; }
.button { display: inline-block; border: 1px solid transparent; border-radius: 3px; padding: 10px 16px; color: var(--surface-0); background: linear-gradient(160deg, var(--accent-bright), var(--accent)); text-decoration: none; font-size: 12px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; cursor: pointer; transition: filter 120ms ease, transform 120ms ease; }
.button:hover, .button:focus-visible { filter: brightness(1.08); }
.button:active { transform: translateY(1px); }
.button-secondary { color: var(--paper); background: var(--surface-2); border-color: var(--line); text-transform: none; letter-spacing: 0; font-weight: 600; }
.button-secondary:hover, .button-secondary:focus-visible { filter: none; border-color: var(--ink-faint); }
.empty-line { display: flex; align-items: center; justify-content: center; gap: 9px; padding: 19px; border: 1px dashed var(--line); border-radius: 3px; color: var(--ink-faint); font-size: 12px; }
.muted { color: var(--ink-dim); }
@media (max-width: 940px) {
.sidebar { width: 212px; }
.metric-grid, .secondary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-columns { grid-template-columns: 1fr; }
}
/* Compact navigation drawer + trigger -- hidden entirely above 840px, where
the persistent sidebar (rendered unconditionally in app.tsx) is used
instead. Kept as its own rule set, not nested inside the 840px media
query below, so .nav-trigger/.nav-close/.nav-backdrop rules exist even
before the query narrows other layout rules. */
.nav-trigger { display: none; flex-direction: column; justify-content: center; gap: 4px; width: 36px; height: 36px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-1); cursor: pointer; flex-shrink: 0; }
.nav-trigger span { display: block; width: 16px; height: 2px; margin: 0 auto; background: var(--paper); border-radius: 1px; }
.nav-close { display: none; border: 0; background: transparent; color: var(--ink-dim); font-size: 22px; line-height: 1; cursor: pointer; padding: var(--space-1); }
.nav-close:hover, .nav-close:focus-visible { color: var(--paper-bright); }
.nav-backdrop { display: none; }
.sidebar-head { display: contents; }
@media (max-width: 840px) {
.app-shell { display: block; }
.nav-trigger { display: flex; }
.topbar { align-items: center; }
/* Real slide-in drawer, not a squeezed copy of the desktop sidebar:
fixed off-canvas panel, translated into view only while open, above a
dedicated backdrop that closes it on click (Escape closes it too, via
app.tsx). */
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 210; width: min(84vw, 300px); overflow-y: auto; transform: translateX(-100%); transition: transform var(--motion-base) ease; box-shadow: var(--shadow-2); padding: 20px 16px; }
.sidebar-open { transform: translateX(0); }
.sidebar-head { display: flex; align-items: center; justify-content: space-between; }
.brand { padding: 0 4px 14px; }
.nav-close { display: block; }
nav[aria-label="Primary navigation"] { display: grid; }
.nav-link { font-size: 13.5px; padding: 10px 12px; }
.sidebar-foot { display: block; }
.nav-backdrop { display: block; position: fixed; inset: 0; background: #00000099; z-index: 200; }
.main-content { padding: 24px 14px 42px; }
.preauth-shell .main-content { padding-top: 10px; }
.topbar { margin-bottom: 24px; gap: 12px; }
.topbar h1 { font-size: 22px; }
.session-label { display: none; }
.hero { display: block; padding: 21px; }
.hero-meta { text-align: left; margin-top: 19px; }
.metric-card { min-height: 120px; padding: 15px; }
.metric-card strong { font-size: 21px; margin-top: 15px; }
.panel { min-width: 0; padding: 18px; }
.dashboard-columns > * { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sidebar { transition: none; }
}
/* Resource / detail views */
.breadcrumbs { display: flex; gap: 9px; align-items: center; color: var(--ink-faint); font-size: 12px; margin-bottom: 17px; font-family: var(--font-mono); }
.session-menu { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.session-identity { display: flex; align-items: center; gap: 8px; text-align: right; line-height: 1.25; }
.session-identity > span:last-child { display: grid; }
.session-identity strong { color: var(--paper-bright); font-size: 12px; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
.session-identity small { color: var(--ink-faint); font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; }
.session-error { color: var(--status-danger); font-size: 11px; }
.breadcrumbs a { color: var(--accent); text-decoration: none; }
.breadcrumbs a:hover { text-decoration: underline; }
.resource-stack { display: grid; gap: 18px; }
.resource-stack .resource-page { max-width: none; margin: 0; text-align: left; padding: 23px; }
.resource-stack .resource-page h2 { font-size: 22px; }
.resource-stack .resource-page p { max-width: none; margin-left: 0; }
.tag { border: 1px solid var(--line); background: var(--surface-2); border-radius: 999px; color: var(--ink-dim); padding: 5px 10px; font-size: 10.5px; white-space: nowrap; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; }
.filter-row { display: flex; align-items: end; gap: 14px; margin: 19px 0; }
.filter-field, .form-grid label { display: grid; gap: 6px; color: var(--ink-dim); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }
.filter-field { flex: 1; max-width: 440px; }
.filter-help { color: var(--ink-faint); font-size: 12px; padding-bottom: 11px; font-family: var(--font-mono); text-transform: none; letter-spacing: 0; }
.filter-field input, .form-grid input, .form-grid select, .form-grid textarea { width: 100%; border: 1px solid var(--line); border-radius: 3px; background: var(--surface-0); color: var(--paper); padding: 10px 11px; outline: none; font-size: 13px; text-transform: none; letter-spacing: 0; font-weight: 400; transition: border-color 120ms ease, box-shadow 120ms ease; }
.filter-field input:focus, .form-grid input:focus, .form-grid select:focus, .form-grid textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px #00b0fe33; }
.long-name { max-width: 250px; white-space: normal; overflow-wrap: anywhere; }
.risk-label { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border: 1px solid var(--line); border-radius: var(--radius-pill); color: var(--paper); background: var(--surface-2); font-family: var(--font-mono); font-size: 11px; }
.inline-actions { display: flex; gap: 5px; }
.button-small { padding: 7px 10px; font-size: 10.5px; }
.table-controls { display: flex; gap: 10px; }
.table-controls .active { border-color: var(--accent); }
.button-danger { color: #f6dcd7; background: var(--status-danger-dim); border-color: #7a3b30; }
.button:disabled { cursor: not-allowed; opacity: 0.4; filter: none; }
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--ink-faint); font-size: 11px; margin-top: 16px; font-family: var(--font-mono); }
.workflow-panel { max-width: none; }
.workflow-panel h3 { margin-bottom: 9px; }
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 19px 0; }
.readiness-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.readiness-arguments { grid-column: 1 / -1; }
.readiness-arguments textarea { min-height: 110px; resize: vertical; font-family: var(--font-mono); line-height: 1.5; }
.readiness-result { margin-top: 17px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent-dim); border-radius: 3px; background: var(--surface-2); }
.readiness-summary { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.readiness-summary span { color: var(--ink-faint); font-family: var(--font-mono); font-size: 11px; }
.readiness-checks { display: grid; gap: 8px; margin: 16px 0; }
.readiness-checks > div { display: grid; grid-template-columns: 90px 1fr; gap: 12px; align-items: start; padding-top: 8px; border-top: 1px solid var(--line); }
.readiness-checks dt { color: var(--ink-faint); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
.readiness-checks dd { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--ink-dim); font-size: 12px; }
.readiness-result small { color: var(--ink-faint); font-family: var(--font-mono); }
.tool-console-actions { display: flex; gap: 10px; margin-top: 16px; }
.tool-result { margin-top: 17px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--status-success); border-radius: 3px; background: var(--surface-2); }
.tool-result pre { max-height: 320px; overflow: auto; padding: 12px; border: 1px solid var(--line); background: var(--surface-0); color: var(--ink-dim); font: 11px/1.5 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.tool-result small { color: var(--ink-faint); font-family: var(--font-mono); }
.connector-checks { display: grid; gap: 8px; margin-top: 18px; }
.connector-checks > div { display: grid; grid-template-columns: auto minmax(110px, .35fr) 1fr; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); }
.connector-checks span { color: var(--ink-dim); font-size: 12px; overflow-wrap: anywhere; }
.alert-list { display: grid; gap: 10px; margin-top: 16px; }
.alert-list .notice { display: grid; grid-template-columns: minmax(130px, .3fr) 1fr; gap: 14px; }
.alert-list .notice strong { text-transform: uppercase; font: 11px/1.4 var(--font-mono); }
.trend-chart { display: flex; align-items: end; gap: 5px; height: 180px; margin-top: 18px; padding: 12px 8px 0; border: 1px solid var(--line); background: var(--surface-0); overflow-x: auto; }
.trend-column { display: grid; grid-template-rows: 1fr 18px; align-items: end; justify-items: center; min-width: 20px; height: 100%; flex: 1; }
.trend-bar { display: block; width: min(12px, 70%); min-height: 4px; border-radius: 2px 2px 0 0; background: var(--accent); }
.trend-column small { color: var(--ink-faint); font: 9px/1 var(--font-mono); }
.onboarding-result { margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface-2); }
.onboarding-result dl { display: grid; gap: 8px; }
.onboarding-result dl > div { display: grid; grid-template-columns: 100px 1fr; gap: 12px; }
.onboarding-result dt { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); text-transform: uppercase; }
.onboarding-result dd { margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
.onboarding-result li { margin: 7px 0; color: var(--ink-dim); }
.decision-card, .diff-box { margin-top: 17px; padding: 16px; border: 1px solid var(--line); border-radius: 3px; background: var(--surface-2); border-left: 3px solid var(--accent-dim); }
.decision-card > div { display: flex; gap: 9px; align-items: center; }
.decision-card strong { color: var(--paper-bright); text-transform: uppercase; font-size: 12.5px; letter-spacing: 0.03em; font-family: var(--font-mono); }
.decision-card p { margin: 11px 0; color: var(--ink-dim); }
.decision-card small { color: var(--ink-faint); font-family: var(--font-mono); }
.diff-box { display: grid; gap: 8px; }
.policy-trace { margin-top: 17px; }
.policy-trace ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.trace-step { position: relative; padding: 12px 14px 12px 42px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-1); }
.trace-step-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.trace-step-head strong { color: var(--paper-bright); font-size: 13px; font-weight: 600; }
.trace-step-icon { position: absolute; left: 12px; top: 12px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; font-size: 12px; font-weight: 700; line-height: 1; }
.trace-step-matched { border-left: 2px solid var(--status-success); }
.trace-step-matched .trace-step-icon { background: var(--status-success-dim); color: var(--status-success); }
.trace-step-skipped { border-left: 2px solid var(--line); }
.trace-step-skipped .trace-step-icon { background: var(--surface-2); color: var(--ink-faint); }
.trace-rule-chip { margin-left: auto; }
.trace-step p { margin: 6px 0 0; color: var(--ink-dim); font-size: 12px; font-family: var(--font-mono); }
.diff-add { color: var(--status-success); font-family: var(--font-mono); font-size: 12px; }
.diff-error, .error-text { color: var(--status-danger); font-size: 12px; font-family: var(--font-mono); }
.tabs { display: flex; gap: 3px; overflow-x: auto; border-bottom: 1px solid var(--line); margin: 23px 0; padding-bottom: 0; }
.tab { color: var(--ink-dim); text-decoration: none; padding: 10px 12px; white-space: nowrap; font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 2px solid transparent; transition: color 120ms ease, border-color 120ms ease; }
.tab:hover, .tab:focus-visible { color: var(--paper); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; text-align: left; margin-bottom: 19px; }
.detail-grid .metric-card { min-height: 114px; }
.detail-grid .metric-card strong { font-size: 19px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.theme-toggle { display: flex; align-items: center; gap: 10px; margin-bottom: 19px; }
.theme-toggle span { color: var(--ink-dim); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
@media (max-width: 640px) {
.filter-row, .form-grid { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; }
.filter-help { padding: 0; }
.inline-actions { flex-wrap: wrap; }
.detail-grid { grid-template-columns: 1fr; }
.resource-stack .resource-page { padding: 18px; }
.resource-stack .panel-heading { flex-wrap: wrap; }
.session-identity { display: none; }
.connector-checks > div, .alert-list .notice { grid-template-columns: auto 1fr; }
.connector-checks span { grid-column: 1 / -1; }
.desktop-data-table { display: none; }
.mobile-record-list { display: grid; gap: 10px; }
.mobile-record { padding: 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
.mobile-record-heading { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.mobile-record strong, .mobile-record small { display: block; }
.mobile-record strong { color: var(--paper-bright); overflow-wrap: anywhere; }
.mobile-record small { margin-top: 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 10.5px; overflow-wrap: anywhere; }
.mobile-record dl { display: grid; grid-template-columns: minmax(88px, 0.42fr) 1fr; gap: 8px 12px; margin: 14px 0; font-size: 12px; }
.mobile-record dt { color: var(--ink-faint); font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.mobile-record dd { min-width: 0; margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
}
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: #000a; display: grid; place-items: center; z-index: 100; padding: var(--space-4); }
.modal { background: var(--surface-1); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-2); max-width: 760px; width: 100%; max-height: calc(100vh - 32px); overflow-y: auto; padding: var(--space-6); }
.modal-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-4); }
.modal-heading h2 { margin: 0; font-size: 18px; color: var(--paper-bright); }
.modal-close { background: transparent; border: 0; color: var(--ink-dim); font-size: 20px; cursor: pointer; line-height: 1; padding: var(--space-1); }
.modal-close:hover, .modal-close:focus-visible { color: var(--paper-bright); }
@media (prefers-reduced-motion: no-preference) { .modal-overlay { animation: modal-fade var(--motion-fast) ease; } .modal { animation: modal-in var(--motion-base) ease; } }
@keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
/* Table density */
.density-compact td, .density-compact th { padding: 6px 9px; }
.density-comfortable td, .density-comfortable th { padding: 13px 11px; }
/* Sol Ultra product-quality layer
--------------------------------
These rules build on the accepted Cyber-Neutral token set above. They
sharpen hierarchy and interaction without changing the established
security-oriented dark control-plane identity. */
body {
min-height: 100vh;
line-height: 1.5;
background-image:
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px),
radial-gradient(900px 540px at 8% -10%, color-mix(in srgb, var(--accent-dim) 34%, transparent), transparent 62%);
background-size: 64px 64px, 64px 64px, auto;
background-attachment: fixed;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: linear-gradient(90deg, var(--surface-0) 0%, transparent 16%, transparent 84%, var(--surface-0) 100%);
opacity: .48;
}
#root { position: relative; min-height: 100vh; }
.icon { display: block; flex: 0 0 auto; }
/* Authenticated shell */
.sidebar {
position: sticky;
top: 0;
z-index: 20;
width: 252px;
height: 100vh;
padding: 25px 17px 20px;
background: color-mix(in srgb, var(--surface-1) 96%, transparent);
backdrop-filter: blur(18px);
}
.sidebar .brand { padding: 0 9px 25px; }
.brand-mark {
width: 38px;
height: 38px;
border-radius: 9px;
box-shadow: 0 0 0 1px var(--accent-dim), 0 8px 22px -10px color-mix(in srgb, var(--accent) 60%, transparent);
}
.nav-group { margin-bottom: 11px; }
.nav-link { gap: 11px; min-height: 40px; padding: 9px 11px; border: 1px solid transparent; }
.nav-link::before { display: none; }
.nav-link .icon { color: var(--ink-faint); transition: color var(--motion-fast) ease, transform var(--motion-fast) ease; }
.nav-link:hover .icon, .nav-link:focus-visible .icon { color: var(--paper); transform: translateX(1px); }
.nav-link.active { border-color: color-mix(in srgb, var(--accent-dim) 72%, var(--line)); background: color-mix(in srgb, var(--surface-2) 90%, var(--accent-dim)); }
.nav-link.active .icon { color: var(--accent-bright); }
.sidebar-foot { display: grid; gap: 3px; }
.sidebar-foot > span { color: var(--paper); font-size: 11.5px; }
.sidebar-foot small { font-family: var(--font-mono); font-size: 9.5px; }
.main-content { padding: 28px clamp(22px, 3.6vw, 58px) 64px; }
.topbar, .dashboard, .resource-stack { width: min(100%, 1480px); margin-inline: auto; }
.topbar { align-items: center; min-height: 52px; margin-bottom: 29px; }
.topbar-title { min-width: 0; }
.topbar-title .eyebrow { margin-bottom: 4px; font-size: 9.5px; }
.topbar h1 { font-size: clamp(23px, 2.2vw, 29px); }
.topbar-actions { min-width: 0; }
.command-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 7px 8px 7px 11px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
color: var(--ink-dim);
background: var(--surface-1);
cursor: pointer;
font-size: 11.5px;
transition: border-color var(--motion-fast) ease, color var(--motion-fast) ease, background-color var(--motion-fast) ease;
}
.command-trigger:hover, .command-trigger:focus-visible { color: var(--paper-bright); border-color: var(--ink-faint); background: var(--surface-2); }
kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); border-radius: 4px; padding: 2px 6px; color: var(--ink-faint); background: var(--surface-0); font: 9px/1.5 var(--font-mono); }
.session-menu { gap: 9px; }
.session-menu > .tag { padding-inline: 8px; }
/* Public gateway */
.preauth-shell { display: block; min-height: 100vh; }
.preauth-shell .main-content { width: 100%; min-height: 100vh; padding: clamp(18px, 3vw, 42px); }
.auth-layout {
display: grid;
grid-template-columns: minmax(340px, .82fr) minmax(520px, 1.18fr);
width: min(100%, 1320px);
min-height: calc(100vh - clamp(36px, 6vw, 84px));
margin: 0 auto;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, var(--surface-1) 96%, transparent);
box-shadow: 0 28px 80px -48px #000;
}
.auth-copy, .auth-visual { min-width: 0; padding: clamp(34px, 5vw, 74px); }
.auth-copy { display: flex; flex-direction: column; justify-content: center; }
.auth-copy .preauth-brand { margin: 0 0 clamp(50px, 8vh, 88px); }
.auth-copy h1 { max-width: 13ch; margin: 0; color: var(--paper-bright); font-size: clamp(38px, 5vw, 66px); line-height: .99; letter-spacing: -.05em; }
.auth-lead { max-width: 53ch; margin: 23px 0 0; color: var(--ink-dim); font-size: 15px; line-height: 1.7; }
.auth-actions { display: flex; align-items: center; gap: 18px; margin-top: 31px; }
.auth-actions > span { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); }
.auth-button { gap: 10px; min-height: 45px; }
.auth-help { max-width: 58ch; margin: 17px 0 0; color: var(--ink-faint); font-size: 10.5px; }
.auth-trust-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: clamp(42px, 7vh, 74px) 0 0; border: 1px solid var(--line); background: var(--line); }
.auth-trust-list > div { padding: 13px; background: var(--surface-1); }
.auth-trust-list dt { color: var(--paper); font-size: 11px; font-weight: 650; }
.auth-trust-list dd { margin: 5px 0 0; color: var(--ink-faint); font-size: 9.5px; line-height: 1.45; }
.auth-visual {
display: flex;
flex-direction: column;
justify-content: center;
border-left: 1px solid var(--line);
background:
radial-gradient(520px 320px at 64% 34%, color-mix(in srgb, var(--accent-dim) 38%, transparent), transparent 65%),
color-mix(in srgb, var(--surface-2) 66%, var(--surface-1));
}
.auth-visual-intro { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.auth-visual-intro .eyebrow { margin: 0; }
.auth-visual h2 { max-width: 16ch; margin: 18px 0 12px; color: var(--paper-bright); font-size: clamp(25px, 3vw, 40px); line-height: 1.08; letter-spacing: -.035em; }
.auth-visual > p { max-width: 59ch; margin: 0; color: var(--ink-dim); font-size: 13px; }
.auth-boundary { display: grid; grid-template-columns: minmax(150px, .55fr) 1fr; gap: 20px; margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--line); }
.auth-boundary strong { color: var(--paper); font-size: 11.5px; }
.auth-boundary span { color: var(--ink-faint); font-size: 10.5px; line-height: 1.55; }
/* MCP topology illustration */
.topology { min-width: 0; margin: 0; padding: 18px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--surface-0) 88%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--paper) 5%, transparent); }
.auth-visual .topology { margin-top: 32px; }
.topology-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); }
.topology-head > span { display: inline-flex; align-items: center; gap: 8px; color: var(--paper); font-size: 10.5px; font-weight: 650; text-transform: uppercase; letter-spacing: .06em; }
.topology-head small { color: var(--ink-faint); font: 9.5px/1.4 var(--font-mono); }
.signal-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--status-unknown); box-shadow: 0 0 0 3px var(--status-unknown-dim); }
.signal-healthy { background: var(--status-success); box-shadow: 0 0 0 3px var(--status-success-dim); }
.signal-degraded { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.signal-unavailable { background: var(--status-danger); box-shadow: 0 0 0 3px var(--status-danger-dim); }
.topology-canvas { min-width: 0; }
.topology-canvas svg { display: block; width: 100%; height: auto; color: var(--accent); overflow: visible; }
.topology-mobile-flow { display: none; }
.topology-links path { fill: none; stroke: var(--line); stroke-width: 2; }
.topology-flow path { fill: none; stroke: var(--accent); stroke-width: 1.7; stroke-dasharray: 4 14; opacity: .9; animation: topology-flow 2.8s linear infinite; }
.topology-flow path:nth-child(2) { animation-delay: -.5s; }
.topology-flow path:nth-child(n+3) { animation-delay: -1s; }
@keyframes topology-flow { to { stroke-dashoffset: -72; } }
.topology-node rect, .topology-target rect { fill: var(--surface-1); stroke: var(--line); stroke-width: 1.2; transition: fill var(--motion-base) ease, stroke var(--motion-base) ease; }
.topology-node:hover rect, .topology-target:hover rect { fill: var(--surface-2-hover); stroke: var(--ink-faint); }
.topology-node > circle:not(.topology-state), .topology-node > path { fill: none; stroke: var(--accent); stroke-width: 1.5; }
.topology-node-client > circle { fill: color-mix(in srgb, var(--accent-dim) 68%, transparent) !important; }
.topology-node text, .topology-target text { fill: var(--paper); font: 600 10.5px var(--font-sans); }
.topology-node .topology-sub { fill: var(--ink-faint); font: 8.5px var(--font-mono); }
.topology-state { fill: var(--status-unknown); background: var(--status-unknown); }
.topology-node-healthy .topology-state { fill: var(--status-success); background: var(--status-success); }
.topology-node-degraded .topology-state { fill: var(--accent); background: var(--accent); }
.topology-node-unavailable .topology-state { fill: var(--status-danger); background: var(--status-danger); }
.topology figcaption { display: flex; justify-content: space-between; gap: 14px; padding-top: 10px; border-top: 1px solid var(--line-soft); color: var(--ink-faint); font-size: 9.5px; line-height: 1.5; }
.topology figcaption > span { max-width: 72ch; }
.topology figcaption small { flex: 0 0 auto; color: var(--accent); font-family: var(--font-mono); }
/* Dashboard */
.dashboard { display: grid; gap: 18px; }
.dashboard-hero { display: grid; grid-template-columns: minmax(320px, .75fr) minmax(420px, 1.25fr); gap: clamp(26px, 4vw, 58px); padding: clamp(26px, 3vw, 42px); margin: 0; border-radius: var(--radius-lg); }
.dashboard-hero::after { content: ""; position: absolute; width: 280px; height: 280px; right: 9%; top: -60%; border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent); border-radius: 50%; pointer-events: none; }
.hero-content { align-self: center; min-width: 0; }
.hero-signal { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 31px; color: var(--ink-dim); font: 9.5px/1.5 var(--font-mono); text-transform: uppercase; letter-spacing: .07em; }
.hero-signal > span { width: 7px; height: 7px; border-radius: 50%; }
.hero-signal-healthy > span { background: var(--status-success); box-shadow: 0 0 0 4px var(--status-success-dim); }
.hero-signal-attention > span { background: var(--accent); box-shadow: 0 0 0 4px var(--accent-dim); }
.dashboard-hero h2 { max-width: 14ch; font-size: clamp(30px, 3vw, 47px); line-height: 1.04; letter-spacing: -.045em; }
.dashboard-hero .hero-copy { max-width: 62ch; margin-top: 15px; font-size: 13px; line-height: 1.65; }
.hero-actions { display: flex; align-items: center; gap: 9px; margin-top: 24px; }
.hero-meta { margin-top: 27px; text-align: left; }
.hero-meta strong { display: inline; margin: 0 0 0 10px; font-size: 10.5px; }
.dashboard-hero .topology { align-self: center; }
.primary-metrics { margin: 0; }
.metric-card, .panel { border-color: color-mix(in srgb, var(--line) 88%, transparent); box-shadow: var(--shadow-1); }
.metric-card { overflow: hidden; transition: border-color var(--motion-base) ease, transform var(--motion-base) ease, background-color var(--motion-base) ease; }
.metric-card:hover { border-color: var(--ink-faint); transform: translateY(-1px); background: color-mix(in srgb, var(--surface-1) 93%, var(--surface-2-hover)); }
.metric-card::before { left: 0; right: auto; width: 3px; height: 100%; background: var(--line); }
.metric-card:has(.status-healthy)::before { background: var(--status-success); }
.metric-card:has(.status-degraded)::before { background: var(--accent); }
.metric-card:has(.status-unavailable)::before { background: var(--status-danger); }
.exception-panel { margin: 0; }
.exception-action { display: inline-flex; align-items: center; gap: 9px; }
.exception-action > .icon { color: var(--ink-faint); transition: transform var(--motion-fast) ease; }
.exception-item:hover .exception-action > .icon { transform: translateX(3px); color: var(--accent); }
.refresh-button { display: inline-flex; gap: 7px; align-items: center; }
.empty-line > span:last-child { display: grid; }
.empty-line strong { color: var(--paper); font-size: 12px; }
.empty-line small { margin-top: 3px; color: var(--ink-faint); font-size: 10.5px; }
.empty-success { justify-content: flex-start; border-style: solid; border-color: color-mix(in srgb, var(--status-success) 24%, var(--line)); background: color-mix(in srgb, var(--status-success-dim) 34%, transparent); }
.empty-success > span:first-child { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; color: var(--status-success); background: var(--status-success-dim); font-weight: 700; }
.dashboard-columns { margin: 0; }
.panel-heading a { display: inline-flex; align-items: center; gap: 5px; }
.record-link { text-decoration: none; }
.record-link:hover strong, .record-link:focus-visible strong { color: var(--accent-bright); }
.connector-score { display: flex; align-items: center; gap: 13px; margin: -3px 0 11px; padding: 0 0 16px; border-bottom: 1px solid var(--line); }
.connector-score > strong { color: var(--paper-bright); font-size: 31px; line-height: 1; }
.connector-score > span { display: grid; color: var(--paper); font-size: 11px; font-weight: 600; }
.connector-score small { margin-top: 2px; color: var(--ink-faint); font-size: 9.5px; font-weight: 400; }
.secondary-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; }
.secondary-grid .metric-card { min-height: 122px; }
.secondary-grid .metric-card strong { font-size: clamp(18px, 1.6vw, 24px); overflow-wrap: anywhere; }
/* Command menu */
.command-modal { max-width: 620px; padding: 0; overflow: hidden; }
.command-modal .modal-heading { padding: 19px 21px 13px; margin: 0; border-bottom: 1px solid var(--line); }
.command-modal .modal-heading h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; font-family: var(--font-mono); }
.command-modal .modal-body { padding: 14px; }
.command-search { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-0); color: var(--ink-faint); }
.command-search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); }
.command-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--paper); font-size: 13px; }
.command-results { display: grid; gap: 3px; max-height: min(52vh, 430px); overflow-y: auto; margin-top: 10px; }
.command-result { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; width: 100%; padding: 11px; border: 1px solid transparent; border-radius: var(--radius-md); color: var(--ink-dim); text-align: left; background: transparent; cursor: pointer; }
.command-result:hover, .command-result:focus-visible { border-color: var(--line); color: var(--paper); background: var(--surface-2); }
.command-result-icon { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: var(--radius-md); color: var(--accent); background: var(--surface-1); }
.command-result > span:nth-child(2) { display: grid; }
.command-result strong { color: var(--paper); font-size: 12px; }
.command-result small { margin-top: 2px; color: var(--ink-faint); font-size: 10.5px; }
.command-result > .icon { color: var(--ink-faint); }
.command-empty { display: grid; gap: 4px; padding: 28px; text-align: center; color: var(--ink-faint); }
.command-empty strong { color: var(--paper); }
.command-hint { margin: 12px 2px 0 !important; color: var(--ink-faint) !important; font: 9.5px/1.5 var(--font-mono) !important; }
/* Resource workspaces */
.resource-stack, .resource-stack > *, .resource-results, .desktop-data-table { min-width: 0; }
.resource-heading { align-items: flex-start; padding-bottom: 19px; border-bottom: 1px solid var(--line-soft); }
.resource-description { max-width: 76ch !important; margin: 8px 0 0 !important; color: var(--ink-dim) !important; font-size: 12px !important; }
.record-count { display: grid; min-width: 82px; padding: 9px 12px; border: 1px solid var(--line); border-radius: var(--radius-md); text-align: right; background: var(--surface-2); }
.record-count strong { color: var(--paper-bright); font-size: 17px; font-variant-numeric: tabular-nums; }
.record-count small { color: var(--ink-faint); font: 8.5px/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
.resource-toolbar { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin: 19px 0 16px; }
.filter-field { max-width: 540px; }
.filter-input-wrap { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-0); color: var(--ink-faint); transition: border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease; }
.filter-input-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); }
.filter-input-wrap input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; box-shadow: none !important; }
.filter-clear { display: grid; place-items: center; width: 25px; height: 25px; border: 0; border-radius: 50%; color: var(--ink-faint); background: transparent; cursor: pointer; }
.filter-clear:hover, .filter-clear:focus-visible { color: var(--paper); background: var(--surface-2-hover); }
.table-controls { align-items: center; }
.table-controls > span { color: var(--ink-faint); font: 9px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; }
.resource-results { position: relative; min-height: 84px; }
.results-refreshing .desktop-data-table, .results-refreshing .mobile-record-list { opacity: .5; transition: opacity var(--motion-fast) ease; }
.refresh-indicator { position: absolute; z-index: 3; top: 12px; left: 50%; display: inline-flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--radius-pill); color: var(--paper); background: var(--surface-2); box-shadow: var(--shadow-2); font-size: 10px; }
.table-wrap { max-width: 100%; overflow-x: auto; border: 1px solid var(--line-soft); border-radius: var(--radius-md); }
.table-wrap table { min-width: 820px; }
.desktop-data-table table { table-layout: fixed; }
.desktop-data-table th:nth-child(1) { width: 24%; }
.desktop-data-table th:nth-child(2) { width: 12%; }
.desktop-data-table th:nth-child(3) { width: 15%; }
.desktop-data-table td:nth-child(1), .desktop-data-table td:nth-child(3), .desktop-data-table td:nth-child(4) { white-space: normal; overflow-wrap: anywhere; }
.table-kind-tools th:nth-child(4) { width: 32%; }
.table-kind-tools th:nth-child(5) { width: 7%; }
.table-kind-tools th:nth-child(6) { width: 10%; }
.table-kind-clients table { min-width: 940px; }
.table-kind-clients th:nth-child(1) { width: 20%; }
.table-kind-clients th:nth-child(2) { width: 10%; }
.table-kind-clients th:nth-child(3) { width: 15%; }
.table-kind-clients th:nth-child(4) { width: 30%; }
.table-kind-clients th:nth-child(5) { width: 25%; }
.table-kind-audit th:nth-child(4) { width: 29%; }
.table-kind-audit th:nth-child(5) { width: 20%; }
tbody tr { transition: background-color var(--motion-fast) ease; }
tbody tr:hover { background: color-mix(in srgb, var(--surface-2-hover) 62%, transparent); }
th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.pagination { min-height: 38px; margin-top: 11px; }
.pagination > div { display: flex; gap: 6px; }
.pagination .button { display: inline-flex; align-items: center; gap: 5px; }
.client-safety, .approvals-panel { display: flex; justify-content: space-between; align-items: center; gap: 24px; }
.client-safety h3, .approvals-panel h3 { margin-bottom: 8px; }
.boundary-badge { flex: 0 0 auto; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); border-radius: var(--radius-md); color: var(--accent); background: color-mix(in srgb, var(--accent-dim) 55%, transparent); font: 9.5px/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
/* Onboarding */
.onboarding-steps { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; margin: 25px 0 21px; padding: 0; list-style: none; }
.onboarding-steps::before { content: ""; position: absolute; top: 15px; left: 16.66%; right: 16.66%; height: 1px; background: var(--line); }
.onboarding-steps li { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--ink-faint); }
.onboarding-steps li > span { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 50%; background: var(--surface-1); font: 10px var(--font-mono); }
.onboarding-steps li.active > span { border-color: var(--accent); color: var(--surface-0); background: var(--accent); }
.onboarding-steps li > div { display: grid; }
.onboarding-steps strong { color: var(--paper); font-size: 10.5px; }
.onboarding-steps small { font-size: 9.5px; }
.onboarding-form { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.onboarding-form label > small { color: var(--ink-faint); font-size: 9.5px; font-weight: 400; line-height: 1.4; text-transform: none; letter-spacing: 0; }
.form-hint { margin: -10px 0 14px !important; color: var(--ink-faint) !important; font-size: 10.5px !important; }
.external-provisioning-note { display: flex; align-items: flex-start; gap: 9px; max-width: none !important; margin: 18px 0 0 !important; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-2); }
.external-provisioning-note > span:first-child { display: grid; place-items: center; flex: 0 0 auto; width: 20px; height: 20px; border: 1px solid var(--accent-dim); border-radius: 50%; color: var(--accent); font: 700 10px var(--font-mono); }
.external-provisioning-note > span:last-child { color: var(--ink-dim); font-size: 10.5px; }
.external-provisioning-note strong { color: var(--paper); }
.copy-status { margin-top: 10px !important; color: var(--status-success) !important; font: 10px/1.5 var(--font-mono) !important; }
/* Operations and charts */
.operations-overview .panel-heading { align-items: flex-start; }
.operations-overview .panel-heading > .button { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.inline-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 110px; color: var(--ink-faint); font-size: 11px; }
.spinner-small { width: 14px; height: 14px; margin: 0; border-width: 2px; }
.live-check-summary { margin-top: 20px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid; border-radius: var(--radius-md); background: var(--surface-2); }
.live-check-healthy { border-left-color: var(--status-success); }
.live-check-attention { border-left-color: var(--status-danger); }
.live-check-summary > div:first-child { display: flex; align-items: center; gap: 10px; }
.live-check-summary > div:first-child small { margin-left: auto; color: var(--ink-faint); font: 9.5px var(--font-mono); }
.operations-grid { display: grid; grid-template-columns: minmax(320px, .72fr) minmax(520px, 1.28fr); gap: 18px; }
.generated-time { color: var(--ink-faint); font: 9.5px/1.4 var(--font-mono); }
.chart-legend { display: flex; flex-wrap: wrap; gap: 13px; margin-top: -3px; color: var(--ink-faint); font-size: 9.5px; }
.chart-legend > span { display: inline-flex; align-items: center; gap: 5px; }
.chart-legend i { display: block; width: 7px; height: 7px; border-radius: 2px; }
.legend-success, .trend-success { background: var(--status-success); }
.legend-denied, .trend-denied { background: var(--accent); }
.legend-error, .trend-error { background: var(--status-danger); }
.legend-limited, .trend-limited { background: var(--status-unknown); }
.trend-chart { gap: 4px; height: 230px; padding: 16px 10px 0; }
.trend-column { grid-template-rows: 16px 1fr 18px; min-width: 19px; }
.trend-total { color: var(--ink-faint); font: 8px/1 var(--font-mono); }
.trend-stack { display: flex; flex-direction: column-reverse; width: min(15px, 75%); min-height: 4px; overflow: hidden; border-radius: 3px 3px 1px 1px; background: var(--surface-2); transition: filter var(--motion-fast) ease; }
.trend-column:hover .trend-stack { filter: brightness(1.28); }
.trend-stack i { min-height: 0; }
.trend-empty { display: block; min-height: 4px; background: var(--line); }
.approvals-panel p { margin: 0 !important; }
/* Loading and error states */
.state-panel { width: min(100%, 700px); }
.loading-state { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 24px; text-align: left; }
.loading-state > div:nth-child(2) p { margin: 0; }
.state-orbit { position: relative; width: 76px; height: 76px; border: 1px solid var(--line); border-radius: 50%; }
.state-orbit::before, .state-orbit::after { content: ""; position: absolute; inset: 12px; border: 1px solid var(--line-soft); border-radius: 50%; }
.state-orbit::after { inset: 27px; border: 0; background: var(--accent); box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 60%, transparent); }
.state-orbit span { position: absolute; top: 35px; left: 35px; width: 6px; height: 6px; border-radius: 50%; background: var(--status-success); transform-origin: 3px 3px; animation: orbit 2.4s linear infinite; }
.state-orbit span:nth-child(1) { transform: rotate(0deg) translateX(34px); }
.state-orbit span:nth-child(2) { animation-delay: -.8s; transform: rotate(120deg) translateX(34px); }
.state-orbit span:nth-child(3) { animation-delay: -1.6s; transform: rotate(240deg) translateX(34px); }
@keyframes orbit { to { rotate: 360deg; } }
.loading-skeleton { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.loading-skeleton span { height: 5px; border-radius: var(--radius-pill); background: linear-gradient(90deg, var(--line-soft), var(--line), var(--line-soft)); background-size: 200% 100%; animation: skeleton 1.6s ease infinite; }
.loading-skeleton span:nth-child(2) { animation-delay: -.35s; }
.loading-skeleton span:nth-child(3) { animation-delay: -.7s; }
@keyframes skeleton { to { background-position: -200% 0; } }
.state-actions .button, .button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.state-footnote { display: block; margin-top: 20px; color: var(--ink-faint); font: 9.5px/1.5 var(--font-mono); }
/* Responsive composition */
@media (max-width: 1220px) {
.dashboard-hero { grid-template-columns: minmax(0, 1fr); }
.dashboard-hero h2 { max-width: 20ch; }
.secondary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.onboarding-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.auth-layout { grid-template-columns: minmax(340px, .9fr) minmax(450px, 1.1fr); }
.auth-copy, .auth-visual { padding: clamp(30px, 4vw, 52px); }
}
@media (max-width: 980px) {
.auth-layout { grid-template-columns: 1fr; }
.auth-copy .preauth-brand { margin-bottom: 46px; }
.auth-copy h1 { max-width: 16ch; }
.auth-visual { border-top: 1px solid var(--line); border-left: 0; }
.sidebar { width: 220px; }
.operations-grid { grid-template-columns: 1fr; }
.secondary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.command-trigger span, .command-trigger kbd { display: none; }
}
@media (max-width: 840px) {
.app-shell { display: block; }
.nav-trigger { display: flex; }
.topbar { align-items: center; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 210; width: min(84vw, 300px); height: 100vh; overflow-y: auto; transform: translateX(-100%); transition: transform var(--motion-base) ease; box-shadow: var(--shadow-2); padding: 20px 16px; }
.sidebar-open { transform: translateX(0); }
.sidebar-head { display: flex; align-items: center; justify-content: space-between; }
.sidebar .brand { padding: 0 4px 14px; }
.nav-close { display: block; }
nav[aria-label="Primary navigation"] { display: grid; }
.nav-link { font-size: 13.5px; padding: 10px 12px; }
.sidebar-foot { display: block; }
.nav-backdrop { display: block; position: fixed; inset: 0; background: #00000099; z-index: 200; }
}
@media (max-width: 760px) {
.session-menu > .tag { display: none; }
.resource-toolbar { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; }
.table-controls { flex-wrap: wrap; }
.client-safety, .approvals-panel { align-items: flex-start; }
.auth-trust-list { grid-template-columns: 1fr; }
.auth-boundary { grid-template-columns: 1fr; gap: 8px; }
}
@media (max-width: 640px) {
body { background-size: 42px 42px, 42px 42px, auto; }
.sidebar { position: fixed; height: 100vh; width: min(86vw, 310px); }
.nav-backdrop { border: 0; padding: 0; }
.main-content { padding: 19px 14px 44px; }
.topbar { min-height: 44px; margin-bottom: 22px; }
.topbar-actions { gap: 6px; }
.topbar-title .eyebrow { display: none; }
.topbar h1 { font-size: 20px; }
.command-trigger { width: 36px; justify-content: center; padding: 0; }
.session-menu { margin: 0; }
.session-menu .button { min-width: 36px; padding-inline: 8px; font-size: 0; }
.session-menu .button::after { content: "↗"; font-size: 14px; }
.preauth-shell .main-content { padding: 10px; }
.auth-layout { min-height: calc(100vh - 20px); border-radius: var(--radius-lg); }
.auth-copy, .auth-visual { padding: 30px 22px; }
.auth-copy .preauth-brand { margin-bottom: 48px; }
.auth-copy h1 { font-size: clamp(36px, 12vw, 50px); }
.auth-lead { font-size: 13px; }
.auth-actions { align-items: flex-start; flex-direction: column; }
.auth-button { width: 100%; }
.auth-visual h2 { max-width: 20ch; font-size: 27px; }
.auth-visual .topology { margin-top: 24px; }
.topology { padding: 12px; }
.topology-head small { display: none; }
.topology-canvas { display: none; }
.topology-mobile-flow { display: grid; justify-items: stretch; width: min(100%, 250px); margin: 22px auto 18px; }
.topology-mobile-node { position: relative; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 10px; min-height: 56px; padding: 9px 12px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-1); }
.topology-mobile-node > span:nth-child(2) { display: grid; gap: 2px; }
.topology-mobile-node strong { color: var(--paper); font-size: 11px; }
.topology-mobile-node small { color: var(--ink-faint); font: 8.5px/1.4 var(--font-mono); }
.topology-mobile-mark { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid var(--accent); border-radius: 50%; color: var(--accent); font: 600 11px var(--font-mono); background: var(--accent-dim); }
.topology-mobile-node > .topology-state { width: 7px; height: 7px; border-radius: 50%; }
.topology-mobile-link { width: 1px; height: 21px; margin: 0 auto; border-left: 1px dashed var(--accent); }
.topology-mobile-targets { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
.topology-mobile-targets > span { display: inline-flex; align-items: center; gap: 6px; min-height: 28px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--paper); font-size: 9px; background: var(--surface-1); }
.topology-mobile-targets .topology-state { width: 6px; height: 6px; border-radius: 50%; }
.topology figcaption { display: block; }
.topology figcaption small { display: block; margin-top: 6px; }
.dashboard { gap: 14px; }
.dashboard-hero { padding: 22px 18px; gap: 24px; }
.dashboard-hero h2 { font-size: 31px; }
.hero-signal { margin-bottom: 22px; }
.hero-actions { display: grid; grid-template-columns: 1fr; }
.hero-meta { display: grid; gap: 3px; }
.hero-meta strong { margin: 0; }
.metric-grid, .secondary-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { min-height: 116px; padding: 15px; }
.metric-card strong { font-size: 20px; }
.exception-item { align-items: flex-start; }
.exception-action > .icon { display: none; }
.resource-stack { gap: 13px; }
.resource-stack .resource-page, .panel { padding: 16px; }
.resource-heading { gap: 12px; }
.record-count { min-width: 70px; }
.table-controls > span { flex-basis: 100%; }
.pagination { align-items: flex-start; flex-direction: column; }
.pagination > div { width: 100%; }
.pagination .button { flex: 1; }
.onboarding-steps li { display: grid; justify-items: center; text-align: center; }
.onboarding-steps li > div { display: none; }
.onboarding-form { grid-template-columns: 1fr; }
.client-safety, .approvals-panel { display: grid; }
.boundary-badge { justify-self: start; }
.live-check-summary > div:first-child { align-items: flex-start; flex-wrap: wrap; }
.live-check-summary > div:first-child small { width: 100%; margin: 0; }
.trend-chart { height: 205px; }
.chart-legend { gap: 9px; }
.loading-state { grid-template-columns: 1fr; justify-items: center; text-align: center; }
.loading-skeleton { grid-template-columns: 1fr; width: 100%; }
.command-modal { align-self: start; margin-top: 8vh; }
}
@media (max-width: 420px) {
.metric-grid, .secondary-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 102px; }
.auth-copy h1 { font-size: 36px; }
.auth-visual-intro { align-items: flex-start; }
.auth-visual-intro .tag { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.topology-flow path, .state-orbit span, .loading-skeleton span { animation: none; }
.metric-card:hover { transform: none; }
}
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, createApiClient, logoutSession } from "../src/api";
afterEach(() => vi.unstubAllGlobals());
describe("management API safety contracts", () => {
it("uses same-origin server pagination and URL-addressable filters", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ kind: "tools", generatedAt: "2026-08-01T18:00:00Z", rows: [], page: { total: 0 } }) });
vi.stubGlobal("fetch", fetchMock);
await createApiClient().resource("tools", { filter: "risk:R1", limit: 25 });
expect(fetchMock).toHaveBeenCalledWith("/api/tools?q=risk%3AR1&limit=25", expect.objectContaining({ credentials: "same-origin", headers: { Accept: "application/json" } }));
});
it("maps protected client actions and never invents returned secret material", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] }) });
vi.stubGlobal("fetch", fetchMock);
const config = await createApiClient().generatedConfig("client-1");
expect(config).toEqual({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] });
expect(JSON.stringify(config)).not.toContain("secret");
expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: "same-origin" }));
});
it("fails closed without CSRF and sends the session-bound token on writes", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-fixture" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ decision: "deny", matchedPolicies: [], reasons: [], traceId: "trace" }) });
vi.stubGlobal("fetch", fetchMock);
const api = createApiClient();
await expect(api.simulatePolicy({ project: "p", environment: "prod", tool: "t" })).rejects.toBeInstanceOf(ApiError);
await api.session();
await api.simulatePolicy({ project: "p", environment: "prod", tool: "t" });
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-fixture" }) }));
});
it("logs out through the same-origin CSRF-protected BFF route", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-logout" }) })
.mockResolvedValueOnce({ ok: true, status: 204 });
vi.stubGlobal("fetch", fetchMock);
await logoutSession();
expect(fetchMock.mock.calls[1]?.[0]).toBe("/auth/logout");
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: "POST", headers: expect.objectContaining({ "X-CSRF-Token": "csrf-logout" }) }));
});
it("keeps resource listing separate from persistent client management routes", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-1" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ status: "revoked" }) });
vi.stubGlobal("fetch", fetchMock);
const api = createApiClient();
await api.session();
expect(await api.clientAction("client-1", "revoke")).toEqual({ accepted: true, message: "Client revoke completed." });
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/client-management/client-1/revoke");
});
it("posts bounded tool readiness input to the dedicated endpoint", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-ready" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ ready: false, checks: {} }) });
vi.stubGlobal("fetch", fetchMock);
const api = createApiClient();
await api.session();
await api.testTool({ project: "mobilityops", environment: "prod", tool: "mobilityops.vehicle.get", arguments: { vehicleRef: "V-42" } });
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/tools/test");
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: "POST", body: JSON.stringify({ project: "mobilityops", environment: "prod", tool: "mobilityops.vehicle.get", arguments: { vehicleRef: "V-42" } }) }));
});
it("invokes tools and operations checks only through CSRF-protected routes", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-live" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true, traceId: "trace-1" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ healthy: true, connectors: {} }) });
vi.stubGlobal("fetch", fetchMock);
const api = createApiClient();
await api.session();
const payload = { project: "mobilityops", environment: "prod", tool: "mobilityops.operations.summary", arguments: {} };
await api.invokeTool(payload);
await api.runOperationsCheck();
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/tools/invoke");
expect(fetchMock.mock.calls[2]?.[0]).toBe("/api/operations/check");
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-live" }) }));
expect(fetchMock.mock.calls[2]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-live" }) }));
});
it("converts non-success responses to typed safe errors", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403 }));
await expect(createApiClient().dashboard()).rejects.toBeInstanceOf(ApiError);
});
});
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { bootstrapMessage, workspaceName } from "../src/bootstrap";
describe("web workspace bootstrap", () => {
it("exposes a deterministic placeholder contract", () => {
expect(workspaceName).toBe("ITWorx MCP Hub");
expect(bootstrapMessage()).toContain("WP-120");
});
});
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { isSnapshotStale, routeTitle } from "../src/contracts";
import { formatBrusselsTime, healthLabel } from "../src/format";
describe("management shell contracts", () => {
it("maps required routes to stable titles", () => { expect(routeTitle("/")).toBe("Overview"); expect(routeTitle("/projects/ludarium")).toBe("Project detail"); expect(routeTitle("/audit")).toBe("Audit"); expect(routeTitle("/missing")).toBe("Not found"); });
it("marks invalid and old snapshots stale instead of implying health", () => { const snapshot = { generatedAt: "2026-08-01T10:00:00Z", staleAfterSeconds: 30 } as Parameters<typeof isSnapshotStale>[0]; expect(isSnapshotStale(snapshot, Date.parse("2026-08-01T10:00:31Z"))).toBe(true); expect(isSnapshotStale({ ...snapshot, generatedAt: "not-a-date" })).toBe(true); });
it("formats operator times in Brussels and labels state textually", () => { expect(formatBrusselsTime("2026-08-01T10:00:00Z")).toContain("12:00"); expect(formatBrusselsTime(null)).toBe("Never recorded"); expect(healthLabel("unavailable")).toBe("Unavailable"); });
});
+56
View File
@@ -0,0 +1,56 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, loginAs, test, type Persona } from "./fixtures";
const routes: { path: string; persona: Persona }[] = [
{ path: "/", persona: "hub-viewer" },
{ path: "/projects", persona: "hub-viewer" },
{ path: "/servers", persona: "hub-viewer" },
{ path: "/tools", persona: "hub-viewer" },
{ path: "/connectors", persona: "hub-viewer" },
{ path: "/clients", persona: "hub-viewer" },
{ path: "/policies", persona: "hub-viewer" },
{ path: "/operations", persona: "hub-viewer" },
{ path: "/audit", persona: "hub-auditor" },
{ path: "/settings", persona: "hub-viewer" },
];
test.describe("accessibility and responsive baseline", () => {
test("the unauthenticated gateway has landmarks, a skip link, and no serious axe findings", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Skip to main content" })).toBeVisible();
await expect(page.getByRole("main")).toHaveCount(1);
const result = await new AxeBuilder({ page }).analyze();
expect(result.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? ""))).toEqual([]);
});
for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }]) {
test(`authorized routes have no serious axe findings or page overflow at ${viewport.width}px`, async ({ page }) => {
test.setTimeout(90_000);
await page.setViewportSize(viewport);
for (const persona of ["hub-viewer", "hub-auditor"] as const) {
const personaRoutes = routes.filter((route) => route.persona === persona);
if (!personaRoutes.length) continue;
await loginAs(page, persona);
for (const route of personaRoutes) {
await page.goto(route.path);
await expect(page.getByRole("main")).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
expect(overflow, `${route.path} must not cause page-level horizontal overflow`).toBe(false);
const result = await new AxeBuilder({ page }).analyze();
expect(result.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? "")), route.path).toEqual([]);
}
}
});
}
test("mobile drawer and modal preserve keyboard focus", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-viewer");
await page.goto("/");
const trigger = page.getByRole("button", { name: "Open navigation" });
await trigger.focus();
await trigger.press("Enter");
await page.getByRole("button", { name: "Close navigation" }).press("Escape");
await expect(trigger).toBeFocused();
});
});
+65
View File
@@ -0,0 +1,65 @@
import { expect, loginAs, test } from "./fixtures";
test.describe("authentication", () => {
test("an unauthenticated visitor sees the sign-in panel, not management data", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Inspect your governed MCP estate." })).toBeVisible();
await expect(page.getByRole("link", { name: "Continue to sign in" })).toHaveAttribute("href", "/auth/login");
// No management chrome or data leaks before authentication.
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Open platform settings" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toHaveCount(0);
});
test("a real PKCE/BFF login lands on an authenticated dashboard with real data", async ({ page }) => {
await loginAs(page, "hub-viewer");
await expect(page).toHaveURL(/\/$/);
await expect(page.getByText("Authenticated", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
// Real backend data, not a placeholder: Ludarium is the seeded catalog project.
await expect(page.getByText("Ludarium", { exact: true })).toBeVisible();
});
test("an authenticated user can sign out and stale management data disappears", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/settings");
await page.getByRole("button", { name: "Sign out" }).click();
await expect(page.getByRole("heading", { name: "Inspect your governed MCP estate." })).toBeVisible();
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toHaveCount(0);
});
test("an invalidated session cookie safely falls back to the sign-in panel, not stale data", async ({
page,
context,
}) => {
await loginAs(page, "hub-viewer");
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
const cookies = await context.cookies();
const sessionCookie = cookies.find((cookie) => cookie.name === "hub_session");
expect(sessionCookie).toBeDefined();
await context.addCookies([{ ...sessionCookie!, value: "tampered-session-value" }]);
await page.reload();
await expect(page.getByRole("heading", { name: "Inspect your governed MCP estate." })).toBeVisible();
// The stale dashboard from before tampering must not still be showing.
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toHaveCount(0);
});
test("a mutating request with a missing CSRF token is safely rejected, not silently accepted", async ({
page,
}) => {
await loginAs(page, "hub-admin");
await page.goto("/policies");
await page.route("**/api/policies/simulate", async (route) => {
const headers = { ...route.request().headers() };
delete headers["x-csrf-token"];
await route.continue({ headers });
});
await page.getByLabel("Project").selectOption({ index: 0 });
await page.getByRole("button", { name: "Explain decision" }).click();
await expect(page.getByRole("alert")).toBeVisible();
// No decision card was rendered from an unauthenticated-CSRF response.
await expect(page.getByRole("status").filter({ hasText: "Matched:" })).toHaveCount(0);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { test as base, expect, type Page } from "@playwright/test";
/** Matches services/control_api/auth/models.py's ManagementRole group names,
* and tests/e2e/harness.py's ROLE_GROUPS -- these are the only group names
* the real per-endpoint RBAC matrix (services/control_api/authz.py) maps to
* a role at all; anything else is denied by default. */
export type Persona = "hub-viewer" | "hub-auditor" | "hub-operator" | "hub-admin";
/** Drives the real PKCE/BFF login flow through a real browser navigation:
* GET /auth/login (real 307 to the fake OIDC provider's real HTTPS
* /authorize), rewriting only the *outbound request URL* to add a
* `persona` hint the fake provider reads (see fake_provider.py) -- no
* production auth code is touched or bypassed, only the request this test
* harness itself sends to a fake dependency. */
export async function loginAs(page: Page, persona: Persona): Promise<void> {
// Rewriting the *navigation request itself* to /authorize (page.route
// continue({url})) does not reliably apply to a same-navigation redirect
// hop in Chromium -- verified by hand: the outbound request to
// /authorize still carried no persona param, and the fake provider fell
// back to its default group. Instead, intercept the /auth/login response
// (a 307), fetch it with redirects disabled, and rewrite the outgoing
// Location header before letting the browser follow it.
await page.route("**/auth/login", async (route) => {
const response = await route.fetch({ maxRedirects: 0 });
const headers = response.headers();
if (!headers.location) throw new Error("expected /auth/login to redirect with a Location header");
const location = new URL(headers.location);
location.searchParams.set("persona", persona);
await route.fulfill({ status: response.status(), headers: { ...headers, location: location.toString() } });
});
await page.goto("/auth/login");
await page.waitForLoadState("networkidle");
await page.unroute("**/auth/login");
}
export const test = base;
export { expect };
+56
View File
@@ -0,0 +1,56 @@
import { expect, loginAs, test } from "./fixtures";
// The persistent sidebar becomes an off-canvas drawer below 840px (see
// styles.css's compact-navigation media block) -- these tests only make
// sense at that width, so every test in this file pins a mobile viewport.
test.use({ viewport: { width: 390, height: 844 } });
test.describe("mobile navigation drawer", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/");
});
test("opens via the hamburger trigger and exposes its state to assistive tech", async ({ page }) => {
const trigger = page.getByRole("button", { name: "Open navigation" });
await expect(trigger).toHaveAttribute("aria-expanded", "false");
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
await trigger.click();
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
await expect(trigger).toHaveAttribute("aria-expanded", "true");
});
test("closes via its own close button", async ({ page }) => {
await page.getByRole("button", { name: "Open navigation" }).click();
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
await page.getByRole("button", { name: "Close navigation" }).click();
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
});
test("closes on Escape", async ({ page }) => {
await page.getByRole("button", { name: "Open navigation" }).click();
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
await page.keyboard.press("Escape");
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
});
test("closes on backdrop click", async ({ page }) => {
await page.getByRole("button", { name: "Open navigation" }).click();
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
// The backdrop spans the full viewport (position:fixed; inset:0), but
// the open sidebar itself sits on top of its left ~240px and intercepts
// pointer events there -- click well to the right of the drawer's own
// width so the hit lands on the backdrop, not the drawer.
await page.locator(".nav-backdrop").click({ position: { x: 350, y: 400 } });
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
});
test("closes automatically after navigating to another page", async ({ page }) => {
await page.getByRole("button", { name: "Open navigation" }).click();
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
await page.getByRole("link", { name: "Catalog", exact: true }).click();
await expect(page).toHaveURL(/\/tools$/);
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { expect, loginAs, test } from "./fixtures";
test.describe("role-based authorization, driven end to end from the browser", () => {
test("a viewer never sees the client-create form; an admin does", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/clients");
await expect(page.getByRole("button", { name: "Create pending client" })).toHaveCount(0);
await expect(page.getByText("Client creation requires the admin role.")).toBeVisible();
await loginAs(page, "hub-admin");
await page.goto("/clients");
await expect(page.getByRole("button", { name: "Create pending client" })).toBeVisible();
});
test("client.create is still server-enforced even if the hidden form were bypassed", async ({ page, request }) => {
// Proves the security property independent of the UI: the hidden
// button is a usability affordance, not the authorization boundary.
await loginAs(page, "hub-viewer");
const cookies = await page.context().cookies();
const sessionCookie = cookies.find((cookie) => cookie.name.toLowerCase().includes("session"));
if (!sessionCookie) throw new Error("expected a session cookie to be set after login");
const sessionResponse = await request.get("/auth/session", { headers: { Cookie: `${sessionCookie.name}=${sessionCookie.value}` } });
const { csrf_token: csrfToken } = await sessionResponse.json();
const response = await request.post("/api/client-management", {
headers: { Cookie: `${sessionCookie.name}=${sessionCookie.value}`, "X-CSRF-Token": csrfToken },
data: { name: "e2e-bypass-attempt", project: "ludarium", environment: "prod", kind: "native-2026" },
});
expect(response.status()).toBe(403);
});
test("an auditor can read the audit log; a viewer never sees the Audit nav link at all", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/");
await expect(page.getByRole("link", { name: "Audit" })).toHaveCount(0);
await loginAs(page, "hub-auditor");
await page.goto("/");
await expect(page.getByRole("link", { name: "Audit" })).toBeVisible();
await page.goto("/audit");
await expect(page.getByRole("alert")).toHaveCount(0);
await expect(page.getByText(/total · server paginated/)).toBeVisible();
});
test("a viewer cannot select the audit section of a project workspace", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/projects/ludarium?tab=Audit");
await expect(page.getByRole("link", { name: "Audit" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Summary" })).toHaveAttribute("aria-current", "page");
});
test("an operator sees state-appropriate client actions; a viewer does not", async ({ page }) => {
await loginAs(page, "hub-admin");
await page.goto("/clients");
await page.getByLabel("Name").fill("e2e-operator-target");
await page.getByRole("button", { name: "Create pending client" }).click();
await expect(page.getByText(/Client created: e2e-operator-target/)).toBeVisible();
await loginAs(page, "hub-viewer");
await page.goto("/clients");
const viewerRow = page.getByRole("row", { name: /e2e-operator-target/ });
await expect(viewerRow).toBeVisible();
await expect(viewerRow.getByRole("button", { name: "Disable" })).toHaveCount(0);
await loginAs(page, "hub-operator");
await page.goto("/clients");
const operatorRow = page.getByRole("row", { name: /e2e-operator-target/ });
await expect(operatorRow.getByRole("button", { name: "Disable" })).toBeDisabled();
await operatorRow.getByRole("button", { name: "Revoke" }).click();
// The confirmation modal's title is dynamic ("Revoke e2e-operator-target?"),
// which makes an exact accessible-name match on the confirm button awkward
// to pin reliably against the row-scoped locator above. This is the one
// deliberate exception to this file's role/text-based query convention,
// justified by the dynamic title: DataTable.tsx's modal confirm button
// carries data-testid="confirm-action" specifically for this case.
await page.getByTestId("confirm-action").click();
await expect(page.getByText(/completed\./)).toBeVisible();
});
test("tool readiness is hidden below operator and visible to an operator", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/tools");
await expect(page.getByRole("button", { name: "Readiness" })).toHaveCount(0);
await loginAs(page, "hub-operator");
await page.goto("/tools");
await expect(page.getByRole("button", { name: "Readiness" }).first()).toBeVisible();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, loginAs, test } from "./fixtures";
// Playwright's default browser color-scheme emulation is "light", which
// would make styles.css's `@media (prefers-color-scheme: light)` block
// apply even with no `data-theme` attribute set, collapsing the "dark"
// and "light" reads below into the same value. Pin the emulated OS
// preference to dark so the initial (no localStorage) state is
// deterministic and the toggle's own explicit `data-theme` write -- not
// an OS-preference coincidence -- is what the test is proving out.
test.use({ colorScheme: "dark" });
test.describe("theme toggle", () => {
test("switching to light theme persists across reload and changes the rendered background", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/settings");
const darkBackground = await page.evaluate(() => getComputedStyle(document.documentElement).backgroundColor);
await page.getByRole("button", { name: "Light" }).click();
const lightBackground = await page.evaluate(() => getComputedStyle(document.documentElement).backgroundColor);
expect(lightBackground).not.toBe(darkBackground);
await page.reload();
const afterReload = await page.evaluate(() => getComputedStyle(document.documentElement).backgroundColor);
expect(afterReload).toBe(lightBackground);
await page.getByRole("button", { name: "Dark" }).click();
});
test("appearance toggle exposes which theme is selected, not just the applied state", async ({ page }) => {
// Regression test alongside the density toggle fix: SettingsPage had no
// state tracking the current theme at all, so Dark/Light always
// rendered as the same solid pill regardless of which was active.
await loginAs(page, "hub-viewer");
await page.goto("/settings");
const dark = page.getByRole("button", { name: "Dark" });
const light = page.getByRole("button", { name: "Light" });
await expect(dark).toHaveAttribute("aria-pressed", "true");
await expect(light).toHaveAttribute("aria-pressed", "false");
await light.click();
await expect(dark).toHaveAttribute("aria-pressed", "false");
await expect(light).toHaveAttribute("aria-pressed", "true");
await dark.click();
});
});
+106
View File
@@ -0,0 +1,106 @@
import { expect, loginAs, test } from "./fixtures";
const captureReview = Boolean(process.env.SOL_VISUAL_REVIEW);
async function expectNoPageOverflow(page: import("@playwright/test").Page) {
const dimensions = await page.evaluate(() => ({ documentWidth: document.documentElement.scrollWidth, viewportWidth: window.innerWidth }));
expect(dimensions.documentWidth, `document width at ${dimensions.viewportWidth}px`).toBeLessThanOrEqual(dimensions.viewportWidth + 1);
}
test.describe("product-quality interaction and composition", () => {
test("the public gateway uses the available canvas and stays usable from desktop to mobile", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await expect(page.getByRole("heading", { name: "Inspect your governed MCP estate." })).toBeVisible();
await expect(page.getByLabel("How governed MCP access works")).toBeVisible();
const desktopLayout = await page.locator(".auth-layout").boundingBox();
expect(desktopLayout?.width ?? 0).toBeGreaterThan(1100);
await expectNoPageOverflow(page);
if (captureReview) await page.screenshot({ path: testInfo.outputPath("public-desktop.png"), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("link", { name: /Continue to sign in/ })).toBeVisible();
await expect(page.getByText("Project scoped", { exact: true })).toBeVisible();
await expectNoPageOverflow(page);
if (captureReview) await page.screenshot({ path: testInfo.outputPath("public-mobile.png"), fullPage: true });
});
test("quick jump is keyboard-first, access-aware and routes without a reload", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/");
await expect(page.getByRole("button", { name: "Open quick jump" })).toBeVisible();
await page.keyboard.press("Control+K");
const dialog = page.getByRole("dialog", { name: "Quick jump" });
await expect(dialog).toBeVisible();
const search = dialog.getByLabel("Find a workspace");
await expect(search).toBeFocused();
await expect(dialog.getByRole("button", { name: /Audit/ })).toHaveCount(0);
await search.fill("catalog");
await dialog.getByRole("button", { name: /Catalog/ }).click();
await expect(page).toHaveURL(/\/tools$/);
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
});
test("resource filtering is debounced, normalized and URL-addressable", async ({ page }) => {
await loginAs(page, "hub-admin");
await page.goto("/tools");
const filter = page.getByLabel("Filter records");
await filter.fill(" Fleet Ops ");
await expect(page).toHaveURL(/\/tools\?q=Fleet%20Ops$/);
await expect(page.getByText("Updating results…")).toHaveCount(0);
await filter.fill(" ");
await expect(page).toHaveURL(/\/tools$/);
});
test("the authenticated control plane retains hierarchy and has no overflow at key viewports", async ({ page }, testInfo) => {
await loginAs(page, "hub-viewer");
for (const viewport of [{ width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport);
await page.goto("/");
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
await expect(page.getByLabel("Governed MCP request path")).toBeVisible();
const topbar = await page.locator(".topbar").boundingBox();
expect(topbar?.y ?? Number.POSITIVE_INFINITY, `topbar position at ${viewport.width}px`).toBeLessThan(100);
await expectNoPageOverflow(page);
if (captureReview) await page.screenshot({
path: testInfo.outputPath(`dashboard-${viewport.width}.png`),
fullPage: false,
});
}
});
test("catalog, operations and onboarding remain workable beyond the dashboard", async ({ page }, testInfo) => {
const consoleErrors: string[] = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
await loginAs(page, "hub-admin");
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/tools");
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
await expect(page.getByLabel("Filter records")).toBeVisible();
await expectNoPageOverflow(page);
if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-desktop.png"), fullPage: false });
await page.goto("/operations");
await expect(page.getByRole("heading", { name: "Governed-call trend" })).toBeVisible();
await expectNoPageOverflow(page);
if (captureReview) await page.screenshot({ path: testInfo.outputPath("operations-desktop.png"), fullPage: false });
await page.goto("/clients");
const onboarding = page.locator(".onboarding-panel");
await expect(onboarding).toBeVisible();
await expectNoPageOverflow(page);
if (captureReview) await onboarding.screenshot({ path: testInfo.outputPath("client-onboarding.png") });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/tools");
await expect(page.getByLabel("Filter records")).toBeVisible();
await expectNoPageOverflow(page);
if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-mobile.png"), fullPage: false });
expect(consoleErrors).toEqual([]);
});
});
+230
View File
@@ -0,0 +1,230 @@
import { expect, loginAs, test } from "./fixtures";
test.describe("management workflows, driven end to end from the browser", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "hub-admin");
});
test("tool catalog pagination is real: Next/Previous move through disjoint, server-issued pages", async ({
page,
}) => {
await page.goto("/tools");
const firstRowId = () => page.locator("table tbody tr").first().locator("small").first().textContent();
const firstPageFirstId = await firstRowId();
await expect(page.getByRole("button", { name: "Previous page" })).toBeDisabled();
await page.getByRole("button", { name: "Next page" }).click();
await expect(page.locator("table tbody tr").first().locator("small").first()).not.toHaveText(firstPageFirstId ?? "");
const secondPageFirstId = await firstRowId();
await page.getByRole("button", { name: "Previous page" }).click();
await expect(page.locator("table tbody tr").first().locator("small").first()).toHaveText(firstPageFirstId ?? "");
await page.getByRole("button", { name: "Next page" }).click();
await expect(page.locator("table tbody tr").first().locator("small").first()).toHaveText(secondPageFirstId ?? "");
});
test("project detail renders real registered environments and connector bindings, not a fixed 'prod'", async ({
page,
}) => {
await page.goto("/projects/ludarium?tab=Environments");
await expect(page.getByLabel("Project detail sections").getByRole("link", { name: "Environments" })).toHaveAttribute("aria-current", "page");
await expect(page.getByRole("cell", { name: "prod", exact: true })).toBeVisible();
// ludarium never registered a staging environment (docs/known-state, and
// tests/test_wp230_ui_completion.py's equivalent backend assertion).
await expect(page.getByRole("cell", { name: "staging" })).toHaveCount(0);
await page.getByLabel("Project detail sections").getByRole("link", { name: "Connectors" }).click();
await expect(page.getByRole("cell", { name: "knowledge", exact: true })).toBeVisible();
});
test("an unregistered project is a real 404, not a fabricated default", async ({ page }) => {
await page.goto("/projects/does-not-exist");
await expect(page.getByRole("alert")).toBeVisible();
await expect(page.getByText(/is not registered/)).toBeVisible();
});
test("policy simulation is wired to the real engine: a known-allow case explains its decision", async ({
page,
}) => {
await page.goto("/policies");
await page.getByLabel("Project").selectOption({ label: "Ludarium" });
await page.getByLabel("Environment").selectOption("prod");
await page.getByLabel("Tool").selectOption({ label: "Knowledge Document Search" });
await page.getByRole("button", { name: "Explain decision" }).click();
const decisionCard = page.getByRole("status").filter({ hasText: "Matched:" });
await expect(decisionCard).toBeVisible();
await expect(decisionCard.getByText(/Trace [0-9a-f-]{8,}/)).toBeVisible();
await expect(decisionCard.getByText("Matched: none")).toHaveCount(0);
});
test("catalog preview surfaces a real validation result, driven by a real project selector", async ({ page }) => {
await page.goto("/tools");
await page.getByRole("button", { name: "Validate and preview" }).click();
const outcome = page.getByText(/Valid preview|Validation blocked/);
await expect(outcome).toBeVisible();
const applyButton = page.getByRole("button", { name: "Apply approved catalog" });
if (await outcome.filter({ hasText: "Valid preview" }).count()) {
await expect(applyButton).toBeEnabled();
await applyButton.click();
await page.getByRole("button", { name: "Confirm apply" }).click();
} else {
await expect(applyButton).toBeDisabled();
}
});
test("connector state is communicated as visible text, not color alone", async ({ page }) => {
await page.goto("/connectors");
await expect(page.locator("table tbody tr").first()).toBeVisible();
const statusCells = page.locator("table tbody tr td .status");
const count = await statusCells.count();
expect(count).toBeGreaterThan(0);
for (let index = 0; index < count; index += 1) {
const text = (await statusCells.nth(index).innerText()).trim();
expect(["Healthy", "Degraded", "Unavailable", "Unknown"]).toContain(text);
}
});
test("table density toggle changes row padding without changing row count or data", async ({ page }) => {
await page.goto("/tools");
await expect(page.locator("table tbody tr").first()).toBeVisible();
const rowCountBefore = await page.locator("table tbody tr").count();
await page.getByRole("button", { name: "Compact" }).click();
await expect(page.locator(".table-wrap")).toHaveClass(/density-compact/);
expect(await page.locator("table tbody tr").count()).toBe(rowCountBefore);
await page.getByRole("button", { name: "Comfortable" }).click();
await expect(page.locator(".table-wrap")).toHaveClass(/density-comfortable/);
});
test("density toggle exposes which option is selected, not just the applied state", async ({ page }) => {
// Regression test for a real bug found during WP-235's visual polish
// pass: the Stitch retokenization made .button's default look a solid
// accent pill, which is also what the "active" class resolved to --
// Comfortable and Compact rendered identically, so neither the sighted
// UI nor assistive tech could tell which density was selected.
await page.goto("/tools");
const comfortable = page.getByRole("button", { name: "Comfortable" });
const compact = page.getByRole("button", { name: "Compact" });
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
await expect(compact).toHaveAttribute("aria-pressed", "false");
await expect(compact).toHaveClass(/button-secondary/);
await compact.click();
await expect(comfortable).toHaveAttribute("aria-pressed", "false");
await expect(compact).toHaveAttribute("aria-pressed", "true");
await expect(comfortable).toHaveClass(/button-secondary/);
});
test("policy simulation renders a step-by-step decision trace, not just the top-line result", async ({ page }) => {
await page.goto("/policies");
await page.getByLabel("Project").selectOption({ label: "Ludarium" });
await page.getByLabel("Environment").selectOption("prod");
await page.getByLabel("Tool").selectOption({ label: "Knowledge Document Search" });
await page.getByRole("button", { name: "Explain decision" }).click();
await expect(page.getByText("Decision logic trace")).toBeVisible();
const steps = page.locator(".trace-step");
await expect(steps.first()).toBeVisible();
expect(await steps.count()).toBeGreaterThan(0);
// Every rendered step must carry a real phase name from the engine's
// trace, not an empty placeholder row.
const firstStepPhase = await steps.first().locator("strong").innerText();
expect(firstStepPhase.trim().length).toBeGreaterThan(0);
});
test("generated client configuration never renders secret material into the DOM", async ({ page }) => {
await page.goto("/clients");
await page.getByLabel("Name").fill("e2e-config-check");
await page.getByRole("button", { name: "Create pending client" }).click();
await expect(page.getByText(/Client created: e2e-config-check/)).toBeVisible();
await page.reload();
const targetRow = page.getByRole("row", { name: /e2e-config-check/ });
await expect(targetRow).toBeVisible();
await targetRow.getByRole("button", { name: "Config" }).click();
await expect(page.getByText(/Generated configuration for/)).toBeVisible();
const bodyText = await page.locator("body").innerText();
expect(bodyText).not.toMatch(/"secret"|client_secret|bearer [a-z0-9]{20,}/i);
expect(bodyText).toContain("Secret material is write-only");
});
test("client onboarding does not claim that the Hub provisions an Authentik identity", async ({ page }) => {
await page.goto("/clients");
await expect(page.getByText(/External identity provisioning is a separate administrator task/)).toBeVisible();
await expect(page.getByText(/Authentik hand-off reference/)).toHaveCount(0);
});
test("Operations page renders the same real drift/backup/denial data as the dashboard, not a fabricated summary", async ({ page }) => {
await page.goto("/");
const dashboardDrift = await page.locator(".metric-card", { hasText: "Reconcile drift" }).locator("strong").innerText();
await page.goto("/operations");
await expect(page.getByRole("heading", { name: "Operations" })).toBeVisible();
const operationsDrift = await page.locator(".metric-card", { hasText: "Reconcile drift" }).locator("strong").innerText();
expect(operationsDrift).toBe(dashboardDrift);
});
test("tool readiness validates the full registered path without invoking the tool", async ({ page }) => {
await page.goto("/tools");
await page.getByRole("button", { name: "Validate and preview" }).click();
await expect(page.getByText("Valid preview")).toBeVisible();
await page.getByRole("button", { name: "Apply approved catalog" }).click();
await page.getByRole("button", { name: "Confirm apply" }).click();
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible();
await page.getByLabel("Filter records").fill("Fleet Ops Operations Summary");
const row = page.getByRole("row", { name: /Fleet Ops Operations Summary/ });
await row.getByRole("button", { name: "Readiness" }).click();
await page.getByRole("dialog").getByLabel("Project").selectOption({ label: "Fleet Ops" });
await page.getByRole("dialog").getByLabel("Environment").selectOption("prod");
await page.getByRole("button", { name: "Run readiness check" }).click();
await expect(page.getByRole("dialog").getByText("Ready", { exact: true })).toBeVisible();
await expect(page.getByRole("dialog").getByText(/no MCP tool invocation was performed/i)).toBeVisible();
await expect(page.getByRole("dialog").getByText(/healthy/i).first()).toBeVisible();
});
test("closing a Modal returns keyboard focus to the element that opened it, not document.body", async ({ page }) => {
// Regression test for a real bug found during WP-235 Task 12's manual
// accessibility pass: Modal moved focus into the dialog on open but
// never restored it on close, silently dropping keyboard users back to
// document.body. Uses the tools "Readiness" button's Modal as an isolated,
// side-effect-free trigger.
await page.goto("/tools");
const trigger = page.locator("table tbody tr").first().getByRole("button", { name: "Readiness" });
await trigger.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(trigger).toBeFocused();
});
test("an open Modal locks background scroll and traps keyboard focus", async ({ page }) => {
await page.goto("/tools");
await page.locator("table tbody tr").first().getByRole("button", { name: "Readiness" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
await page.keyboard.press("Control+K");
await expect(page.getByRole("dialog")).toHaveCount(1);
await expect(page.getByRole("dialog", { name: "Quick jump" })).toHaveCount(0);
const close = dialog.getByRole("button", { name: "Close dialog" });
const readiness = dialog.getByRole("button", { name: "Run readiness check" });
await expect(readiness).toBeEnabled();
await close.focus();
await page.keyboard.press("Shift+Tab");
await expect(readiness).toBeFocused();
await page.keyboard.press("Tab");
await expect(close).toBeFocused();
await close.click();
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("");
});
test("Operations page shows an honest placeholder for approvals, not a fabricated queue", async ({ page }) => {
await page.goto("/operations");
await expect(page.getByRole("heading", { name: "Approvals" })).toBeVisible();
await expect(page.getByText(/Approval workflow is not yet part of the authorized v1 management surface/)).toBeVisible();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { can } from "../src/rbac";
describe("can", () => {
it("allows every role to read the dashboard", () => {
expect(can(["viewer"], "dashboard.read")).toBe(true);
expect(can(["auditor"], "dashboard.read")).toBe(true);
expect(can(["operator"], "dashboard.read")).toBe(true);
expect(can(["admin"], "dashboard.read")).toBe(true);
});
it("denies audit.read to a viewer but allows it for auditor and above", () => {
expect(can(["viewer"], "audit.read")).toBe(false);
expect(can(["auditor"], "audit.read")).toBe(true);
expect(can(["operator"], "audit.read")).toBe(true);
expect(can(["admin"], "audit.read")).toBe(true);
});
it("restricts client.disable and client.revoke to operator and admin", () => {
expect(can(["viewer"], "client.disable")).toBe(false);
expect(can(["auditor"], "client.disable")).toBe(false);
expect(can(["operator"], "client.disable")).toBe(true);
expect(can(["admin"], "client.disable")).toBe(true);
expect(can(["operator"], "client.revoke")).toBe(true);
expect(can(["viewer"], "client.revoke")).toBe(false);
});
it("restricts live tool readiness checks to operator and admin", () => {
expect(can(["viewer"], "tool.test")).toBe(false);
expect(can(["auditor"], "tool.test")).toBe(false);
expect(can(["operator"], "tool.test")).toBe(true);
expect(can(["admin"], "tool.test")).toBe(true);
});
it("restricts actual tool invocation and operational probes to operator and admin", () => {
for (const action of ["tool.invoke", "operations.check"] as const) {
expect(can(["viewer"], action)).toBe(false);
expect(can(["auditor"], action)).toBe(false);
expect(can(["operator"], action)).toBe(true);
expect(can(["admin"], action)).toBe(true);
}
});
it("restricts client.create and catalog.apply to admin only", () => {
expect(can(["operator"], "client.create")).toBe(false);
expect(can(["admin"], "client.create")).toBe(true);
expect(can(["operator"], "catalog.apply")).toBe(false);
expect(can(["admin"], "catalog.apply")).toBe(true);
});
it("allows policy.simulate and catalog.preview for every role, side-effect-free reads", () => {
expect(can(["viewer"], "policy.simulate")).toBe(true);
expect(can(["viewer"], "catalog.preview")).toBe(true);
});
it("returns false for an empty role list, never throws", () => {
expect(can([], "dashboard.read")).toBe(false);
});
it("is permissive if the principal holds any one sufficient role among several", () => {
expect(can(["viewer", "admin"], "client.create")).toBe(true);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"isolatedModules": true,
"noUncheckedIndexedAccess": true
},
"include": ["src", "tests", "vite.config.ts", "playwright.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
coverage: { reporter: ["text", "json-summary"] },
},
});