1104 lines
40 KiB
TypeScript
1104 lines
40 KiB
TypeScript
"use client";
|
||
|
||
import { useAuth } from "@/components/AuthProvider";
|
||
import { c2api } from "@/lib/c2api";
|
||
import { useEffect, useState, useRef, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared primitives
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface FeatureFlags {
|
||
stt_enabled: boolean;
|
||
correlation_enabled: boolean;
|
||
summaries_enabled: boolean;
|
||
vocabulary_learning_enabled: boolean;
|
||
}
|
||
|
||
const FLAG_META: { key: keyof FeatureFlags; label: string; description: string }[] = [
|
||
{
|
||
key: "stt_enabled",
|
||
label: "Speech-to-Text (Whisper)",
|
||
description: "Transcribe call audio via OpenAI Whisper. When off, calls are recorded and stored but no transcript is generated.",
|
||
},
|
||
{
|
||
key: "correlation_enabled",
|
||
label: "Incident Correlation",
|
||
description: "Run scene extraction and incident correlation on each call. When off, calls are logged but not linked to incidents.",
|
||
},
|
||
{
|
||
key: "summaries_enabled",
|
||
label: "Incident Summaries",
|
||
description: "Generate AI summaries for active incidents on each summarizer pass. Auto-resolve sweep is also paused when off.",
|
||
},
|
||
{
|
||
key: "vocabulary_learning_enabled",
|
||
label: "Vocabulary Learning",
|
||
description: "Run the background vocabulary induction loop that proposes new STT terms from recent transcripts.",
|
||
},
|
||
];
|
||
|
||
function Toggle({
|
||
enabled,
|
||
onChange,
|
||
disabled,
|
||
}: {
|
||
enabled: boolean;
|
||
onChange: (val: boolean) => void;
|
||
disabled: boolean;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={() => onChange(!enabled)}
|
||
disabled={disabled}
|
||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50 ${
|
||
enabled ? "bg-indigo-600" : "bg-gray-700"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||
enabled ? "translate-x-6" : "translate-x-1"
|
||
}`}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function fmtDate(iso: string | null | undefined) {
|
||
if (!iso) return "—";
|
||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||
}
|
||
|
||
function fmtDatetime(iso: string | null | undefined) {
|
||
if (!iso) return "—";
|
||
return new Date(iso).toLocaleString("en-US", {
|
||
month: "short", day: "numeric", year: "numeric",
|
||
hour: "numeric", minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
const ROLE_COLORS: Record<UserRole, string> = {
|
||
admin: "bg-indigo-900 text-indigo-300",
|
||
operator: "bg-green-900 text-green-300",
|
||
viewer: "bg-gray-800 text-gray-400",
|
||
};
|
||
|
||
function RoleBadge({ role }: { role: UserRole }) {
|
||
const labels: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
|
||
return (
|
||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ROLE_COLORS[role]}`}>
|
||
{labels[role]}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AI Features tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function FeaturesTab() {
|
||
const [flags, setFlags] = useState<FeatureFlags | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
c2api.getFeatureFlags()
|
||
.then((f) => setFlags(f as unknown as FeatureFlags))
|
||
.catch((e) => setError(String(e)))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
|
||
if (!flags) return;
|
||
setSaving(key);
|
||
setError(null);
|
||
try {
|
||
const updated = await c2api.setFeatureFlags({ [key]: value });
|
||
setFlags(updated as unknown as FeatureFlags);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setSaving(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="space-y-3">
|
||
{error && (
|
||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||
</div>
|
||
)}
|
||
{loading ? (
|
||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||
) : (
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||
{FLAG_META.map(({ key, label, description }) => (
|
||
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
||
<div className="min-w-0">
|
||
<p className="text-white text-sm font-semibold">{label}</p>
|
||
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
||
</div>
|
||
<Toggle
|
||
enabled={flags?.[key] ?? true}
|
||
onChange={(val) => handleToggle(key, val)}
|
||
disabled={saving === key}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Correlation Debug tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function CorrelationDebugTab() {
|
||
const [limit, setLimit] = useState(20);
|
||
const [orphanHours, setOrphanHours] = useState(48);
|
||
const [data, setData] = useState<unknown>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [copied, setCopied] = useState(false);
|
||
const preRef = useRef<HTMLPreElement>(null);
|
||
|
||
async function handleFetch() {
|
||
setLoading(true);
|
||
setError(null);
|
||
setData(null);
|
||
setCopied(false);
|
||
try {
|
||
const result = await c2api.getCorrelationDebug(limit, orphanHours);
|
||
setData(result);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function handleCopy() {
|
||
if (!data) return;
|
||
const text = JSON.stringify(data, null, 2);
|
||
if (navigator.clipboard) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
}).catch(() => fallbackCopy(text));
|
||
} else {
|
||
fallbackCopy(text);
|
||
}
|
||
}
|
||
|
||
function fallbackCopy(text: string) {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
ta.style.position = "fixed";
|
||
ta.style.opacity = "0";
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
document.execCommand("copy");
|
||
document.body.removeChild(ta);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
}
|
||
|
||
const json = data ? JSON.stringify(data, null, 2) : null;
|
||
const meta = data as {
|
||
incident_count?: number;
|
||
orphaned_call_count?: number;
|
||
generated_at?: string;
|
||
incidents?: Array<{ calls_detail?: Array<{ corr_path?: string; corr_fit_signal?: string }> }>;
|
||
orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
|
||
} | null;
|
||
|
||
const pathCounts: Record<string, number> = {};
|
||
const signalCounts: Record<string, number> = {};
|
||
if (meta?.incidents) {
|
||
for (const inc of meta.incidents) {
|
||
for (const call of inc.calls_detail ?? []) {
|
||
const p = call.corr_path ?? "unknown";
|
||
pathCounts[p] = (pathCounts[p] ?? 0) + 1;
|
||
if (call.corr_fit_signal) {
|
||
signalCounts[call.corr_fit_signal] = (signalCounts[call.corr_fit_signal] ?? 0) + 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-gray-500 font-mono">
|
||
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
|
||
Copy the JSON and paste it to Claude to diagnose correlation problems.
|
||
</p>
|
||
|
||
<div className="flex flex-wrap items-end gap-4">
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Incidents (limit)</label>
|
||
<input
|
||
type="number"
|
||
min={1} max={100}
|
||
value={limit}
|
||
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
|
||
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Orphan scan window (hours)</label>
|
||
<input
|
||
type="number"
|
||
min={1} max={168}
|
||
value={orphanHours}
|
||
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
|
||
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={handleFetch}
|
||
disabled={loading}
|
||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||
>
|
||
{loading ? "Fetching…" : "Fetch"}
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{meta && (
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-gray-500 font-mono">
|
||
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
|
||
</p>
|
||
<button
|
||
onClick={handleCopy}
|
||
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-gray-700 bg-gray-900 text-gray-300 hover:text-white transition-colors"
|
||
>
|
||
{copied ? "Copied!" : "Copy JSON"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{meta && Object.keys(pathCounts).length > 0 && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">corr_path distribution</p>
|
||
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
|
||
<div key={path} className="flex justify-between text-xs font-mono">
|
||
<span className="text-gray-300">{path}</span>
|
||
<span className="text-indigo-400">{n}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">fit_signal distribution</p>
|
||
{Object.keys(signalCounts).length === 0
|
||
? <p className="text-xs text-gray-600 font-mono">No signal data yet — deploy correlator update first</p>
|
||
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
|
||
<div key={sig} className="flex justify-between text-xs font-mono">
|
||
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-gray-300"}>{sig}</span>
|
||
<span className="text-indigo-400">{n}</span>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">orphans by talkgroup</p>
|
||
{meta.orphans_by_talkgroup.map((tg) => (
|
||
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
|
||
<span className="text-gray-300">{tg.talkgroup_name} <span className="text-gray-600">({tg.talkgroup_id})</span></span>
|
||
<span className="text-gray-400">
|
||
{tg.count} total · <span className="text-gray-500">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{json && (
|
||
<pre
|
||
ref={preRef}
|
||
className="bg-gray-950 border border-gray-800 rounded-xl p-4 text-xs text-gray-300 font-mono overflow-auto max-h-[60vh] whitespace-pre"
|
||
>
|
||
{json}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// User detail panel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function UserDetailPanel({
|
||
user,
|
||
onClose,
|
||
onUpdated,
|
||
currentUid,
|
||
}: {
|
||
user: UserRecord;
|
||
onClose: () => void;
|
||
onUpdated: (u: UserRecord) => void;
|
||
currentUid: string;
|
||
}) {
|
||
const [detail, setDetail] = useState<UserRecord>(user);
|
||
const [editRole, setEditRole] = useState<UserRole>(user.role);
|
||
const [editNodes, setEditNodes] = useState<string>(user.owned_node_ids.join(", "));
|
||
const [editName, setEditName] = useState<string>(user.display_name ?? "");
|
||
const [saving, setSaving] = useState(false);
|
||
const [toggling, setToggling] = useState(false);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showSessions, setShowSessions] = useState(false);
|
||
|
||
// Fetch full detail (sessions) lazily
|
||
useEffect(() => {
|
||
c2api.getUser(user.uid)
|
||
.then((d) => setDetail(d))
|
||
.catch(() => {});
|
||
}, [user.uid]);
|
||
|
||
async function handleSave() {
|
||
setSaving(true);
|
||
setError(null);
|
||
const nodes = editRole === "operator"
|
||
? editNodes.split(",").map((s) => s.trim()).filter(Boolean)
|
||
: [];
|
||
try {
|
||
const updated = await c2api.updateUser(user.uid, {
|
||
role: editRole,
|
||
owned_node_ids: nodes,
|
||
display_name: editName || undefined,
|
||
});
|
||
onUpdated(updated);
|
||
setDetail((d) => ({ ...d, ...updated }));
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleToggleDisabled() {
|
||
setToggling(true);
|
||
setError(null);
|
||
try {
|
||
if (detail.disabled) {
|
||
await c2api.enableUser(user.uid);
|
||
} else {
|
||
await c2api.disableUser(user.uid);
|
||
}
|
||
const next = { ...detail, disabled: !detail.disabled };
|
||
setDetail(next);
|
||
onUpdated(next);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setToggling(false);
|
||
}
|
||
}
|
||
|
||
async function handleDelete() {
|
||
if (!confirm(`Permanently delete ${detail.email}? This cannot be undone.`)) return;
|
||
setDeleting(true);
|
||
setError(null);
|
||
try {
|
||
await c2api.deleteUser(user.uid);
|
||
onUpdated({ ...detail, uid: "__deleted__" });
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setDeleting(false);
|
||
}
|
||
}
|
||
|
||
const isSelf = user.uid === currentUid;
|
||
|
||
return (
|
||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-white font-semibold">{detail.email}</p>
|
||
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
|
||
</div>
|
||
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||
<p className="text-red-400 text-xs">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
|
||
<input
|
||
value={editName}
|
||
onChange={(e) => setEditName(e.target.value)}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
placeholder="Full name"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||
<select
|
||
value={editRole}
|
||
onChange={(e) => setEditRole(e.target.value as UserRole)}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
>
|
||
<option value="admin">Admin — full access</option>
|
||
<option value="operator">Operator — owns nodes</option>
|
||
<option value="viewer">Viewer — read-only</option>
|
||
</select>
|
||
</div>
|
||
|
||
{editRole === "operator" && (
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">
|
||
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
||
</label>
|
||
<input
|
||
value={editNodes}
|
||
onChange={(e) => setEditNodes(e.target.value)}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||
placeholder="node-abc123, node-def456"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
|
||
>
|
||
{saving ? "Saving…" : "Save changes"}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">Status</span>
|
||
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
||
{detail.disabled ? "Disabled" : "Active"}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">Discord</span>
|
||
<span className="text-gray-300">
|
||
{detail.discord_linked
|
||
? `@${detail.discord_username ?? detail.discord_user_id}`
|
||
: "Not linked"}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">Created</span>
|
||
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">Last sign-in</span>
|
||
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{(detail.sessions?.length ?? 0) > 0 && (
|
||
<div className="border-t border-gray-800 pt-4">
|
||
<button
|
||
onClick={() => setShowSessions((v) => !v)}
|
||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
|
||
>
|
||
<span>{showSessions ? "▲" : "▼"}</span>
|
||
<span>Login history ({detail.sessions?.length} recent)</span>
|
||
</button>
|
||
{showSessions && (
|
||
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
|
||
{detail.sessions?.map((s) => (
|
||
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
|
||
<span>{fmtDatetime(s.timestamp)}</span>
|
||
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
|
||
{!isSelf ? (
|
||
<>
|
||
<button
|
||
onClick={handleToggleDisabled}
|
||
disabled={toggling}
|
||
className="text-xs text-yellow-500 hover:text-yellow-400 disabled:opacity-50 transition-colors"
|
||
>
|
||
{toggling ? "…" : detail.disabled ? "Enable account" : "Disable account"}
|
||
</button>
|
||
<button
|
||
onClick={handleDelete}
|
||
disabled={deleting}
|
||
className="text-xs text-red-500 hover:text-red-400 disabled:opacity-50 transition-colors"
|
||
>
|
||
{deleting ? "Deleting…" : "Delete user"}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Create User modal
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function CreateUserModal({
|
||
onClose,
|
||
onCreated,
|
||
}: {
|
||
onClose: () => void;
|
||
onCreated: (u: UserRecord) => void;
|
||
}) {
|
||
const [email, setEmail] = useState("");
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [role, setRole] = useState<UserRole>("viewer");
|
||
const [nodeIds, setNodeIds] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
setError(null);
|
||
const owned_node_ids = role === "operator"
|
||
? nodeIds.split(",").map((s) => s.trim()).filter(Boolean)
|
||
: [];
|
||
try {
|
||
const created = await c2api.createUser({
|
||
email,
|
||
role,
|
||
display_name: displayName || undefined,
|
||
owned_node_ids,
|
||
});
|
||
onCreated(created);
|
||
if (created.invite_link) {
|
||
setInviteLink(created.invite_link);
|
||
} else {
|
||
onClose();
|
||
}
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
function copyLink() {
|
||
if (!inviteLink) return;
|
||
navigator.clipboard?.writeText(inviteLink).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
});
|
||
}
|
||
|
||
if (inviteLink) {
|
||
return (
|
||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
|
||
<h2 className="text-white font-semibold">User Created</h2>
|
||
<p className="text-xs text-gray-400">
|
||
Share this one-time invite link with the new user so they can set their password.
|
||
It expires after use.
|
||
</p>
|
||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={copyLink}
|
||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||
>
|
||
{copied ? "Copied!" : "Copy link"}
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg py-2 text-sm transition-colors"
|
||
>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
|
||
<h2 className="text-white font-semibold mb-4">Create User</h2>
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||
<input
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
required
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
placeholder="user@example.com"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">
|
||
Display Name <span className="text-gray-600">(optional)</span>
|
||
</label>
|
||
<input
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
placeholder="Jane Smith"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||
<select
|
||
value={role}
|
||
onChange={(e) => setRole(e.target.value as UserRole)}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
>
|
||
<option value="admin">Admin — full access</option>
|
||
<option value="operator">Operator — owns nodes</option>
|
||
<option value="viewer">Viewer — read-only</option>
|
||
</select>
|
||
</div>
|
||
{role === "operator" && (
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">
|
||
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
||
</label>
|
||
<input
|
||
value={nodeIds}
|
||
onChange={(e) => setNodeIds(e.target.value)}
|
||
required
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||
placeholder="node-abc123, node-def456"
|
||
/>
|
||
</div>
|
||
)}
|
||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||
<div className="flex gap-3 pt-1">
|
||
<button
|
||
type="submit"
|
||
disabled={saving}
|
||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||
>
|
||
{saving ? "Creating…" : "Create user"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Users tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function UsersTab({ currentUid }: { currentUid: string }) {
|
||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selectedUid, setSelectedUid] = useState<string | null>(null);
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
|
||
const loadUsers = useCallback(async () => {
|
||
try {
|
||
const data = await c2api.listUsers();
|
||
setUsers(data);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadUsers(); }, [loadUsers]);
|
||
|
||
function handleUpdated(updated: UserRecord) {
|
||
if (updated.uid === "__deleted__") {
|
||
setUsers((prev) => prev.filter((u) => u.uid !== selectedUid));
|
||
setSelectedUid(null);
|
||
} else {
|
||
setUsers((prev) => prev.map((u) => u.uid === updated.uid ? { ...u, ...updated } : u));
|
||
}
|
||
}
|
||
|
||
function handleCreated(created: UserRecord) {
|
||
setUsers((prev) => [...prev, created]);
|
||
}
|
||
|
||
const selectedUser = users.find((u) => u.uid === selectedUid);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{showCreate && (
|
||
<CreateUserModal
|
||
onClose={() => setShowCreate(false)}
|
||
onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
|
||
/>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
|
||
<button
|
||
onClick={() => setShowCreate(true)}
|
||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||
>
|
||
+ Create user
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||
) : users.length === 0 ? (
|
||
<p className="text-gray-600 text-sm font-mono">No users found.</p>
|
||
) : (
|
||
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
||
<table className="w-full text-xs font-mono">
|
||
<thead>
|
||
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
||
<th className="px-4 py-2.5 text-left">Email</th>
|
||
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
|
||
<th className="px-4 py-2.5 text-left">Role</th>
|
||
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Discord</th>
|
||
<th className="px-4 py-2.5 text-left hidden md:table-cell">Last sign-in</th>
|
||
<th className="px-4 py-2.5 text-left">Status</th>
|
||
<th className="px-4 py-2.5 w-16"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((u) => (
|
||
<tr
|
||
key={u.uid}
|
||
className={`border-t border-gray-800 transition-colors ${
|
||
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
|
||
}`}
|
||
>
|
||
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
|
||
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
|
||
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
|
||
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
|
||
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
|
||
<td className="px-4 py-2.5">
|
||
{u.disabled
|
||
? <span className="text-red-500">Disabled</span>
|
||
: <span className="text-green-500">Active</span>
|
||
}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-right">
|
||
<button
|
||
onClick={() => setSelectedUid(selectedUid === u.uid ? null : u.uid)}
|
||
className="text-indigo-400 hover:text-indigo-300 transition-colors"
|
||
>
|
||
{selectedUid === u.uid ? "Close" : "Edit"}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{selectedUser && (
|
||
<UserDetailPanel
|
||
user={selectedUser}
|
||
onClose={() => setSelectedUid(null)}
|
||
onUpdated={handleUpdated}
|
||
currentUid={currentUid}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Audit Log tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function AuditLogTab() {
|
||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [hasMore, setHasMore] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const PAGE = 50;
|
||
|
||
useEffect(() => {
|
||
c2api.getAuditLog(PAGE, 0)
|
||
.then((data) => {
|
||
setEntries(data);
|
||
setHasMore(data.length === PAGE);
|
||
})
|
||
.catch((e) => setError(String(e)))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
async function loadMore() {
|
||
setLoadingMore(true);
|
||
try {
|
||
const more = await c2api.getAuditLog(PAGE, entries.length);
|
||
setEntries((prev) => [...prev, ...more]);
|
||
setHasMore(more.length === PAGE);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setLoadingMore(false);
|
||
}
|
||
}
|
||
|
||
function actionColor(action: string) {
|
||
if (action.includes("delete")) return "text-red-400";
|
||
if (action.includes("disable")) return "text-yellow-400";
|
||
if (action.includes("create")) return "text-green-400";
|
||
return "text-indigo-400";
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{error && (
|
||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||
) : entries.length === 0 ? (
|
||
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
|
||
) : (
|
||
<>
|
||
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
||
<table className="w-full text-xs font-mono">
|
||
<thead>
|
||
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
||
<th className="px-4 py-2.5 text-left">Time</th>
|
||
<th className="px-4 py-2.5 text-left">Action</th>
|
||
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
|
||
<th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
|
||
<th className="px-4 py-2.5 text-left">Details</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{entries.map((e) => (
|
||
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
|
||
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
|
||
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
|
||
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
|
||
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
||
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
|
||
{Object.keys(e.details).length > 0
|
||
? Object.entries(e.details)
|
||
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
||
.join(" · ")
|
||
: "—"}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{hasMore && (
|
||
<button
|
||
onClick={loadMore}
|
||
disabled={loadingMore}
|
||
className="text-sm font-mono text-indigo-400 hover:text-indigo-300 disabled:opacity-50 transition-colors"
|
||
>
|
||
{loadingMore ? "Loading…" : "Load more"}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stale Calls tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function StaleCallsTab() {
|
||
const [minutes, setMinutes] = useState(30);
|
||
const [result, setResult] = useState<{ dry_run: boolean; count: number; call_ids: string[] } | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function run(dryRun: boolean) {
|
||
setLoading(true);
|
||
setError(null);
|
||
setResult(null);
|
||
try {
|
||
const res = await c2api.closeStallCalls(minutes, dryRun);
|
||
setResult(res);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<p className="text-xs text-gray-500 font-mono">
|
||
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
|
||
Preview first, then close.
|
||
</p>
|
||
|
||
<div className="flex flex-wrap items-end gap-4">
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
|
||
<input
|
||
type="number"
|
||
min={1} max={1440}
|
||
value={minutes}
|
||
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
|
||
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => run(true)}
|
||
disabled={loading}
|
||
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||
>
|
||
{loading ? "Working…" : "Preview"}
|
||
</button>
|
||
<button
|
||
onClick={() => run(false)}
|
||
disabled={loading || result === null || result.count === 0}
|
||
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||
>
|
||
{result && !result.dry_run ? "Closed" : result?.count ? `Close ${result.count} calls` : "Close calls"}
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{result && (
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
|
||
<p className="text-sm font-mono text-white">
|
||
{result.dry_run ? "Preview: " : "Closed: "}
|
||
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
||
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
||
</span>
|
||
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>}
|
||
</p>
|
||
{result.call_ids.length > 0 && (
|
||
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
||
{result.call_ids.map((id) => (
|
||
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main admin page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls";
|
||
|
||
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||
{ key: "features", label: "AI Features" },
|
||
{ key: "correlation", label: "Correlation Debug" },
|
||
{ key: "calls", label: "Calls" },
|
||
{ key: "users", label: "Users" },
|
||
{ key: "audit", label: "Audit Log" },
|
||
];
|
||
|
||
export default function AdminPage() {
|
||
const { user, isAdmin } = useAuth();
|
||
const router = useRouter();
|
||
const [tab, setTab] = useState<AdminTab>("features");
|
||
|
||
useEffect(() => {
|
||
if (!isAdmin) router.replace("/dashboard");
|
||
}, [isAdmin, router]);
|
||
|
||
if (!isAdmin) return null;
|
||
|
||
// Users/Audit tabs benefit from full width; everything else is narrow
|
||
const wide = tab === "users" || tab === "audit";
|
||
|
||
return (
|
||
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
||
|
||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||
{TAB_LABELS.map(({ key, label }) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => setTab(key)}
|
||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
|
||
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === "features" && <FeaturesTab />}
|
||
{tab === "correlation" && <CorrelationDebugTab />}
|
||
{tab === "calls" && <StaleCallsTab />}
|
||
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||
{tab === "audit" && <AuditLogTab />}
|
||
</div>
|
||
);
|
||
}
|