Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 326 additions & 0 deletions azure/entra-id/AzureEntraIdPanel.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<th
className="th-sort"
onClick={() => onSort(col)}
style={{ color: active ? "var(--amber)" : undefined }}
>
{label}{" "}
<span style={{ opacity: active ? 1 : 0.3, fontSize: "0.65rem" }}>
{active ? (sort.dir === "asc" ? "↑" : "↓") : "↕"}
</span>
</th>
);
}

function MetaItem({ label, value, mono }) {
return (
<div className="drawer-meta-item">
<span className="drawer-meta-key">{label}</span>
<span
className={`drawer-meta-val${mono ? " cell-mono" : ""}`}
style={mono ? { fontSize: "0.72rem", wordBreak: "break-all" } : {}}
>
{value ?? "—"}
</span>
</div>
);
}

// ─── 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 <div className="panel-loading">Loading Entra ID…</div>;
if (error) return <div className="panel-error">Azure Entra ID: {error}</div>;

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 (
<section className="panel">
<AlertBanner serviceType="azure/entra-id" onAlarmsLoaded={setResourceAlarms} />
<div className="panel-header">
<h2>
Entra ID <span className="count-badge">{instances.length}</span>
</h2>
<div className="panel-header-actions">
<button
className="refresh-btn"
onClick={handleRefresh}
disabled={refreshing || syncing}
title="Sync from Azure and refresh"
>
{showRefreshed ? (
<span className="refresh-done"><Check size={13} /> Refreshed</span>
) : (
<RefreshCw size={13} className={refreshing || syncing ? "spinning" : ""} />
)}
</button>
</div>
</div>

{instances.length === 0 ? (
<div className="panel-empty">No Entra ID service principals found — run a collection first.</div>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th style={{ width: "32px" }}></th>
<SortTh col="name" label="Name" sort={sort} onSort={toggleSort} />
<th>App ID</th>
<SortTh col="service_principal_type" label="Type" sort={sort} onSort={toggleSort} />
<SortTh col="account_enabled" label="Enabled" sort={sort} onSort={toggleSort} />
</tr>
</thead>
<tbody>
{sorted.map(inst => (
<tr
key={inst.app_id || inst.name}
className={`row-clickable ${selected?.app_id === inst.app_id ? "row-selected" : ""}`}
onClick={() => setSelected(inst)}
>
<td>
{resourceAlarms.some(
a => a.resource_id === inst.name && a.state === "ALARM",
) && (
<span
className="alert-dot"
title={`${resourceAlarms.filter(a => a.resource_id === inst.name && a.state === "ALARM").length} active alarm(s)`}
/>
)}
</td>
<td className="cell-bold">{inst.name}</td>
<td className="cell-mono" style={{ fontSize: "0.75rem", color: "var(--text-muted)" }}>
{inst.app_id || "—"}
</td>
<td>{inst.service_principal_type || "—"}</td>
<td>
<span className={`state-pill ${enabledColor(inst.account_enabled)}`}>
{inst.account_enabled ? "Enabled" : "Disabled"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}

{selected && (
<>
<div className="drawer-backdrop" onClick={() => setSelected(null)} />
<DetailDrawer instance={selected} onClose={() => setSelected(null)} />
</>
)}
</section>
);
}

// ─── 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 (
<div className="drawer-section">
<div className="drawer-section-hdr" onClick={() => toggle(id)}>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{Icon && <Icon size={13} style={{ margin: "0 4px" }} />}
<span>{title}</span>
</div>
{open && <div className="drawer-section-body">{children}</div>}
</div>
);
}

const d = detail || instance;

return (
<div className="detail-drawer">
<div className="drawer-header">
<div>
<div className="drawer-title">{instance.name}</div>
<div className="drawer-subtitle" style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<span className={`state-pill ${enabledColor(d.account_enabled)}`}>
{d.account_enabled ? "Enabled" : "Disabled"}
</span>
{d.service_principal_type && (
<span style={{ color: "var(--text-muted)", fontSize: "0.75rem" }}>
{d.service_principal_type}
</span>
)}
</div>
</div>
<button className="drawer-close" onClick={onClose}><X size={16} /></button>
</div>

<div className="drawer-body">
{loading && <div className="panel-loading">Loading details…</div>}
{error && <div className="panel-error">{error}</div>}

{/* ── Overview ── */}
<Section id="overview" title="Overview" icon={Info}>
<div className="drawer-meta-grid">
<MetaItem
label="App ID"
value={d.app_id || "—"}
mono
/>
<MetaItem
label="Type"
value={d.service_principal_type || "—"}
/>
<MetaItem
label="Account Enabled"
value={
<span className={`state-pill ${enabledColor(d.account_enabled)}`}>
{d.account_enabled ? "Enabled" : "Disabled"}
</span>
}
/>
</div>
</Section>

{/* ── Tags ── */}
{d.tags && Object.keys(d.tags).length > 0 && (
<Section id="tags" title={`Tags (${Object.keys(d.tags).length})`} icon={Tag}>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr><th>Key</th><th>Value</th></tr>
</thead>
<tbody>
{Object.entries(d.tags).map(([k, v]) => (
<tr key={k}>
<td className="cell-mono" style={{ color: "var(--text-dim)" }}>{k}</td>
<td style={{ whiteSpace: "normal", wordBreak: "break-all" }}>{v}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
)}
</div>
</div>
);
}
7 changes: 7 additions & 0 deletions azure/entra-id/manifest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import AzureEntraIdPanel from "./AzureEntraIdPanel";
import { Users } from "lucide-react";

export default {
component: AzureEntraIdPanel,
icon: Users,
};