Private
Public Access
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:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user