diff --git a/azure/entra-id/AzureEntraIdPanel.jsx b/azure/entra-id/AzureEntraIdPanel.jsx new file mode 100644 index 0000000..4bec5b7 --- /dev/null +++ b/azure/entra-id/AzureEntraIdPanel.jsx @@ -0,0 +1,326 @@ +import { useCallback, useState, useEffect } from "react"; +import { + RefreshCw, X, Info, Tag, Check, + ChevronDown, ChevronRight, +} from "lucide-react"; +import { useData } from "../../hooks/useData"; +import { REFRESH_STREAM_TIMEOUT_MS } from "../../constants"; +import AlertBanner from "../../components/AlertBanner"; + +const API_BASE = process.env.REACT_APP_API_URL || ""; + +async function fetchPlugin(path, options = {}) { + const res = await fetch(`${API_BASE}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!res.ok) { + if (res.status === 401) window.dispatchEvent(new CustomEvent("session-expired")); + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || "Request failed"); + } + return res.json(); +} + +// account_enabled → pill colour +function enabledColor(enabled) { + return enabled ? "state-green" : "state-gray"; +} + +// ─── Utility components ────────────────────────────────────────────────────── + +function SortTh({ col, label, sort, onSort }) { + const active = sort.col === col; + return ( + onSort(col)} + style={{ color: active ? "var(--amber)" : undefined }} + > + {label}{" "} + + {active ? (sort.dir === "asc" ? "↑" : "↓") : "↕"} + + + ); +} + +function MetaItem({ label, value, mono }) { + return ( +
+ {label} + + {value ?? "—"} + +
+ ); +} + +// ─── Main Panel ────────────────────────────────────────────────────────────── + +export default function AzureEntraIdPanel() { + const fetcher = useCallback(() => fetchPlugin("/api/azure/entra-id/"), []); + const { data, loading, error, refresh, refreshing } = useData(fetcher); + const [selected, setSelected] = useState(null); + const [sort, setSort] = useState({ col: "name", dir: "asc" }); + const [syncing, setSyncing] = useState(false); + const [showRefreshed, setShowRefreshed] = useState(false); + const [resourceAlarms, setResourceAlarms] = useState([]); + + async function handleRefresh() { + setSyncing(true); + setShowRefreshed(false); + const streamUrl = `${API_BASE}/api/azure/entra-id/refresh/stream`; + const es = new EventSource(streamUrl); + const timeoutId = setTimeout(() => { + es.close(); + refresh(); + setSyncing(false); + }, REFRESH_STREAM_TIMEOUT_MS); + es.addEventListener("refresh_done", () => { + clearTimeout(timeoutId); + es.close(); + refresh(); + setSyncing(false); + setShowRefreshed(true); + setTimeout(() => setShowRefreshed(false), 1500); + }); + es.onerror = () => { + clearTimeout(timeoutId); + es.close(); + refresh(); + setSyncing(false); + }; + try { + await fetchPlugin("/api/azure/entra-id/refresh", { method: "POST" }); + } catch { + clearTimeout(timeoutId); + es.close(); + refresh(); + setSyncing(false); + } + } + + if (loading) return
Loading Entra ID…
; + if (error) return
Azure Entra ID: {error}
; + + const instances = data?.instances || []; + + const toggleSort = (col) => + setSort(s => + s.col === col + ? { col, dir: s.dir === "asc" ? "desc" : "asc" } + : { col, dir: "asc" }, + ); + + const sorted = [...instances].sort((a, b) => { + const d = sort.dir === "asc" ? 1 : -1; + const va = a[sort.col], vb = b[sort.col]; + if (typeof va === "boolean") { + // true first when asc + return d * ((va === vb ? 0 : va ? -1 : 1)); + } + if (typeof va === "string") return d * (va || "").localeCompare(vb || ""); + return d * ((va ?? 0) - (vb ?? 0)); + }); + + return ( +
+ +
+

+ Entra ID {instances.length} +

+
+ +
+
+ + {instances.length === 0 ? ( +
No Entra ID service principals found — run a collection first.
+ ) : ( +
+ + + + + + + + + + + + {sorted.map(inst => ( + setSelected(inst)} + > + + + + + + + ))} + +
App ID
+ {resourceAlarms.some( + a => a.resource_id === inst.name && a.state === "ALARM", + ) && ( + a.resource_id === inst.name && a.state === "ALARM").length} active alarm(s)`} + /> + )} + {inst.name} + {inst.app_id || "—"} + {inst.service_principal_type || "—"} + + {inst.account_enabled ? "Enabled" : "Disabled"} + +
+
+ )} + + {selected && ( + <> +
setSelected(null)} /> + setSelected(null)} /> + + )} +
+ ); +} + +// ─── Detail Drawer ──────────────────────────────────────────────────────────── + +function DetailDrawer({ instance, onClose }) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [openSections, setOpenSections] = useState({ + overview: true, + tags: false, + }); + + useEffect(() => { + setLoading(true); + setError(null); + setDetail(null); + fetchPlugin(`/api/azure/entra-id/instances/${encodeURIComponent(instance.app_id || instance.name)}`) + .then(setDetail) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, [instance.app_id, instance.name]); + + useEffect(() => { + const handler = (e) => { if (e.key === "Escape") onClose(); }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + const toggle = (s) => setOpenSections(p => ({ ...p, [s]: !p[s] })); + + function Section({ id, title, icon: Icon, children }) { + const open = openSections[id]; + return ( +
+
toggle(id)}> + {open ? : } + {Icon && } + {title} +
+ {open &&
{children}
} +
+ ); + } + + const d = detail || instance; + + return ( +
+
+
+
{instance.name}
+
+ + {d.account_enabled ? "Enabled" : "Disabled"} + + {d.service_principal_type && ( + + {d.service_principal_type} + + )} +
+
+ +
+ +
+ {loading &&
Loading details…
} + {error &&
{error}
} + + {/* ── Overview ── */} +
+
+ + + + {d.account_enabled ? "Enabled" : "Disabled"} + + } + /> +
+
+ + {/* ── Tags ── */} + {d.tags && Object.keys(d.tags).length > 0 && ( +
+
+ + + + + + {Object.entries(d.tags).map(([k, v]) => ( + + + + + ))} + +
KeyValue
{k}{v}
+
+
+ )} +
+
+ ); +} diff --git a/azure/entra-id/manifest.js b/azure/entra-id/manifest.js new file mode 100644 index 0000000..6601959 --- /dev/null +++ b/azure/entra-id/manifest.js @@ -0,0 +1,7 @@ +import AzureEntraIdPanel from "./AzureEntraIdPanel"; +import { Users } from "lucide-react"; + +export default { + component: AzureEntraIdPanel, + icon: Users, +};