Add per-device active sessions with revocation (#69)

Replaces the stateless signed-cookie session (bare user_id) with a
real server-side sessions table -- the cookie now just carries an
opaque session id, resolved against the DB on every request. Each
session records IP address (respects X-Forwarded-For), a parsed
device label, and last-seen time (throttled updates, not written on
every request).

New GET/DELETE /api/auth/sessions endpoints and an "Active sessions"
section in Profile settings let a user see every device they're
logged in from and revoke one they don't recognize -- including their
own current session, which just signs them out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 20:22:50 -06:00
co-authored by Claude Sonnet 5
parent 278f8bb995
commit b26643527d
15 changed files with 554 additions and 14 deletions
+11 -1
View File
@@ -1,5 +1,5 @@
import { apiFetch, ApiError, NetworkError } from './client'
import type { User } from '../types'
import type { User, UserSession } from '../types'
// No register() here: this is an invite-only site. Accounts are created by
// an operator via the backend CLI (`python -m app.cli create-user`), not
@@ -16,6 +16,16 @@ export function logout(): Promise<void> {
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
}
// #69: every device/browser currently logged into this account, newest
// last-seen first -- see backend's app/schemas/session.py.
export function listSessions(): Promise<UserSession[]> {
return apiFetch<UserSession[]>('/api/auth/sessions')
}
export function revokeSession(sessionId: string): Promise<void> {
return apiFetch<void>(`/api/auth/sessions/${sessionId}`, { method: 'DELETE' })
}
export function me(): Promise<User> {
return apiFetch<User>('/api/auth/me')
}
+19
View File
@@ -429,6 +429,25 @@
color: var(--ds-muted);
}
.modal-list-row-action {
flex: none;
background: transparent;
border: none;
color: var(--ds-danger);
font-size: 0.76rem;
cursor: pointer;
padding: 4px 6px;
}
.modal-list-row-action:hover:not(:disabled) {
text-decoration: underline;
}
.modal-list-row-action:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.profile-modal-avatar-row {
display: flex;
align-items: center;
+74 -3
View File
@@ -1,5 +1,14 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { changePassword, me, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth'
import {
changePassword,
listSessions,
me,
removeAvatar,
revokeSession,
updateProfile,
updateTheme,
uploadAvatar,
} from '../api/auth'
import { ApiError } from '../api/client'
import {
activateCustomTheme,
@@ -12,7 +21,7 @@ import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors } from '../types'
import type { CustomTheme, CustomThemeColors, UserSession } from '../types'
import { ThemeBuilderModal } from './ThemeBuilderModal'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
@@ -44,7 +53,7 @@ interface ProfileModalProps {
}
export function ProfileModal({ onClose }: ProfileModalProps) {
const { user, updateUser } = useAuth()
const { user, updateUser, logout } = useAuth()
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
const [error, setError] = useState<string | null>(null)
const [savingName, setSavingName] = useState(false)
@@ -66,6 +75,10 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [passwordSuccess, setPasswordSuccess] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const [sessions, setSessions] = useState<UserSession[]>([])
const [sessionsError, setSessionsError] = useState<string | null>(null)
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
useEffect(() => {
listCustomThemes()
.then(setCustomThemes)
@@ -73,6 +86,12 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
// Non-critical -- the saved-themes list just stays empty; presets
// and everything else in this modal still work fine.
})
listSessions()
.then(setSessions)
.catch(() => {
// Same non-critical treatment -- an empty list just means this
// section renders no rows rather than failing the whole modal.
})
}, [])
if (!user) return null
@@ -252,6 +271,27 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
}
}
async function handleRevokeSession(session: UserSession) {
setSessionsError(null)
setRevokingSessionId(session.id)
try {
if (session.is_current) {
// Revoking your own current session is really just "sign out" --
// go through the normal logout path so local auth state (and the
// rest of the app) clears immediately, instead of waiting for the
// next request to organically 401.
await logout()
return
}
await revokeSession(session.id)
setSessions((prev) => prev.filter((s) => s.id !== session.id))
} catch (err) {
setSessionsError(err instanceof ApiError ? err.message : String(err))
} finally {
setRevokingSessionId(null)
}
}
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null
@@ -464,6 +504,37 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</button>
</div>
</form>
<hr className="modal-divider" />
<div className="modal-field-label">Active sessions</div>
{sessionsError && <p className="modal-error">{sessionsError}</p>}
{sessions.length === 0 ? (
<p className="modal-empty">No active sessions.</p>
) : (
sessions.map((session) => (
<div key={session.id} className="modal-list-row">
<div className="modal-list-row-body">
<div className="modal-list-row-title">
{session.device_label}
{session.is_current && ' · This device'}
</div>
<div className="modal-list-row-sub">
{session.ip_address ?? 'Unknown location'} · last active{' '}
{new Date(session.last_seen_at).toLocaleString()}
</div>
</div>
<button
type="button"
className="modal-list-row-action"
disabled={revokingSessionId === session.id}
onClick={() => handleRevokeSession(session)}
>
{session.is_current ? 'Sign out' : 'Revoke'}
</button>
</div>
))
)}
</div>
</div>
)
+10
View File
@@ -42,6 +42,16 @@ export interface User {
created_at: string
}
// #69: one row per logged-in device/browser -- see backend's app/models/session.py.
export interface UserSession {
id: string
ip_address: string | null
device_label: string
created_at: string
last_seen_at: string
is_current: boolean
}
export interface UserDirectoryEntry {
id: string
username: string