Skip to content

Commit a53150e

Browse files
authored
feat: add admin API to manage user sessions (#92)
1 parent fa546fa commit a53150e

6 files changed

Lines changed: 253 additions & 3 deletions

File tree

docs/api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ All endpoints are under the `/api/v1` prefix.
250250
| `POST` | `/admin/users` | Create a user |
251251
| `PUT` | `/admin/users/{id}` | Update a user |
252252
| `DELETE` | `/admin/users/{id}` | Delete a user |
253+
| `GET` | `/admin/sessions` | List all active sessions |
254+
| `DELETE` | `/admin/sessions/{id}` | Revoke a session |
253255
| `GET` | `/admin/github/org-mappings` | List org role mappings |
254256
| `POST` | `/admin/github/org-mappings` | Create/update org mapping |
255257
| `DELETE` | `/admin/github/org-mappings/{id}` | Delete org mapping |
@@ -298,7 +300,7 @@ To enable API integration, add the `api` field to the UI's `config.json`:
298300

299301
When the API is configured, the UI provides:
300302
- **Login page** (`/login`) — username/password form and/or "Sign in with GitHub" button
301-
- **Admin page** (`/admin`) — user management, GitHub org/user role mapping management
303+
- **Admin page** (`/admin`) — user management, session management, GitHub org/user role mapping management
302304
- **Header controls** — sign in/out button, username display, admin link (for admins)
303305

304306
When the API is not configured, none of these features appear and the UI functions as a static results viewer.

pkg/api/admin.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,91 @@ func (s *server) handleDeleteUser(
205205
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
206206
}
207207

208+
// --- Session management ---
209+
210+
type sessionResponse struct {
211+
ID uint `json:"id"`
212+
UserID uint `json:"user_id"`
213+
Username string `json:"username"`
214+
Source string `json:"source"`
215+
ExpiresAt string `json:"expires_at"`
216+
CreatedAt string `json:"created_at"`
217+
}
218+
219+
// handleListSessions returns all sessions with resolved usernames.
220+
func (s *server) handleListSessions(
221+
w http.ResponseWriter, r *http.Request,
222+
) {
223+
sessions, err := s.store.ListSessions(r.Context())
224+
if err != nil {
225+
s.log.WithError(err).Error("Failed to list sessions")
226+
writeJSON(w, http.StatusInternalServerError,
227+
errorResponse{"internal error"})
228+
229+
return
230+
}
231+
232+
users, err := s.store.ListUsers(r.Context())
233+
if err != nil {
234+
s.log.WithError(err).Error("Failed to list users")
235+
writeJSON(w, http.StatusInternalServerError,
236+
errorResponse{"internal error"})
237+
238+
return
239+
}
240+
241+
type userInfo struct {
242+
Username string
243+
Source string
244+
}
245+
246+
userMap := make(map[uint]userInfo, len(users))
247+
for i := range users {
248+
userMap[users[i].ID] = userInfo{
249+
Username: users[i].Username,
250+
Source: users[i].Source,
251+
}
252+
}
253+
254+
resp := make([]sessionResponse, 0, len(sessions))
255+
for i := range sessions {
256+
info := userMap[sessions[i].UserID]
257+
resp = append(resp, sessionResponse{
258+
ID: sessions[i].ID,
259+
UserID: sessions[i].UserID,
260+
Username: info.Username,
261+
Source: info.Source,
262+
ExpiresAt: sessions[i].ExpiresAt.Format("2006-01-02T15:04:05Z"),
263+
CreatedAt: sessions[i].CreatedAt.Format("2006-01-02T15:04:05Z"),
264+
})
265+
}
266+
267+
writeJSON(w, http.StatusOK, resp)
268+
}
269+
270+
// handleDeleteSessionByID revokes a session by ID.
271+
func (s *server) handleDeleteSessionByID(
272+
w http.ResponseWriter, r *http.Request,
273+
) {
274+
id, err := parseIDParam(r)
275+
if err != nil {
276+
writeJSON(w, http.StatusBadRequest,
277+
errorResponse{err.Error()})
278+
279+
return
280+
}
281+
282+
if err := s.store.DeleteSessionByID(r.Context(), id); err != nil {
283+
s.log.WithError(err).Error("Failed to delete session")
284+
writeJSON(w, http.StatusInternalServerError,
285+
errorResponse{"internal error"})
286+
287+
return
288+
}
289+
290+
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
291+
}
292+
208293
// --- GitHub org mapping management ---
209294

210295
type orgMappingRequest struct {

pkg/api/routes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ func (s *server) buildRouter() http.Handler {
7878
r.Put("/users/{id}", s.handleUpdateUser)
7979
r.Delete("/users/{id}", s.handleDeleteUser)
8080

81+
// Session management.
82+
r.Get("/sessions", s.handleListSessions)
83+
r.Delete("/sessions/{id}", s.handleDeleteSessionByID)
84+
8185
// GitHub org mappings.
8286
r.Get("/github/org-mappings", s.handleListOrgMappings)
8387
r.Post("/github/org-mappings", s.handleUpsertOrgMapping)

pkg/api/store/store.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ type Store interface {
3030
// Session CRUD.
3131
CreateSession(ctx context.Context, session *Session) error
3232
GetSessionByToken(ctx context.Context, token string) (*Session, error)
33+
ListSessions(ctx context.Context) ([]Session, error)
3334
DeleteSession(ctx context.Context, token string) error
35+
DeleteSessionByID(ctx context.Context, id uint) error
3436
DeleteExpiredSessions(ctx context.Context) error
3537

3638
// GitHub org mapping CRUD.
@@ -221,6 +223,17 @@ func (s *store) GetSessionByToken(
221223
return &session, nil
222224
}
223225

226+
func (s *store) ListSessions(ctx context.Context) ([]Session, error) {
227+
var sessions []Session
228+
if err := s.db.WithContext(ctx).
229+
Order("id ASC").
230+
Find(&sessions).Error; err != nil {
231+
return nil, fmt.Errorf("listing sessions: %w", err)
232+
}
233+
234+
return sessions, nil
235+
}
236+
224237
func (s *store) DeleteSession(ctx context.Context, token string) error {
225238
if err := s.db.WithContext(ctx).
226239
Where("token = ?", token).
@@ -231,6 +244,15 @@ func (s *store) DeleteSession(ctx context.Context, token string) error {
231244
return nil
232245
}
233246

247+
func (s *store) DeleteSessionByID(ctx context.Context, id uint) error {
248+
if err := s.db.WithContext(ctx).
249+
Delete(&Session{}, id).Error; err != nil {
250+
return fmt.Errorf("deleting session by id: %w", err)
251+
}
252+
253+
return nil
254+
}
255+
234256
func (s *store) DeleteExpiredSessions(ctx context.Context) error {
235257
result := s.db.WithContext(ctx).
236258
Where("expires_at < ?", time.Now()).

ui/src/api/hooks/useAdmin.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@ async function adminFetch<T>(path: string, options?: RequestInit): Promise<T> {
3333
return resp.json()
3434
}
3535

36+
// Sessions
37+
export interface AdminSession {
38+
id: number
39+
user_id: number
40+
username: string
41+
source: string
42+
expires_at: string
43+
created_at: string
44+
}
45+
46+
export function useSessions() {
47+
return useQuery<AdminSession[]>({
48+
queryKey: ['admin', 'sessions'],
49+
queryFn: () => adminFetch('/api/v1/admin/sessions'),
50+
})
51+
}
52+
53+
export function useDeleteSession() {
54+
const queryClient = useQueryClient()
55+
return useMutation({
56+
mutationFn: (id: number) =>
57+
adminFetch(`/api/v1/admin/sessions/${id}`, { method: 'DELETE' }),
58+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] }),
59+
})
60+
}
61+
3662
// Users
3763
export function useUsers() {
3864
return useQuery<AuthUser[]>({

ui/src/pages/AdminPage.tsx

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useMemo, useState } from 'react'
2+
import { useNavigate, useSearch } from '@tanstack/react-router'
23
import { useAuth } from '@/hooks/useAuth'
34
import type { AuthConfig } from '@/api/auth-client'
45
import { Modal } from '@/components/shared/Modal'
@@ -7,6 +8,8 @@ import {
78
useCreateUser,
89
useUpdateUser,
910
useDeleteUser,
11+
useSessions,
12+
useDeleteSession,
1013
useOrgMappings,
1114
useUpsertOrgMapping,
1215
useDeleteOrgMapping,
@@ -17,7 +20,7 @@ import {
1720
import { Plus, Pencil, Trash2 } from 'lucide-react'
1821
import clsx from 'clsx'
1922

20-
type Tab = 'users' | 'github-mappings'
23+
type Tab = 'users' | 'github-mappings' | 'sessions'
2124

2225
export 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

224335
function GitHubMappingsTab() {

0 commit comments

Comments
 (0)