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")
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
+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.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)
+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 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]
+25 -1
View File
@@ -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}
+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}
+710 -113
View File
@@ -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<UserRole, string> = {
admin: "bg-indigo-900 text-indigo-300",
operator: "bg-green-900 text-green-300",
viewer: "bg-gray-800 text-gray-400",
};
function RoleBadge({ role }: { role: UserRole }) {
const labels: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ROLE_COLORS[role]}`}>
{labels[role]}
</span>
);
}
// ---------------------------------------------------------------------------
// AI Features tab
// ---------------------------------------------------------------------------
function FeaturesTab() {
const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
c2api.getFeatureFlags()
.then((f) => setFlags(f as unknown as FeatureFlags))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
if (!flags) return;
setSaving(key);
setError(null);
try {
const updated = await c2api.setFeatureFlags({ [key]: value });
setFlags(updated as unknown as FeatureFlags);
} catch (e) {
setError(String(e));
} finally {
setSaving(null);
}
}
return (
<section className="space-y-3">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
</div>
<Toggle
enabled={flags?.[key] ?? true}
onChange={(val) => handleToggle(key, val)}
disabled={saving === key}
/>
</div>
))}
</div>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Correlation Debug tab
// ---------------------------------------------------------------------------
function CorrelationDebugTab() {
const [limit, setLimit] = useState(20);
const [orphanHours, setOrphanHours] = useState(48);
@@ -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<string, number> = {};
const signalCounts: Record<string, number> = {};
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<string | null>(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<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);
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 (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
<div className="flex items-start justify-between">
<div>
<p className="text-white font-semibold">{detail.email}</p>
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
</div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-xs">{error}</p>
</div>
)}
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Full name"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin full access</option>
<option value="operator">Operator owns nodes</option>
<option value="viewer">Viewer read-only</option>
</select>
</div>
{editRole === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={editNodes}
onChange={(e) => setEditNodes(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456"
/>
</div>
)}
<button
onClick={handleSave}
disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
{detail.disabled ? "Disabled" : "Active"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Discord</span>
<span className="text-gray-300">
{detail.discord_linked
? `@${detail.discord_username ?? detail.discord_user_id}`
: "Not linked"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Created</span>
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Last sign-in</span>
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
</div>
</div>
{(detail.sessions?.length ?? 0) > 0 && (
<div className="border-t border-gray-800 pt-4">
<button
onClick={() => setShowSessions((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
>
<span>{showSessions ? "▲" : "▼"}</span>
<span>Login history ({detail.sessions?.length} recent)</span>
</button>
{showSessions && (
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
{detail.sessions?.map((s) => (
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
<span>{fmtDatetime(s.timestamp)}</span>
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
</div>
))}
</div>
)}
</div>
)}
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
{!isSelf ? (
<>
<button
onClick={handleToggleDisabled}
disabled={toggling}
className="text-xs text-yellow-500 hover:text-yellow-400 disabled:opacity-50 transition-colors"
>
{toggling ? "…" : detail.disabled ? "Enable account" : "Disable account"}
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="text-xs text-red-500 hover:text-red-400 disabled:opacity-50 transition-colors"
>
{deleting ? "Deleting…" : "Delete user"}
</button>
</>
) : (
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Create User modal
// ---------------------------------------------------------------------------
function CreateUserModal({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (u: UserRecord) => void;
}) {
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [role, setRole] = useState<UserRole>("viewer");
const [nodeIds, setNodeIds] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const owned_node_ids = role === "operator"
? nodeIds.split(",").map((s) => s.trim()).filter(Boolean)
: [];
try {
const created = await c2api.createUser({
email,
role,
display_name: displayName || undefined,
owned_node_ids,
});
onCreated(created);
if (created.invite_link) {
setInviteLink(created.invite_link);
} else {
onClose();
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}
function copyLink() {
if (!inviteLink) return;
navigator.clipboard?.writeText(inviteLink).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
if (inviteLink) {
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
<h2 className="text-white font-semibold">User Created</h2>
<p className="text-xs text-gray-400">
Share this one-time invite link with the new user so they can set their password.
It expires after use.
</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div>
<div className="flex gap-3">
<button
onClick={copyLink}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
{copied ? "Copied!" : "Copy link"}
</button>
<button
onClick={onClose}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg py-2 text-sm transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
<h2 className="text-white font-semibold mb-4">Create User</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="user@example.com"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Display Name <span className="text-gray-600">(optional)</span>
</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Jane Smith"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin full access</option>
<option value="operator">Operator owns nodes</option>
<option value="viewer">Viewer read-only</option>
</select>
</div>
{role === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={nodeIds}
onChange={(e) => setNodeIds(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456"
/>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={saving}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Creating…" : "Create user"}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Users tab
// ---------------------------------------------------------------------------
function UsersTab({ currentUid }: { currentUid: string }) {
const [users, setUsers] = useState<UserRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUid, setSelectedUid] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadUsers = useCallback(async () => {
try {
const data = await c2api.listUsers();
setUsers(data);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUsers(); }, [loadUsers]);
function handleUpdated(updated: UserRecord) {
if (updated.uid === "__deleted__") {
setUsers((prev) => prev.filter((u) => u.uid !== selectedUid));
setSelectedUid(null);
} else {
setUsers((prev) => prev.map((u) => u.uid === updated.uid ? { ...u, ...updated } : u));
}
}
return (
<div className="space-y-5">
<p className="text-xs text-gray-500 font-mono">
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
Preview first, then close.
</p>
function handleCreated(created: UserRecord) {
setUsers((prev) => [...prev, created]);
}
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
<input
type="number"
min={1} max={1440}
value={minutes}
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
const selectedUser = users.find((u) => u.uid === selectedUid);
return (
<div className="space-y-4">
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
/>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
<button
onClick={() => run(true)}
disabled={loading}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{loading ? "Working…" : "Preview"}
</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"}
+ Create user
</button>
</div>
@@ -305,114 +793,223 @@ function StaleCallsTab() {
</div>
)}
{result && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
<p className="text-sm font-mono text-white">
{result.dry_run ? "Preview: " : "Closed: "}
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
{result.count} stale call{result.count !== 1 ? "s" : ""}
</span>
{result.count === 0 && <span className="text-gray-500"> nothing to clear</span>}
</p>
{result.call_ids.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-0.5">
{result.call_ids.map((id) => (
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : users.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No users found.</p>
) : (
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Email</th>
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
<th className="px-4 py-2.5 text-left">Role</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Discord</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Last sign-in</th>
<th className="px-4 py-2.5 text-left">Status</th>
<th className="px-4 py-2.5 w-16"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.uid}
className={`border-t border-gray-800 transition-colors ${
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
}`}
>
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
</td>
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
<td className="px-4 py-2.5">
{u.disabled
? <span className="text-red-500">Disabled</span>
: <span className="text-green-500">Active</span>
}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => setSelectedUid(selectedUid === u.uid ? null : u.uid)}
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
{selectedUid === u.uid ? "Close" : "Edit"}
</button>
</td>
</tr>
))}
</div>
)}
</tbody>
</table>
</div>
)}
{selectedUser && (
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUid(null)}
onUpdated={handleUpdated}
currentUid={currentUid}
/>
)}
</div>
);
}
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<FeatureFlags | null>(null);
function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
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 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 (
<div className="max-w-2xl space-y-6">
<div className="space-y-4">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : entries.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
) : (
<>
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Time</th>
<th className="px-4 py-2.5 text-left">Action</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
<th className="px-4 py-2.5 text-left">Details</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
{Object.keys(e.details).length > 0
? Object.entries(e.details)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
.join(" · ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="text-sm font-mono text-indigo-400 hover:text-indigo-300 disabled:opacity-50 transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{(["features", "correlation", "calls"] as const).map((t) => (
<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={t}
onClick={() => setTab(t)}
key={key}
onClick={() => setTab(key)}
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"
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{t === "features" ? "AI Features" : t === "correlation" ? "Correlation Debug" : "Calls"}
{label}
</button>
))}
</div>
{tab === "features" && (
<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>
)}
{tab === "features" && <FeaturesTab />}
{tab === "correlation" && <CorrelationDebugTab />}
{tab === "calls" && <StaleCallsTab />}
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />}
</div>
);
}
+3
View File
@@ -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.");
+11 -1
View File
@@ -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<NodeRecord | null>(null);
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() {
const { user, isAdmin, signOut } = useAuth();
const { user, isAdmin, role, signOut } = useAuth();
const router = useRouter();
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
@@ -94,9 +94,13 @@ export default function ProfilePage() {
{user.displayName && user.email && (
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
)}
{isAdmin && (
<span className="inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full bg-indigo-900 text-indigo-300">
Admin
{role && (
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
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>
)}
</div>
@@ -109,7 +113,7 @@ export default function ProfilePage() {
<div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} />
<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 && (
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
)}
+11 -1
View File
@@ -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<SystemRecord | null | "new">(null);
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
+4 -4
View File
@@ -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<TokenRecord[]>([]);
const [loading, setLoading] = useState(true);
@@ -26,8 +26,8 @@ export default function TokensPage() {
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
+29 -5
View File
@@ -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<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
role: null,
isAdmin: false,
isOperator: false,
ownedNodeIds: [],
signOut: async () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [role, setRole] = useState<UserRole | null>(null);
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
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 (
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}>
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
{children}
</AuthContext.Provider>
);
+23 -13
View File
@@ -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 ${
+30
View File
@@ -186,4 +186,34 @@ export const c2api = {
method: "PUT",
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 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 {
node_id: string;