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.
+ ) : (
+
+
+
+
+ |
+
+ App ID |
+
+
+
+
+
+ {sorted.map(inst => (
+ setSelected(inst)}
+ >
+ |
+ {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 && (
+
+ )}
+
+
+ );
+}
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,
+};