feat: add role-based user management, audit log, and session tracking

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) }
This commit is contained in:
Logan
2026-06-22 00:02:09 -04:00
parent 961cc6f36e
commit 1f17b6c0d2
16 changed files with 1261 additions and 148 deletions
+26
View File
@@ -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)
+19 -3
View File
@@ -37,12 +37,28 @@ async def require_service_or_firebase_token(
raise HTTPException(status_code=401, detail="Invalid or expired 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( async def require_admin_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict: ) -> 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) 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") raise HTTPException(status_code=403, detail="Admin access required")
return decoded return decoded
@@ -77,7 +93,7 @@ async def require_service_key_or_admin(
decoded = firebase_auth.verify_id_token(token) decoded = firebase_auth.verify_id_token(token)
except Exception: except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired token") 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") raise HTTPException(status_code=403, detail="Admin access required")
return decoded return decoded
+2 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token 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 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(places.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline 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(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) app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
+12 -1
View File
@@ -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.feature_flags import get_flags, set_flags
from app.internal import firestore as fstore from app.internal import firestore as fstore
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]: 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.""" """Return system_ids where at least one AI function (STT or correlation) is effectively on."""
global_stt = global_flags.get("stt_enabled", True) global_stt = global_flags.get("stt_enabled", True)
@@ -163,3 +162,15 @@ async def debug_correlation(
"incidents": incident_records, "incidents": incident_records,
"orphaned_calls": orphans[:250], "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]
+25 -1
View File
@@ -1,7 +1,8 @@
import random import random
import string import string
from datetime import datetime, timezone, timedelta 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 pydantic import BaseModel
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_key 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("discord_links", discord_user_id)
await fstore.doc_delete("firebase_discord_links", firebase_uid) await fstore.doc_delete("firebase_discord_links", firebase_uid)
return {"ok": True} 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}
+308
View File
@@ -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}
+708 -111
View File
@@ -2,8 +2,13 @@
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
// ---------------------------------------------------------------------------
// Shared primitives
// ---------------------------------------------------------------------------
interface FeatureFlags { interface FeatureFlags {
stt_enabled: boolean; 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<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() { function CorrelationDebugTab() {
const [limit, setLimit] = useState(20); const [limit, setLimit] = useState(20);
const [orphanHours, setOrphanHours] = useState(48); 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 }>; orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
} | null; } | null;
// Aggregate corr_path and corr_fit_signal counts across all incident calls.
const pathCounts: Record<string, number> = {}; const pathCounts: Record<string, number> = {};
const signalCounts: Record<string, number> = {}; const signalCounts: Record<string, number> = {};
if (meta?.incidents) { if (meta?.incidents) {
@@ -245,57 +342,448 @@ function CorrelationDebugTab() {
); );
} }
function StaleCallsTab() { // ---------------------------------------------------------------------------
const [minutes, setMinutes] = useState(30); // User detail panel
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) { function UserDetailPanel({
setLoading(true); 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); setError(null);
setResult(null); const nodes = editRole === "operator"
? editNodes.split(",").map((s) => s.trim()).filter(Boolean)
: [];
try { try {
const res = await c2api.closeStallCalls(minutes, dryRun); const updated = await c2api.updateUser(user.uid, {
setResult(res); 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) { } catch (e) {
setError(String(e)); setError(String(e));
} finally { } finally {
setLoading(false); 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 ( function handleCreated(created: UserRecord) {
<div className="space-y-5"> setUsers((prev) => [...prev, created]);
<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"> const selectedUser = users.find((u) => u.uid === selectedUid);
<div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label> return (
<input <div className="space-y-4">
type="number" {showCreate && (
min={1} max={1440} <CreateUserModal
value={minutes} onClose={() => setShowCreate(false)}
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))} onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
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> )}
<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 <button
onClick={() => run(true)} onClick={() => setShowCreate(true)}
disabled={loading} className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
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"} + Create user
</button>
<button
onClick={() => run(false)}
disabled={loading || result === null}
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"
>
Close {result && !result.dry_run ? "Done" : result?.count ? `${result.count} calls` : "calls"}
</button> </button>
</div> </div>
@@ -305,114 +793,223 @@ function StaleCallsTab() {
</div> </div>
)} )}
{result && ( {loading ? (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2"> <p className="text-gray-500 text-sm font-mono">Loading</p>
<p className="text-sm font-mono text-white"> ) : users.length === 0 ? (
{result.dry_run ? "Preview: " : "Closed: "} <p className="text-gray-600 text-sm font-mono">No users found.</p>
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}> ) : (
{result.count} stale call{result.count !== 1 ? "s" : ""} <div className="border border-gray-800 rounded-xl overflow-hidden">
</span> <table className="w-full text-xs font-mono">
{result.count === 0 && <span className="text-gray-500"> nothing to clear</span>} <thead>
</p> <tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
{result.call_ids.length > 0 && ( <th className="px-4 py-2.5 text-left">Email</th>
<div className="max-h-40 overflow-y-auto space-y-0.5"> <th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
{result.call_ids.map((id) => ( <th className="px-4 py-2.5 text-left">Role</th>
<p key={id} className="text-xs font-mono text-gray-400">{id}</p> <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> </div>
)} )}
</div>
{selectedUser && (
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUid(null)}
onUpdated={handleUpdated}
currentUid={currentUid}
/>
)} )}
</div> </div>
); );
} }
export default function AdminPage() { // ---------------------------------------------------------------------------
const { isAdmin } = useAuth(); // Audit Log tab
const router = useRouter(); // ---------------------------------------------------------------------------
const [tab, setTab] = useState<"features" | "correlation" | "calls">("features");
const [flags, setFlags] = useState<FeatureFlags | null>(null); function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null); const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const PAGE = 50;
useEffect(() => { useEffect(() => {
if (!isAdmin) { c2api.getAuditLog(PAGE, 0)
router.replace("/dashboard"); .then((data) => {
return; setEntries(data);
} setHasMore(data.length === PAGE);
c2api.getFeatureFlags() })
.then((f) => setFlags(f as unknown as FeatureFlags))
.catch((e) => setError(String(e))) .catch((e) => setError(String(e)))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [isAdmin, router]); }, []);
async function handleToggle(key: keyof FeatureFlags, value: boolean) { async function loadMore() {
if (!flags) return; setLoadingMore(true);
setSaving(key);
setError(null);
try { try {
const updated = await c2api.setFeatureFlags({ [key]: value }); const more = await c2api.getAuditLog(PAGE, entries.length);
setFlags(updated as unknown as FeatureFlags); setEntries((prev) => [...prev, ...more]);
setHasMore(more.length === PAGE);
} catch (e) { } catch (e) {
setError(String(e)); setError(String(e));
} finally { } 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 ( return (
<div className="max-w-2xl space-y-6"> <div className="space-y-4">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{(["features", "correlation", "calls"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{t === "features" ? "AI Features" : t === "correlation" ? "Correlation Debug" : "Calls"}
</button>
))}
</div>
{tab === "features" && (
<section className="space-y-3">
{error && ( {error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3"> <div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p> <p className="text-red-400 text-sm font-mono">{error}</p>
</div> </div>
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p> <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="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <>
{FLAG_META.map(({ key, label, description }) => ( <div className="border border-gray-800 rounded-xl overflow-hidden">
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4"> <table className="w-full text-xs font-mono">
<div className="min-w-0"> <thead>
<p className="text-white text-sm font-semibold">{label}</p> <tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p> <th className="px-4 py-2.5 text-left">Time</th>
</div> <th className="px-4 py-2.5 text-left">Action</th>
<Toggle <th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
enabled={flags?.[key] ?? true} <th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
onChange={(val) => handleToggle(key, val)} <th className="px-4 py-2.5 text-left">Details</th>
disabled={saving === key} </tr>
/> </thead>
</div> <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> </div>
)}
</section>
)}
{tab === "correlation" && <CorrelationDebugTab />} {hasMore && (
{tab === "calls" && <StaleCallsTab />} <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>
);
}
// ---------------------------------------------------------------------------
// 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<AdminTab>("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 (
<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 === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />}
</div> </div>
); );
} }
+3
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import { c2api } from "@/lib/c2api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function LoginPage() { export default function LoginPage() {
@@ -18,6 +19,7 @@ export default function LoginPage() {
setError(null); setError(null);
try { try {
await signInWithEmailAndPassword(auth, email, password); await signInWithEmailAndPassword(auth, email, password);
c2api.recordSession().catch(() => {});
router.push("/dashboard"); router.push("/dashboard");
} catch { } catch {
setError("Invalid email or password."); setError("Invalid email or password.");
@@ -31,6 +33,7 @@ export default function LoginPage() {
setError(null); setError(null);
try { try {
await signInWithPopup(auth, new GoogleAuthProvider()); await signInWithPopup(auth, new GoogleAuthProvider());
c2api.recordSession().catch(() => {});
router.push("/dashboard"); router.push("/dashboard");
} catch { } catch {
setError("Google sign-in failed. Try again."); setError("Google sign-in failed. Try again.");
+11 -1
View File
@@ -1,15 +1,25 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { NodeCard } from "@/components/NodeCard"; import { NodeCard } from "@/components/NodeCard";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { useAuth } from "@/components/AuthProvider";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
export default function NodesPage() { export default function NodesPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { nodes, loading } = useNodes(); const { nodes, loading } = useNodes();
const { systems } = useSystems(); 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<NodeRecord | null>(null); const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
+9 -5
View File
@@ -31,7 +31,7 @@ function Initials({ name }: { name: string }) {
} }
export default function ProfilePage() { export default function ProfilePage() {
const { user, isAdmin, signOut } = useAuth(); const { user, isAdmin, role, signOut } = useAuth();
const router = useRouter(); const router = useRouter();
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null); const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
@@ -94,9 +94,13 @@ export default function ProfilePage() {
{user.displayName && user.email && ( {user.displayName && user.email && (
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p> <p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
)} )}
{isAdmin && ( {role && (
<span className="inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full bg-indigo-900 text-indigo-300"> <span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
Admin role === "admin" ? "bg-indigo-900 text-indigo-300" :
role === "operator" ? "bg-green-900 text-green-300" :
"bg-gray-800 text-gray-400"
}`}>
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
</span> </span>
)} )}
</div> </div>
@@ -109,7 +113,7 @@ export default function ProfilePage() {
<div className="space-y-2"> <div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} /> <Row label="Email" value={user.email ?? "—"} />
<Row label="UID" value={user.uid} mono truncate /> <Row label="UID" value={user.uid} mono truncate />
<Row label="Role" value={isAdmin ? "Admin" : "Member"} /> <Row label="Role" value={role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} />
{user.metadata.creationTime && ( {user.metadata.creationTime && (
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} /> <Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
)} )}
+11 -1
View File
@@ -1,8 +1,10 @@
"use client"; "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 { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types"; import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
// ── P25 structured config types ─────────────────────────────────────────────── // ── P25 structured config types ───────────────────────────────────────────────
@@ -1178,7 +1180,15 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
// ── Systems list page ───────────────────────────────────────────────────────── // ── Systems list page ─────────────────────────────────────────────────────────
export default function SystemsPage() { export default function SystemsPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { systems, loading } = useSystems(); 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<SystemRecord | null | "new">(null); const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
const [editIsDuplicate, setEditIsDuplicate] = useState(false); const [editIsDuplicate, setEditIsDuplicate] = useState(false);
+4 -4
View File
@@ -15,7 +15,7 @@ interface TokenRecord {
} }
export default function TokensPage() { export default function TokensPage() {
const { isAdmin, loading: authLoading } = useAuth(); const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const [tokens, setTokens] = useState<TokenRecord[]>([]); const [tokens, setTokens] = useState<TokenRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -26,8 +26,8 @@ export default function TokensPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/dashboard"); if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
}, [authLoading, isAdmin, router]); }, [authLoading, isAdmin, isOperator, router]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -67,7 +67,7 @@ export default function TokensPage() {
} }
} }
if (authLoading || !isAdmin) return null; if (authLoading || (!isAdmin && !isOperator)) return null;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
+29 -5
View File
@@ -3,25 +3,33 @@
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth"; import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import type { UserRole } from "@/lib/types";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
role: UserRole | null;
isAdmin: boolean; isAdmin: boolean;
isOperator: boolean;
ownedNodeIds: string[];
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
user: null, user: null,
loading: true, loading: true,
role: null,
isAdmin: false, isAdmin: false,
isOperator: false,
ownedNodeIds: [],
signOut: async () => {}, signOut: async () => {},
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false); const [role, setRole] = useState<UserRole | null>(null);
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
return onAuthStateChanged(auth, async (u) => { return onAuthStateChanged(auth, async (u) => {
@@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (u) { if (u) {
document.cookie = "drb_session=1; path=/; SameSite=Strict"; document.cookie = "drb_session=1; path=/; SameSite=Strict";
// Read custom claims to determine admin status
const result = await u.getIdTokenResult(true); 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 { } else {
document.cookie = "drb_session=; path=/; max-age=0"; 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"; document.cookie = "drb_session=; path=/; max-age=0";
} }
const isAdmin = role === "admin";
const isOperator = role === "operator";
return ( return (
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}> <AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+16 -6
View File
@@ -8,10 +8,9 @@ import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider"; import { useTheme } from "@/components/ThemeProvider";
const links = [ // Links visible to all authenticated roles (viewer+)
const viewerLinks = [
{ href: "/dashboard", label: "Dashboard" }, { href: "/dashboard", label: "Dashboard" },
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/calls", label: "Calls" }, { href: "/calls", label: "Calls" },
{ href: "/incidents", label: "Incidents" }, { href: "/incidents", label: "Incidents" },
{ href: "/map", label: "Map" }, { href: "/map", label: "Map" },
@@ -19,8 +18,15 @@ const links = [
{ href: "/trips", label: "Trips" }, { href: "/trips", label: "Trips" },
]; ];
const adminLinks = [ // Additional links for operators and admins
const operatorLinks = [
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/tokens", label: "Tokens" }, { href: "/tokens", label: "Tokens" },
];
// Admin-only links
const adminLinks = [
{ href: "/admin", label: "Admin" }, { href: "/admin", label: "Admin" },
]; ];
@@ -49,7 +55,7 @@ function MoonIcon() {
} }
export function Nav() { export function Nav() {
const { user, isAdmin } = useAuth(); const { user, isAdmin, isOperator } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { nodes: pending } = useUnconfiguredNodes(); const { nodes: pending } = useUnconfiguredNodes();
@@ -59,7 +65,11 @@ export function Nav() {
if (!user) return null; if (!user) return null;
const allLinks = [...links, ...(isAdmin ? adminLinks : [])]; const allLinks = [
...viewerLinks,
...(isAdmin || isOperator ? operatorLinks : []),
...(isAdmin ? adminLinks : []),
];
function navLinkClass(href: string) { function navLinkClass(href: string) {
return `text-sm font-mono transition-colors shrink-0 ${ return `text-sm font-mono transition-colors shrink-0 ${
+30
View File
@@ -186,4 +186,34 @@ export const c2api = {
method: "PUT", method: "PUT",
body: JSON.stringify(flags), body: JSON.stringify(flags),
}), }),
// User management (admin only)
listUsers: () =>
request<import("@/lib/types").UserRecord[]>("/admin/users"),
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
method: "POST",
body: JSON.stringify(body),
}),
getUser: (uid: string) =>
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
request<import("@/lib/types").UserRecord>(`/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<import("@/lib/types").AuditEntry[]>(`/admin/audit?limit=${limit}&offset=${offset}`),
// Session recording — called on each explicit sign-in
recordSession: () =>
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
}; };
+39
View File
@@ -1,5 +1,44 @@
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured"; export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
export type ApprovalStatus = "pending" | "approved" | "rejected"; 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<string, unknown>;
timestamp: string;
}
export interface NodeRecord { export interface NodeRecord {
node_id: string; node_id: string;