11import { useMemo , useState } from 'react'
2+ import { useNavigate , useSearch } from '@tanstack/react-router'
23import { useAuth } from '@/hooks/useAuth'
34import type { AuthConfig } from '@/api/auth-client'
45import { Modal } from '@/components/shared/Modal'
78 useCreateUser ,
89 useUpdateUser ,
910 useDeleteUser ,
11+ useSessions ,
12+ useDeleteSession ,
1013 useOrgMappings ,
1114 useUpsertOrgMapping ,
1215 useDeleteOrgMapping ,
@@ -17,7 +20,7 @@ import {
1720import { Plus , Pencil , Trash2 } from 'lucide-react'
1821import clsx from 'clsx'
1922
20- type Tab = 'users' | 'github-mappings'
23+ type Tab = 'users' | 'github-mappings' | 'sessions'
2124
2225export function AdminPage ( ) {
2326 const { isAdmin, authConfig } = useAuth ( )
@@ -26,12 +29,19 @@ export function AdminPage() {
2629 const result : { key : Tab ; label : string } [ ] = [ ]
2730 if ( authConfig ?. auth . basic_enabled ) result . push ( { key : 'users' , label : 'Users' } )
2831 if ( authConfig ?. auth . github_enabled ) result . push ( { key : 'github-mappings' , label : 'GitHub Mappings' } )
32+ result . push ( { key : 'sessions' , label : 'Sessions' } )
2933 return result
3034 } , [ authConfig ] )
3135
32- const [ activeTab , setActiveTab ] = useState < Tab > ( 'users' )
36+ const navigate = useNavigate ( )
37+ const search = useSearch ( { from : '/admin' } ) as { tab ?: string }
38+ const activeTab = ( search . tab as Tab ) || tabs [ 0 ] ?. key
3339 const resolvedTab = tabs . find ( ( t ) => t . key === activeTab ) ? activeTab : tabs [ 0 ] ?. key
3440
41+ const setActiveTab = ( tab : Tab ) => {
42+ navigate ( { to : '/admin' , search : { tab } } )
43+ }
44+
3545 if ( ! isAdmin ) {
3646 return (
3747 < div className = "py-12 text-center text-gray-500 dark:text-gray-400" >
@@ -67,6 +77,7 @@ export function AdminPage() {
6777
6878 { resolvedTab === 'users' && < UsersTab /> }
6979 { resolvedTab === 'github-mappings' && < GitHubMappingsTab /> }
80+ { resolvedTab === 'sessions' && < SessionsTab /> }
7081 </ >
7182 ) }
7283 </ div >
@@ -219,6 +230,106 @@ function UsersTab() {
219230 )
220231}
221232
233+ // --- Sessions Tab ---
234+
235+ function formatTimestamp ( iso : string ) : string {
236+ const date = new Date ( iso )
237+ const now = new Date ( )
238+ const diffMs = date . getTime ( ) - now . getTime ( )
239+ const absDiffMs = Math . abs ( diffMs )
240+ const isPast = diffMs < 0
241+
242+ const minutes = Math . floor ( absDiffMs / 60000 )
243+ const hours = Math . floor ( minutes / 60 )
244+ const days = Math . floor ( hours / 24 )
245+
246+ let relative : string
247+ if ( minutes < 1 ) relative = 'just now'
248+ else if ( minutes < 60 ) relative = `${ minutes } m ${ isPast ? 'ago' : 'from now' } `
249+ else if ( hours < 24 ) relative = `${ hours } h ${ isPast ? 'ago' : 'from now' } `
250+ else relative = `${ days } d ${ isPast ? 'ago' : 'from now' } `
251+
252+ return `${ relative } (${ date . toLocaleString ( ) } )`
253+ }
254+
255+ function SessionsTab ( ) {
256+ const { data : sessions = [ ] , isLoading } = useSessions ( )
257+ const deleteSession = useDeleteSession ( )
258+
259+ if ( isLoading ) return < div className = "text-sm text-gray-500" > Loading...</ div >
260+
261+ return (
262+ < div >
263+ < div className = "mb-4" >
264+ < h2 className = "text-sm font-medium text-gray-700 dark:text-gray-300" >
265+ { sessions . length } session{ sessions . length !== 1 ? 's' : '' }
266+ </ h2 >
267+ </ div >
268+
269+ < div className = "overflow-hidden rounded-sm border border-gray-200 dark:border-gray-700" >
270+ < table className = "w-full text-left text-sm" >
271+ < thead className = "bg-gray-50 text-xs text-gray-500 uppercase dark:bg-gray-800 dark:text-gray-400" >
272+ < tr >
273+ < th className = "px-4 py-2" > Username</ th >
274+ < th className = "px-4 py-2" > Source</ th >
275+ < th className = "px-4 py-2" > Created</ th >
276+ < th className = "px-4 py-2" > Expires</ th >
277+ < th className = "px-4 py-2 text-right" > Actions</ th >
278+ </ tr >
279+ </ thead >
280+ < tbody className = "divide-y divide-gray-200 dark:divide-gray-700" >
281+ { sessions . map ( ( s ) => (
282+ < tr key = { s . id } className = "bg-white dark:bg-gray-900" >
283+ < td className = "px-4 py-2 font-medium text-gray-900 dark:text-gray-100" >
284+ { s . username || `User #${ s . user_id } ` }
285+ </ td >
286+ < td className = "px-4 py-2" >
287+ < span
288+ className = { clsx (
289+ 'inline-block rounded-full px-2 py-0.5 text-xs font-medium' ,
290+ s . source === 'github'
291+ ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
292+ : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' ,
293+ ) }
294+ >
295+ { s . source }
296+ </ span >
297+ </ td >
298+ < td className = "px-4 py-2 text-gray-500 dark:text-gray-400" >
299+ { formatTimestamp ( s . created_at ) }
300+ </ td >
301+ < td className = "px-4 py-2 text-gray-500 dark:text-gray-400" >
302+ { formatTimestamp ( s . expires_at ) }
303+ </ td >
304+ < td className = "px-4 py-2 text-right" >
305+ < button
306+ onClick = { ( ) => {
307+ if ( confirm ( `Revoke session for "${ s . username || `User #${ s . user_id } ` } "?` ) ) {
308+ deleteSession . mutate ( s . id )
309+ }
310+ } }
311+ className = "rounded-sm p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
312+ title = "Revoke session"
313+ >
314+ < Trash2 className = "size-3.5" />
315+ </ button >
316+ </ td >
317+ </ tr >
318+ ) ) }
319+ { sessions . length === 0 && (
320+ < tr >
321+ < td colSpan = { 5 } className = "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" >
322+ No active sessions
323+ </ td >
324+ </ tr >
325+ ) }
326+ </ tbody >
327+ </ table >
328+ </ div >
329+ </ div >
330+ )
331+ }
332+
222333// --- GitHub Mappings Tab ---
223334
224335function GitHubMappingsTab ( ) {
0 commit comments