From 1f17b6c0d293959c7f82e7e1a4204533c595ad13 Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 22 Jun 2026 00:02:09 -0400 Subject: [PATCH] feat: add role-based user management, audit log, and session tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a full user management system with three roles (admin, operator, viewer), an audit log, and per-session login history. Backend: - app/internal/audit.py: write_audit() helper → audit_log Firestore collection - app/internal/auth.py: get_role() helper; require_admin_token accepts both legacy admin:true claim and new role:"admin" claim for backward compat - app/routers/users.py: CRUD under /admin/users — list, create (returns one-time invite link), get (with sessions), patch role/nodes/name, disable, enable, delete; operator role requires ≥1 owned node - app/routers/links.py: POST /auth/session records sign-in events to user_sessions Firestore collection - app/routers/admin.py: GET /admin/audit paginated endpoint - app/main.py: register users router Frontend: - AuthProvider: exposes role, isAdmin, isOperator, ownedNodeIds from claims - Nav: role-gated links — viewers get dashboard/calls/incidents/map/alerts/ trips; operators add nodes/systems/tokens; admins add admin - admin/page.tsx: new Users tab (list table, create modal, inline edit panel with role/nodes editor, disable/enable/delete, login history) and Audit Log tab (paginated, color-coded actions) - login/page.tsx: calls recordSession() on email and Google sign-in - nodes, systems, tokens pages: role guards redirect viewers to dashboard - profile/page.tsx: shows accurate role badge and label - lib/types.ts: UserRole, UserRecord, UserSession, AuditEntry types - lib/c2api.ts: user management methods + recordSession Firestore collections added: user_profiles, audit_log, user_sessions Firebase custom claims schema: { role, owned_node_ids, admin (legacy) } --- drb-c2-core/app/internal/audit.py | 26 + drb-c2-core/app/internal/auth.py | 22 +- drb-c2-core/app/main.py | 3 +- drb-c2-core/app/routers/admin.py | 13 +- drb-c2-core/app/routers/links.py | 26 +- drb-c2-core/app/routers/users.py | 308 +++++++++ drb-frontend/app/admin/page.tsx | 823 +++++++++++++++++++---- drb-frontend/app/login/page.tsx | 3 + drb-frontend/app/nodes/page.tsx | 12 +- drb-frontend/app/profile/page.tsx | 14 +- drb-frontend/app/systems/page.tsx | 12 +- drb-frontend/app/tokens/page.tsx | 8 +- drb-frontend/components/AuthProvider.tsx | 34 +- drb-frontend/components/Nav.tsx | 36 +- drb-frontend/lib/c2api.ts | 30 + drb-frontend/lib/types.ts | 39 ++ 16 files changed, 1261 insertions(+), 148 deletions(-) create mode 100644 drb-c2-core/app/internal/audit.py create mode 100644 drb-c2-core/app/routers/users.py diff --git a/drb-c2-core/app/internal/audit.py b/drb-c2-core/app/internal/audit.py new file mode 100644 index 0000000..c4e6387 --- /dev/null +++ b/drb-c2-core/app/internal/audit.py @@ -0,0 +1,26 @@ +from datetime import datetime, timezone +from typing import Optional +from uuid import uuid4 +from app.internal import firestore as fstore + + +async def write_audit( + actor_uid: str, + actor_email: str, + action: str, + target_uid: Optional[str] = None, + target_email: Optional[str] = None, + details: Optional[dict] = None, +) -> None: + """Write an entry to the audit_log collection.""" + doc_id = str(uuid4()) + await fstore.doc_set("audit_log", doc_id, { + "log_id": doc_id, + "action": action, + "actor_uid": actor_uid, + "actor_email": actor_email, + "target_uid": target_uid, + "target_email": target_email, + "details": details or {}, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, merge=False) diff --git a/drb-c2-core/app/internal/auth.py b/drb-c2-core/app/internal/auth.py index 10d0cdb..c662759 100644 --- a/drb-c2-core/app/internal/auth.py +++ b/drb-c2-core/app/internal/auth.py @@ -37,12 +37,28 @@ async def require_service_or_firebase_token( raise HTTPException(status_code=401, detail="Invalid or expired token") +def get_role(decoded: dict) -> str: + """Extract the effective role from a decoded Firebase token. + + Checks the granular ``role`` claim first, then falls back to the legacy + ``admin`` boolean so existing tokens continue to work during the transition. + """ + if decoded.get("role") == "admin" or decoded.get("admin"): + return "admin" + role = decoded.get("role", "viewer") + return role if role in ("admin", "operator", "viewer") else "viewer" + + async def require_admin_token( credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), ) -> dict: - """Verify a Firebase ID token AND require the admin custom claim.""" + """Verify a Firebase ID token AND require the admin role. + + Accepts both the legacy ``admin: True`` boolean claim and the newer + ``role: "admin"`` claim so tokens issued before the role migration still work. + """ decoded = await require_firebase_token(credentials) - if not decoded.get("admin"): + if get_role(decoded) != "admin": raise HTTPException(status_code=403, detail="Admin access required") return decoded @@ -77,7 +93,7 @@ async def require_service_key_or_admin( decoded = firebase_auth.verify_id_token(token) except Exception: raise HTTPException(status_code=401, detail="Invalid or expired token") - if not decoded.get("admin"): + if get_role(decoded) != "admin": raise HTTPException(status_code=403, detail="Admin access required") return decoded diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index 1f7b809..07087f9 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop from app.internal.recorrelation_sweep import recorrelation_loop from app.config import settings from app.internal.auth import require_firebase_token, require_service_or_firebase_token -from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links +from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.internal import firestore as fstore @@ -72,6 +72,7 @@ app.include_router(trips.router, dependencies=[Depends(require_service_or_fi app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(upload.router) # auth is per-node, handled inline app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) +app.include_router(users.router) # auth: admin only app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key) diff --git a/drb-c2-core/app/routers/admin.py b/drb-c2-core/app/routers/admin.py index 8ff8748..307d1f3 100644 --- a/drb-c2-core/app/routers/admin.py +++ b/drb-c2-core/app/routers/admin.py @@ -5,7 +5,6 @@ from app.internal.auth import require_admin_token, require_firebase_token from app.internal.feature_flags import get_flags, set_flags from app.internal import firestore as fstore - async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]: """Return system_ids where at least one AI function (STT or correlation) is effectively on.""" global_stt = global_flags.get("stt_enabled", True) @@ -163,3 +162,15 @@ async def debug_correlation( "incidents": incident_records, "orphaned_calls": orphans[:250], } + + +@router.get("/audit") +async def get_audit_log( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + _=Depends(require_admin_token), +): + """Return paginated audit log entries, most recent first.""" + entries = await fstore.collection_list("audit_log") + entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True) + return entries[offset: offset + limit] diff --git a/drb-c2-core/app/routers/links.py b/drb-c2-core/app/routers/links.py index 7d9e466..d9340d4 100644 --- a/drb-c2-core/app/routers/links.py +++ b/drb-c2-core/app/routers/links.py @@ -1,7 +1,8 @@ import random import string from datetime import datetime, timezone, timedelta -from fastapi import APIRouter, HTTPException, Depends +from uuid import uuid4 +from fastapi import APIRouter, HTTPException, Depends, Request from pydantic import BaseModel from app.internal import firestore as fstore from app.internal.auth import require_firebase_token, require_service_key @@ -126,3 +127,26 @@ async def unlink(decoded: dict = Depends(require_firebase_token)): await fstore.doc_delete("discord_links", discord_user_id) await fstore.doc_delete("firebase_discord_links", firebase_uid) return {"ok": True} + + +# --------------------------------------------------------------------------- +# Session recording — called by the frontend on each successful sign-in +# --------------------------------------------------------------------------- + +@router.post("/session") +async def record_session(request: Request, decoded: dict = Depends(require_firebase_token)): + """Record a sign-in event for the authenticated user.""" + session_id = str(uuid4()) + ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent", "") + + await fstore.doc_set("user_sessions", session_id, { + "session_id": session_id, + "uid": decoded["uid"], + "email": decoded.get("email", ""), + "timestamp": datetime.now(timezone.utc).isoformat(), + "ip": ip, + "user_agent": user_agent, + }, merge=False) + + return {"ok": True} diff --git a/drb-c2-core/app/routers/users.py b/drb-c2-core/app/routers/users.py new file mode 100644 index 0000000..0cbcbcd --- /dev/null +++ b/drb-c2-core/app/routers/users.py @@ -0,0 +1,308 @@ +import asyncio +from datetime import datetime, timezone +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends, Query +from pydantic import BaseModel +from firebase_admin import auth as firebase_auth +from app.internal.auth import require_admin_token +from app.internal import firestore as fstore +from app.internal import audit + +router = APIRouter(prefix="/admin/users", tags=["users"]) + +VALID_ROLES = {"admin", "operator", "viewer"} + + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- + +class UserCreate(BaseModel): + email: str + role: str = "viewer" + display_name: Optional[str] = None + owned_node_ids: list[str] = [] + + +class UserUpdate(BaseModel): + role: Optional[str] = None + owned_node_ids: Optional[list[str]] = None + display_name: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _ms_to_iso(ms: Optional[int]) -> Optional[str]: + if ms is None: + return None + return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat() + + +def _extract_role_nodes(fb_user: firebase_auth.UserRecord) -> tuple[str, list[str]]: + claims = fb_user.custom_claims or {} + if claims.get("role") == "admin" or claims.get("admin"): + role = "admin" + else: + role = claims.get("role", "viewer") + if role not in VALID_ROLES: + role = "viewer" + owned_node_ids = claims.get("owned_node_ids") or [] + return role, owned_node_ids + + +def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None) -> dict: + role, owned_node_ids = _extract_role_nodes(fb_user) + return { + "uid": fb_user.uid, + "email": fb_user.email, + "display_name": fb_user.display_name, + "role": role, + "owned_node_ids": owned_node_ids, + "disabled": fb_user.disabled, + "creation_time": _ms_to_iso(fb_user.user_metadata.creation_timestamp), + "last_sign_in": _ms_to_iso(fb_user.user_metadata.last_sign_in_timestamp), + "discord_linked": bool(link and link.get("discord_user_id")), + "discord_username": link.get("discord_username") if link else None, + "discord_user_id": link.get("discord_user_id") if link else None, + } + + +def _list_fb_users() -> list[firebase_auth.UserRecord]: + users: list[firebase_auth.UserRecord] = [] + page = firebase_auth.list_users() + while page: + users.extend(page.users) + page = page.get_next_page() + return users + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("") +async def list_users(decoded: dict = Depends(require_admin_token)): + """List all Firebase Auth users with role, node ownership, and Discord link status.""" + fb_users = await asyncio.to_thread(_list_fb_users) + + links: list[Optional[dict]] = await asyncio.gather(*[ + fstore.doc_get("firebase_discord_links", u.uid) for u in fb_users + ]) + + return [_format_user(u, lnk) for u, lnk in zip(fb_users, links)] + + +@router.post("") +async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_token)): + """Create a new Firebase Auth user and set their role. Returns a one-time invite link.""" + if body.role not in VALID_ROLES: + raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}") + if body.role == "operator" and not body.owned_node_ids: + raise HTTPException(400, "Operator role requires at least one owned node.") + + try: + fb_user: firebase_auth.UserRecord = await asyncio.to_thread( + firebase_auth.create_user, + email=body.email, + display_name=body.display_name or "", + email_verified=False, + ) + except firebase_auth.EmailAlreadyExistsError: + raise HTTPException(409, "A user with this email already exists.") + except Exception as e: + raise HTTPException(400, f"Failed to create user: {e}") + + # Set custom claims + claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids} + if body.role == "admin": + claims["admin"] = True + await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims) + + # Write Firestore profile + now = datetime.now(timezone.utc).isoformat() + await fstore.doc_set("user_profiles", fb_user.uid, { + "uid": fb_user.uid, + "email": body.email, + "display_name": body.display_name or "", + "role": body.role, + "owned_node_ids": body.owned_node_ids, + "created_by_uid": decoded["uid"], + "created_at": now, + }, merge=False) + + # Generate a one-time invite/password-reset link + invite_link: Optional[str] = None + try: + invite_link = await asyncio.to_thread(firebase_auth.generate_password_reset_link, body.email) + except Exception: + pass + + await audit.write_audit( + actor_uid=decoded["uid"], + actor_email=decoded.get("email", ""), + action="user.create", + target_uid=fb_user.uid, + target_email=body.email, + details={"role": body.role, "owned_node_ids": body.owned_node_ids}, + ) + + return {**_format_user(fb_user), "invite_link": invite_link} + + +@router.get("/{uid}") +async def get_user(uid: str, decoded: dict = Depends(require_admin_token)): + """Get a single user with full detail, including recent sessions.""" + try: + fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) + except firebase_auth.UserNotFoundError: + raise HTTPException(404, "User not found.") + + link, raw_sessions = await asyncio.gather( + fstore.doc_get("firebase_discord_links", uid), + fstore.collection_where("user_sessions", [("uid", "==", uid)]), + ) + + raw_sessions.sort(key=lambda s: s.get("timestamp", ""), reverse=True) + + return { + **_format_user(fb_user, link), + "sessions": raw_sessions[:20], + } + + +@router.patch("/{uid}") +async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(require_admin_token)): + """Update a user's role, owned nodes, or display name.""" + try: + fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) + except firebase_auth.UserNotFoundError: + raise HTTPException(404, "User not found.") + + current_role, current_nodes = _extract_role_nodes(fb_user) + new_role = body.role if body.role is not None else current_role + new_nodes = body.owned_node_ids if body.owned_node_ids is not None else current_nodes + + if new_role not in VALID_ROLES: + raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}") + if new_role == "operator" and not new_nodes: + raise HTTPException(400, "Operator role requires at least one owned node.") + + # Merge with existing claims (preserve any other claims already set) + existing_claims: dict = dict(fb_user.custom_claims or {}) + new_claims = {**existing_claims, "role": new_role, "owned_node_ids": new_nodes} + if new_role == "admin": + new_claims["admin"] = True + else: + new_claims.pop("admin", None) + + await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims) + + if body.display_name is not None: + await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name) + + profile_data: dict = {"uid": uid, "role": new_role, "owned_node_ids": new_nodes} + if body.display_name is not None: + profile_data["display_name"] = body.display_name + await fstore.doc_set("user_profiles", uid, profile_data, merge=True) + + await audit.write_audit( + actor_uid=decoded["uid"], + actor_email=decoded.get("email", ""), + action="user.update", + target_uid=uid, + target_email=fb_user.email, + details={ + "old_role": current_role, + "new_role": new_role, + "old_nodes": current_nodes, + "new_nodes": new_nodes, + }, + ) + + updated: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) + link = await fstore.doc_get("firebase_discord_links", uid) + return _format_user(updated, link) + + +@router.post("/{uid}/disable") +async def disable_user(uid: str, decoded: dict = Depends(require_admin_token)): + """Disable a user — they can no longer sign in but their data is preserved.""" + if uid == decoded.get("uid"): + raise HTTPException(400, "Cannot disable your own account.") + try: + fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) + except firebase_auth.UserNotFoundError: + raise HTTPException(404, "User not found.") + + await asyncio.to_thread(firebase_auth.update_user, uid, disabled=True) + + await audit.write_audit( + actor_uid=decoded["uid"], + actor_email=decoded.get("email", ""), + action="user.disable", + target_uid=uid, + target_email=fb_user.email, + ) + + return {"ok": True} + + +@router.post("/{uid}/enable") +async def enable_user(uid: str, decoded: dict = Depends(require_admin_token)): + """Re-enable a previously disabled user.""" + try: + fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) + except firebase_auth.UserNotFoundError: + raise HTTPException(404, "User not found.") + + await asyncio.to_thread(firebase_auth.update_user, uid, disabled=False) + + await audit.write_audit( + actor_uid=decoded["uid"], + actor_email=decoded.get("email", ""), + action="user.enable", + target_uid=uid, + target_email=fb_user.email, + ) + + return {"ok": True} + + +@router.delete("/{uid}") +async def delete_user(uid: str, decoded: dict = Depends(require_admin_token)): + """Permanently delete a user from Firebase Auth and clean up Firestore data.""" + if uid == decoded.get("uid"): + raise HTTPException(400, "Cannot delete your own account.") + + try: + fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) + except firebase_auth.UserNotFoundError: + raise HTTPException(404, "User not found.") + + email = fb_user.email + + # Clean up Discord link if present + link = await fstore.doc_get("firebase_discord_links", uid) + if link and link.get("discord_user_id"): + await asyncio.gather( + fstore.doc_delete("discord_links", link["discord_user_id"]), + fstore.doc_delete("firebase_discord_links", uid), + ) + + # Delete Firestore profile (sessions are kept for audit history) + await fstore.doc_delete("user_profiles", uid) + + # Delete from Firebase Auth + await asyncio.to_thread(firebase_auth.delete_user, uid) + + await audit.write_audit( + actor_uid=decoded["uid"], + actor_email=decoded.get("email", ""), + action="user.delete", + target_uid=uid, + target_email=email, + ) + + return {"ok": True} diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index 0c9c8c9..5dc13c1 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -2,8 +2,13 @@ import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; -import { useEffect, useState, useRef } from "react"; +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; @@ -61,6 +66,99 @@ function Toggle({ ); } +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 = { + 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 = { admin: "Admin", operator: "Operator", viewer: "Viewer" }; + return ( + + {labels[role]} + + ); +} + +// --------------------------------------------------------------------------- +// AI Features tab +// --------------------------------------------------------------------------- + +function FeaturesTab() { + const [flags, setFlags] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); + const [error, setError] = useState(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 ( +
+ {error && ( +
+

{error}

+
+ )} + {loading ? ( +

Loading…

+ ) : ( +
+ {FLAG_META.map(({ key, label, description }) => ( +
+
+

{label}

+

{description}

+
+ handleToggle(key, val)} + disabled={saving === key} + /> +
+ ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Correlation Debug tab +// --------------------------------------------------------------------------- + function CorrelationDebugTab() { const [limit, setLimit] = useState(20); const [orphanHours, setOrphanHours] = useState(48); @@ -121,7 +219,6 @@ function CorrelationDebugTab() { orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>; } | null; - // Aggregate corr_path and corr_fit_signal counts across all incident calls. const pathCounts: Record = {}; const signalCounts: Record = {}; if (meta?.incidents) { @@ -245,57 +342,448 @@ function CorrelationDebugTab() { ); } -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(null); +// --------------------------------------------------------------------------- +// User detail panel +// --------------------------------------------------------------------------- - async function run(dryRun: boolean) { - setLoading(true); +function UserDetailPanel({ + user, + onClose, + onUpdated, + currentUid, +}: { + user: UserRecord; + onClose: () => void; + onUpdated: (u: UserRecord) => void; + currentUid: string; +}) { + const [detail, setDetail] = useState(user); + const [editRole, setEditRole] = useState(user.role); + const [editNodes, setEditNodes] = useState(user.owned_node_ids.join(", ")); + const [editName, setEditName] = useState(user.display_name ?? ""); + const [saving, setSaving] = useState(false); + const [toggling, setToggling] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(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); - setResult(null); + const nodes = editRole === "operator" + ? editNodes.split(",").map((s) => s.trim()).filter(Boolean) + : []; try { - const res = await c2api.closeStallCalls(minutes, dryRun); - setResult(res); + 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 ( +
+
+
+

{detail.email}

+

{detail.uid}

+
+ +
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + 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" + /> +
+ +
+ + +
+ + {editRole === "operator" && ( +
+ + 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" + /> +
+ )} + + +
+ +
+
+ Status + + {detail.disabled ? "Disabled" : "Active"} + +
+
+ Discord + + {detail.discord_linked + ? `@${detail.discord_username ?? detail.discord_user_id}` + : "Not linked"} + +
+
+ Created + {fmtDate(detail.creation_time)} +
+
+ Last sign-in + {fmtDate(detail.last_sign_in)} +
+
+ + {(detail.sessions?.length ?? 0) > 0 && ( +
+ + {showSessions && ( +
+ {detail.sessions?.map((s) => ( +
+ {fmtDatetime(s.timestamp)} + {s.ip ?? "—"} +
+ ))} +
+ )} +
+ )} + +
+ {!isSelf ? ( + <> + + + + ) : ( +

Cannot disable or delete your own account.

+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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("viewer"); + const [nodeIds, setNodeIds] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [inviteLink, setInviteLink] = useState(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 ( +
+
+

User Created

+

+ Share this one-time invite link with the new user so they can set their password. + It expires after use. +

+
+

{inviteLink}

+
+
+ + +
+
+
+ ); + } + + return ( +
+
+

Create User

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+ {role === "operator" && ( +
+ + 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" + /> +
+ )} + {error &&

{error}

} +
+ + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Users tab +// --------------------------------------------------------------------------- + +function UsersTab({ currentUid }: { currentUid: string }) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedUid, setSelectedUid] = useState(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)); + } } - return ( -
-

- Finds calls stuck in active status because a node rebooted before sending an end-call event. - Preview first, then close. -

+ function handleCreated(created: UserRecord) { + setUsers((prev) => [...prev, created]); + } -
-
- - 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" - /> -
+ const selectedUser = users.find((u) => u.uid === selectedUid); + + return ( +
+ {showCreate && ( + setShowCreate(false)} + onCreated={(u) => { handleCreated(u); setShowCreate(false); }} + /> + )} + +
+

{users.length} user{users.length !== 1 ? "s" : ""}

-
@@ -305,114 +793,223 @@ function StaleCallsTab() {
)} - {result && ( -
-

- {result.dry_run ? "Preview: " : "Closed: "} - 0 ? "text-amber-400" : "text-green-400"}> - {result.count} stale call{result.count !== 1 ? "s" : ""} - - {result.count === 0 && — nothing to clear} -

- {result.call_ids.length > 0 && ( -
- {result.call_ids.map((id) => ( -

{id}

+ {loading ? ( +

Loading…

+ ) : users.length === 0 ? ( +

No users found.

+ ) : ( +
+ + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} - - )} + +
EmailNameRoleDiscordLast sign-inStatus
{u.email ?? "—"}{u.display_name ?? "—"} + {u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"} + {fmtDate(u.last_sign_in)} + {u.disabled + ? Disabled + : Active + } + + +
)} + + {selectedUser && ( + setSelectedUid(null)} + onUpdated={handleUpdated} + currentUid={currentUid} + /> + )}
); } -export default function AdminPage() { - const { isAdmin } = useAuth(); - const router = useRouter(); - const [tab, setTab] = useState<"features" | "correlation" | "calls">("features"); +// --------------------------------------------------------------------------- +// Audit Log tab +// --------------------------------------------------------------------------- - const [flags, setFlags] = useState(null); +function AuditLogTab() { + const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); + const PAGE = 50; useEffect(() => { - if (!isAdmin) { - router.replace("/dashboard"); - return; - } - c2api.getFeatureFlags() - .then((f) => setFlags(f as unknown as FeatureFlags)) + c2api.getAuditLog(PAGE, 0) + .then((data) => { + setEntries(data); + setHasMore(data.length === PAGE); + }) .catch((e) => setError(String(e))) .finally(() => setLoading(false)); - }, [isAdmin, router]); + }, []); - async function handleToggle(key: keyof FeatureFlags, value: boolean) { - if (!flags) return; - setSaving(key); - setError(null); + async function loadMore() { + setLoadingMore(true); try { - const updated = await c2api.setFeatureFlags({ [key]: value }); - setFlags(updated as unknown as FeatureFlags); + const more = await c2api.getAuditLog(PAGE, entries.length); + setEntries((prev) => [...prev, ...more]); + setHasMore(more.length === PAGE); } catch (e) { setError(String(e)); } finally { - setSaving(null); + setLoadingMore(false); } } - if (!isAdmin) return null; + 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 ( -
+
+ {error && ( +
+

{error}

+
+ )} + + {loading ? ( +

Loading…

+ ) : entries.length === 0 ? ( +

No audit entries yet.

+ ) : ( + <> +
+ + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + ))} + +
TimeActionActorTargetDetails
{fmtDatetime(e.timestamp)}{e.action}{e.actor_email}{e.target_email ?? "—"} + {Object.keys(e.details).length > 0 + ? Object.entries(e.details) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join(" · ") + : "—"} +
+
+ + {hasMore && ( + + )} + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main admin page +// --------------------------------------------------------------------------- + +type AdminTab = "features" | "correlation" | "users" | "audit"; + +const TAB_LABELS: { key: AdminTab; label: string }[] = [ + { key: "features", label: "AI Features" }, + { key: "correlation", label: "Correlation Debug" }, + { key: "users", label: "Users" }, + { key: "audit", label: "Audit Log" }, +]; + +export default function AdminPage() { + const { user, isAdmin } = useAuth(); + const router = useRouter(); + const [tab, setTab] = useState("features"); + + useEffect(() => { + if (!isAdmin) router.replace("/dashboard"); + }, [isAdmin, router]); + + if (!isAdmin) return null; + + // Users/Audit tabs benefit from full width; AI Features / Correlation are narrow + const wide = tab === "users" || tab === "audit"; + + return ( +

Admin

-
- {(["features", "correlation", "calls"] as const).map((t) => ( +
+ {TAB_LABELS.map(({ key, label }) => ( ))}
- {tab === "features" && ( -
- {error && ( -
-

{error}

-
- )} - {loading ? ( -

Loading…

- ) : ( -
- {FLAG_META.map(({ key, label, description }) => ( -
-
-

{label}

-

{description}

-
- handleToggle(key, val)} - disabled={saving === key} - /> -
- ))} -
- )} -
- )} - + {tab === "features" && } {tab === "correlation" && } - {tab === "calls" && } + {tab === "users" && } + {tab === "audit" && }
); } diff --git a/drb-frontend/app/login/page.tsx b/drb-frontend/app/login/page.tsx index 938f199..88d72bc 100644 --- a/drb-frontend/app/login/page.tsx +++ b/drb-frontend/app/login/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; import { auth } from "@/lib/firebase"; +import { c2api } from "@/lib/c2api"; import { useRouter } from "next/navigation"; export default function LoginPage() { @@ -18,6 +19,7 @@ export default function LoginPage() { setError(null); try { await signInWithEmailAndPassword(auth, email, password); + c2api.recordSession().catch(() => {}); router.push("/dashboard"); } catch { setError("Invalid email or password."); @@ -31,6 +33,7 @@ export default function LoginPage() { setError(null); try { await signInWithPopup(auth, new GoogleAuthProvider()); + c2api.recordSession().catch(() => {}); router.push("/dashboard"); } catch { setError("Google sign-in failed. Try again."); diff --git a/drb-frontend/app/nodes/page.tsx b/drb-frontend/app/nodes/page.tsx index 7ddca18..9391b03 100644 --- a/drb-frontend/app/nodes/page.tsx +++ b/drb-frontend/app/nodes/page.tsx @@ -1,15 +1,25 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { useNodes } from "@/lib/useNodes"; import { useSystems } from "@/lib/useSystems"; import { NodeCard } from "@/components/NodeCard"; import { NodeConfigModal } from "@/components/NodeConfigModal"; +import { useAuth } from "@/components/AuthProvider"; import type { NodeRecord } from "@/lib/types"; export default function NodesPage() { + const { isAdmin, isOperator, loading: authLoading } = useAuth(); + const router = useRouter(); const { nodes, loading } = useNodes(); const { systems } = useSystems(); + + useEffect(() => { + if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard"); + }, [authLoading, isAdmin, isOperator, router]); + + if (authLoading || (!isAdmin && !isOperator)) return null; const [configNode, setConfigNode] = useState(null); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); diff --git a/drb-frontend/app/profile/page.tsx b/drb-frontend/app/profile/page.tsx index 9827391..abc22b0 100644 --- a/drb-frontend/app/profile/page.tsx +++ b/drb-frontend/app/profile/page.tsx @@ -31,7 +31,7 @@ function Initials({ name }: { name: string }) { } export default function ProfilePage() { - const { user, isAdmin, signOut } = useAuth(); + const { user, isAdmin, role, signOut } = useAuth(); const router = useRouter(); const [linkStatus, setLinkStatus] = useState(null); @@ -94,9 +94,13 @@ export default function ProfilePage() { {user.displayName && user.email && (

{user.email}

)} - {isAdmin && ( - - Admin + {role && ( + + {role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} )}
@@ -109,7 +113,7 @@ export default function ProfilePage() {
- + {user.metadata.creationTime && ( )} diff --git a/drb-frontend/app/systems/page.tsx b/drb-frontend/app/systems/page.tsx index 5dfe759..1d2758c 100644 --- a/drb-frontend/app/systems/page.tsx +++ b/drb-frontend/app/systems/page.tsx @@ -1,8 +1,10 @@ "use client"; -import { useRef, useState, Fragment } from "react"; +import { useEffect, useRef, useState, Fragment } from "react"; +import { useRouter } from "next/navigation"; import { useSystems } from "@/lib/useSystems"; import { c2api } from "@/lib/c2api"; +import { useAuth } from "@/components/AuthProvider"; import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types"; // ── P25 structured config types ─────────────────────────────────────────────── @@ -1178,7 +1180,15 @@ function VocabularyPanel({ systemId }: { systemId: string }) { // ── Systems list page ───────────────────────────────────────────────────────── export default function SystemsPage() { + const { isAdmin, isOperator, loading: authLoading } = useAuth(); + const router = useRouter(); const { systems, loading } = useSystems(); + + useEffect(() => { + if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard"); + }, [authLoading, isAdmin, isOperator, router]); + + if (authLoading || (!isAdmin && !isOperator)) return null; const [editing, setEditing] = useState(null); const [editIsDuplicate, setEditIsDuplicate] = useState(false); diff --git a/drb-frontend/app/tokens/page.tsx b/drb-frontend/app/tokens/page.tsx index 233e8c0..f016d94 100644 --- a/drb-frontend/app/tokens/page.tsx +++ b/drb-frontend/app/tokens/page.tsx @@ -15,7 +15,7 @@ interface TokenRecord { } export default function TokensPage() { - const { isAdmin, loading: authLoading } = useAuth(); + const { isAdmin, isOperator, loading: authLoading } = useAuth(); const router = useRouter(); const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); @@ -26,8 +26,8 @@ export default function TokensPage() { const [error, setError] = useState(null); useEffect(() => { - if (!authLoading && !isAdmin) router.replace("/dashboard"); - }, [authLoading, isAdmin, router]); + if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard"); + }, [authLoading, isAdmin, isOperator, router]); const refresh = useCallback(async () => { try { @@ -67,7 +67,7 @@ export default function TokensPage() { } } - if (authLoading || !isAdmin) return null; + if (authLoading || (!isAdmin && !isOperator)) return null; return (
diff --git a/drb-frontend/components/AuthProvider.tsx b/drb-frontend/components/AuthProvider.tsx index 885c718..0696013 100644 --- a/drb-frontend/components/AuthProvider.tsx +++ b/drb-frontend/components/AuthProvider.tsx @@ -3,25 +3,33 @@ import { createContext, useContext, useEffect, useState } from "react"; import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth"; import { auth } from "@/lib/firebase"; +import type { UserRole } from "@/lib/types"; interface AuthContextType { user: User | null; loading: boolean; + role: UserRole | null; isAdmin: boolean; + isOperator: boolean; + ownedNodeIds: string[]; signOut: () => Promise; } const AuthContext = createContext({ user: null, loading: true, + role: null, isAdmin: false, + isOperator: false, + ownedNodeIds: [], signOut: async () => {}, }); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - const [isAdmin, setIsAdmin] = useState(false); + const [role, setRole] = useState(null); + const [ownedNodeIds, setOwnedNodeIds] = useState([]); useEffect(() => { return onAuthStateChanged(auth, async (u) => { @@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (u) { document.cookie = "drb_session=1; path=/; SameSite=Strict"; - // Read custom claims to determine admin status const result = await u.getIdTokenResult(true); - setIsAdmin(!!result.claims.admin); + const claims = result.claims; + + // Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean + let effectiveRole: UserRole = "viewer"; + if (claims.role === "admin" || claims.admin) { + effectiveRole = "admin"; + } else if (claims.role === "operator") { + effectiveRole = "operator"; + } else if (claims.role === "viewer") { + effectiveRole = "viewer"; + } + + setRole(effectiveRole); + setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []); } else { document.cookie = "drb_session=; path=/; max-age=0"; - setIsAdmin(false); + setRole(null); + setOwnedNodeIds([]); } }); }, []); @@ -45,8 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { document.cookie = "drb_session=; path=/; max-age=0"; } + const isAdmin = role === "admin"; + const isOperator = role === "operator"; + return ( - + {children} ); diff --git a/drb-frontend/components/Nav.tsx b/drb-frontend/components/Nav.tsx index f100afe..08bc08c 100644 --- a/drb-frontend/components/Nav.tsx +++ b/drb-frontend/components/Nav.tsx @@ -8,20 +8,26 @@ import { useUnacknowledgedAlerts } from "@/lib/useAlerts"; import { useAuth } from "@/components/AuthProvider"; import { useTheme } from "@/components/ThemeProvider"; -const links = [ - { href: "/dashboard", label: "Dashboard" }, - { href: "/nodes", label: "Nodes" }, - { href: "/systems", label: "Systems" }, - { href: "/calls", label: "Calls" }, - { href: "/incidents", label: "Incidents" }, - { href: "/map", label: "Map" }, - { href: "/alerts", label: "Alerts" }, - { href: "/trips", label: "Trips" }, +// Links visible to all authenticated roles (viewer+) +const viewerLinks = [ + { href: "/dashboard", label: "Dashboard" }, + { href: "/calls", label: "Calls" }, + { href: "/incidents", label: "Incidents" }, + { href: "/map", label: "Map" }, + { href: "/alerts", label: "Alerts" }, + { href: "/trips", label: "Trips" }, ]; +// Additional links for operators and admins +const operatorLinks = [ + { href: "/nodes", label: "Nodes" }, + { href: "/systems", label: "Systems" }, + { href: "/tokens", label: "Tokens" }, +]; + +// Admin-only links const adminLinks = [ - { href: "/tokens", label: "Tokens" }, - { href: "/admin", label: "Admin" }, + { href: "/admin", label: "Admin" }, ]; function SunIcon() { @@ -49,7 +55,7 @@ function MoonIcon() { } export function Nav() { - const { user, isAdmin } = useAuth(); + const { user, isAdmin, isOperator } = useAuth(); const pathname = usePathname(); const router = useRouter(); const { nodes: pending } = useUnconfiguredNodes(); @@ -59,7 +65,11 @@ export function Nav() { if (!user) return null; - const allLinks = [...links, ...(isAdmin ? adminLinks : [])]; + const allLinks = [ + ...viewerLinks, + ...(isAdmin || isOperator ? operatorLinks : []), + ...(isAdmin ? adminLinks : []), + ]; function navLinkClass(href: string) { return `text-sm font-mono transition-colors shrink-0 ${ diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index fa759e6..0aea23d 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -186,4 +186,34 @@ export const c2api = { method: "PUT", body: JSON.stringify(flags), }), + + // User management (admin only) + listUsers: () => + request("/admin/users"), + createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) => + request("/admin/users", { + method: "POST", + body: JSON.stringify(body), + }), + getUser: (uid: string) => + request(`/admin/users/${uid}`), + updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) => + request(`/admin/users/${uid}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + disableUser: (uid: string) => + request<{ ok: boolean }>(`/admin/users/${uid}/disable`, { method: "POST" }), + enableUser: (uid: string) => + request<{ ok: boolean }>(`/admin/users/${uid}/enable`, { method: "POST" }), + deleteUser: (uid: string) => + request<{ ok: boolean }>(`/admin/users/${uid}`, { method: "DELETE" }), + + // Audit log (admin only) + getAuditLog: (limit = 50, offset = 0) => + request(`/admin/audit?limit=${limit}&offset=${offset}`), + + // Session recording — called on each explicit sign-in + recordSession: () => + request<{ ok: boolean }>("/auth/session", { method: "POST" }), }; diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index ba0ceef..bc17a9c 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -1,5 +1,44 @@ export type NodeStatus = "online" | "offline" | "recording" | "unconfigured"; export type ApprovalStatus = "pending" | "approved" | "rejected"; +export type UserRole = "admin" | "operator" | "viewer"; + +export interface UserRecord { + uid: string; + email: string | null; + display_name: string | null; + role: UserRole; + owned_node_ids: string[]; + disabled: boolean; + creation_time: string | null; + last_sign_in: string | null; + discord_linked: boolean; + discord_username: string | null; + discord_user_id: string | null; + // only present on GET /admin/users/{uid} + sessions?: UserSession[]; + // only present on POST /admin/users response + invite_link?: string | null; +} + +export interface UserSession { + session_id: string; + uid: string; + email: string; + timestamp: string; + ip: string | null; + user_agent: string | null; +} + +export interface AuditEntry { + log_id: string; + action: string; + actor_uid: string; + actor_email: string; + target_uid: string | null; + target_email: string | null; + details: Record; + timestamp: string; +} export interface NodeRecord { node_id: string;