@@ -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-8 00 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-5 00 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 >
) ;
}