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
@@ -0,0 +1,44 @@
"""add sessions table for active-sessions feature
Revision ID: 319c30e24cd9
Revises: 05b28b0a2261
Create Date: 2026-08-28 19:59:51.475384
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '319c30e24cd9'
down_revision: Union[str, Sequence[str], None] = '05b28b0a2261'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sessions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('last_seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
op.drop_table('sessions')
# ### end Alembic commands ###
+14 -3
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import RoomMembership, RoomRole, User
from app.services.bot_service import resolve_token
from app.services.session_service import resolve_session
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
@@ -29,16 +30,26 @@ async def get_current_user(
request.state.api_token = token
return user
user_id = request.session.get("user_id")
if not user_id:
session_id = request.session.get("session_id")
if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated")
# #69: a session row, not a bare user_id -- resolve_session is also
# where a revoked session (this endpoint's own DELETE, or another
# device's "sign out") actually takes effect, since there's no other
# per-request check of that state.
session = await resolve_session(db, uuid.UUID(session_id))
if session is None:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
request.state.session_id = session.id
# Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
# be read without a MissingGreenlet -- selectinload skips the second
# query entirely when active_custom_theme_id is null (the common case),
# so this costs nothing for users who've never set a custom theme.
user = await db.get(
User, uuid.UUID(user_id), options=[selectinload(User.active_custom_theme)]
User, session.user_id, options=[selectinload(User.active_custom_theme)]
)
if user is None or not user.is_active:
request.session.clear()
+2
View File
@@ -15,6 +15,7 @@ from app.models.message_room_reference import MessageRoomReference
from app.models.password_reset import PasswordReset
from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.session import Session
from app.models.site_invite import SiteInvite
from app.models.smtp_settings import SmtpSettings
from app.models.upload_settings import UploadSettings
@@ -35,6 +36,7 @@ __all__ = [
"MessageRoomReference",
"InviteStatus",
"PasswordReset",
"Session",
"SiteInvite",
"SmtpSettings",
"UploadSettings",
+43
View File
@@ -0,0 +1,43 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Session(Base):
__tablename__ = "sessions"
# #69: the row's own id doubles as the opaque value stored in the
# signed session cookie (see app/dependencies.py) -- no separate
# generate_token()/hash_token() pair like ApiToken needs. A bearer API
# token has to be looked up *by itself* from a plaintext string a bot
# pastes into an Authorization header (real leak risk, hence hashing
# it at rest); this id only ever travels inside itsdangerous's signed,
# tamper-proof cookie payload, so a plain UUID primary key carries the
# same security properties the stateless cookie already had before
# this table existed.
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
# 45 chars fits the longest possible IPv6 text representation.
ip_address: Mapped[str | None] = mapped_column(String(45))
user_agent: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Bumped (throttled, not on every request -- see session_service.py) so
# "active sessions" can be sorted/labeled by actual recent use, not just
# login time -- a session opened once a week ago and used constantly
# since should not look identical to one opened once and abandoned.
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Null while active. Set on explicit logout or a deliberate "sign out
# this device" from another session -- never deleted outright, so a
# revoked row still means something if anyone ever needs to ask "was
# this session valid at time X."
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user = relationship("User")
+49 -3
View File
@@ -1,3 +1,5 @@
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -7,6 +9,7 @@ from app.dependencies import get_current_user
from app.models import User
from app.schemas.auth import LoginRequest
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
from app.schemas.session import SessionRead
from app.schemas.user import ProfileUpdate, UserRead
from app.services.auth_service import (
AccountDeactivatedError,
@@ -22,7 +25,15 @@ from app.services.password_service import (
request_password_reset,
validate_reset_token,
)
from app.services.session_service import (
SessionNotFoundError,
list_sessions,
revoke_session,
revoke_session_unchecked,
start_session,
)
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.services.user_agent_service import describe_user_agent
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
InvalidImageError,
@@ -55,12 +66,15 @@ async def login(
except AccountDeactivatedError:
raise HTTPException(status_code=401, detail="Account is deactivated")
request.session["user_id"] = str(user.id)
await start_session(request, db, user.id)
return user
@router.post("/logout", status_code=204)
async def logout(request: Request) -> Response:
async def logout(request: Request, db: AsyncSession = Depends(get_db)) -> Response:
session_id = request.session.get("session_id")
if session_id:
await revoke_session_unchecked(db, uuid.UUID(session_id))
request.session.clear()
return Response(status_code=204)
@@ -218,5 +232,37 @@ async def complete_reset_password_endpoint(
except PasswordResetInvalidError:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
request.session["user_id"] = str(user.id)
await start_session(request, db, user.id)
return user
@router.get("/sessions", response_model=list[SessionRead])
async def list_sessions_endpoint(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
sessions = await list_sessions(db, current_user.id)
return [
SessionRead(
id=s.id,
ip_address=s.ip_address,
device_label=describe_user_agent(s.user_agent),
created_at=s.created_at,
last_seen_at=s.last_seen_at,
is_current=s.id == request.state.session_id,
)
for s in sessions
]
@router.delete("/sessions/{session_id}", status_code=204)
async def revoke_session_endpoint(
session_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await revoke_session(db, current_user.id, session_id)
except SessionNotFoundError:
raise HTTPException(status_code=404, detail="Session not found")
+2 -1
View File
@@ -5,6 +5,7 @@ from app.database import get_db
from app.schemas.site_invite import SignupComplete, SignupValidateRead
from app.schemas.user import UserRead
from app.services.auth_service import DuplicateUserError
from app.services.session_service import start_session
from app.services.site_invite_service import (
SiteInviteInvalidError,
complete_signup,
@@ -39,5 +40,5 @@ async def complete_signup_endpoint(
except DuplicateUserError:
raise HTTPException(status_code=409, detail="That username or email is already taken")
request.session["user_id"] = str(user.id)
await start_session(request, db, user.id)
return user
+20
View File
@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class SessionRead(BaseModel):
id: uuid.UUID
ip_address: str | None
# Parsed from the stored user_agent by the router (see
# user_agent_service.describe_user_agent) -- not a stored column, so a
# future improvement to the parser applies retroactively to old rows
# too.
device_label: str
created_at: datetime
last_seen_at: datetime
# Whether this is the session the request making this call is itself
# authenticated with -- lets the UI mark "this device" and treat
# revoking it as a self-logout rather than just another row.
is_current: bool
+100
View File
@@ -0,0 +1,100 @@
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Session
# #69: how stale last_seen_at has to be before a request bothers updating
# it. get_current_user resolves a session on *every* authenticated
# request (dozens per minute per active browser tab, between message
# polling, presence, etc.) -- writing+committing on every single one would
# turn a read into a write storm for no real benefit, since "active
# sessions" only needs last-seen accurate to within a few minutes, not to
# the second.
LAST_SEEN_THROTTLE = timedelta(minutes=5)
class SessionNotFoundError(Exception):
pass
def get_client_ip(request_or_websocket) -> str | None:
# X-Forwarded-For's first entry is the original client -- everything
# after it was appended by intermediate proxies. Production runs
# behind Nginx Proxy Manager (see backend/README.md's "Admin portal"
# section preamble), which sets this; local dev has nothing in front
# of the app, so this falls back to the direct peer address.
forwarded = request_or_websocket.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
client = request_or_websocket.client
return client.host if client else None
async def create_session(
db: AsyncSession, user_id: uuid.UUID, ip_address: str | None, user_agent: str | None
) -> Session:
session = Session(user_id=user_id, ip_address=ip_address, user_agent=user_agent)
db.add(session)
await db.commit()
await db.refresh(session)
return session
async def start_session(request: Request, db: AsyncSession, user_id: uuid.UUID) -> Session:
"""Every "log this browser in" call site (login, reset-password
completion, signup completion) needs the exact same three steps --
read the request's IP/UA, create the row, stash its id in the signed
cookie -- so this is the one place that combination lives."""
session = await create_session(db, user_id, get_client_ip(request), request.headers.get("user-agent"))
request.session["session_id"] = str(session.id)
return session
async def resolve_session(db: AsyncSession, session_id: uuid.UUID) -> Session | None:
"""Returns the session iff it exists and hasn't been revoked -- the
single choke point get_current_user and the WS handshake both go
through, so revoking a session (this endpoint or another device's
"sign out") takes effect on that session's very next request rather
than only once its signed cookie happens to expire."""
session = await db.get(Session, session_id)
if session is None or session.revoked_at is not None:
return None
now = datetime.now(timezone.utc)
if now - session.last_seen_at > LAST_SEEN_THROTTLE:
session.last_seen_at = now
await db.commit()
return session
async def list_sessions(db: AsyncSession, user_id: uuid.UUID) -> list[Session]:
result = await db.execute(
select(Session)
.where(Session.user_id == user_id, Session.revoked_at.is_(None))
.order_by(Session.last_seen_at.desc())
)
return list(result.scalars().all())
async def revoke_session(db: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID) -> None:
session = await db.get(Session, session_id)
if session is None or session.user_id != user_id or session.revoked_at is not None:
raise SessionNotFoundError()
session.revoked_at = datetime.now(timezone.utc)
await db.commit()
async def revoke_session_unchecked(db: AsyncSession, session_id: uuid.UUID) -> None:
"""Logout's own path -- no ownership check needed (a session can only
ever log itself out) and silently does nothing for a session that's
missing or already revoked, since "sign this browser out" should
never itself fail."""
session = await db.get(Session, session_id)
if session is None or session.revoked_at is not None:
return
session.revoked_at = datetime.now(timezone.utc)
await db.commit()
@@ -0,0 +1,46 @@
def describe_user_agent(user_agent: str | None) -> str:
"""A short, human-readable "Browser on OS" label for the sessions list
-- no dependency pulled in for this (the app has stayed on a
zero-subdependency-when-possible diet, see MessageContent.tsx's
markdown-to-jsx choice), just substring checks against the handful of
tokens that actually distinguish the browsers/platforms this app's
users run. Order matters: Electron and Edge both also contain
"Chrome/", and Chrome-on-iOS/Safari-on-iOS both contain "Safari/", so
the more specific token has to be checked first.
"""
if not user_agent:
return "Unknown device"
if "Electron/" in user_agent:
browser = "DS Chat Desktop"
elif "Edg/" in user_agent:
browser = "Edge"
elif "OPR/" in user_agent:
browser = "Opera"
elif "Firefox/" in user_agent:
browser = "Firefox"
elif "Chrome/" in user_agent:
browser = "Chrome"
elif "CriOS/" in user_agent:
browser = "Chrome"
elif "Safari/" in user_agent:
browser = "Safari"
else:
browser = "Unknown browser"
if "Windows" in user_agent:
os_name = "Windows"
elif "Mac OS X" in user_agent and ("iPhone" in user_agent or "iPad" in user_agent):
os_name = "iOS"
elif "Mac OS X" in user_agent:
os_name = "macOS"
elif "Android" in user_agent:
os_name = "Android"
elif "Linux" in user_agent:
os_name = "Linux"
else:
os_name = "Unknown OS"
if browser == "DS Chat Desktop":
return f"{browser} ({os_name})"
return f"{browser} on {os_name}"
+8 -3
View File
@@ -25,6 +25,7 @@ from app.services.message_service import (
toggle_reaction,
)
from app.services.room_service import mark_room_read
from app.services.session_service import resolve_session
router = APIRouter(tags=["ws"])
@@ -75,11 +76,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
return
user, api_token = resolved
else:
user_id_raw = websocket.session.get("user_id")
if not user_id_raw:
session_id_raw = websocket.session.get("session_id")
if not session_id_raw:
await websocket.close(code=WS_UNAUTHENTICATED)
return
user = await db.get(User, uuid.UUID(user_id_raw))
session = await resolve_session(db, uuid.UUID(session_id_raw))
if session is None:
await websocket.close(code=WS_UNAUTHENTICATED)
return
user = await db.get(User, session.user_id)
if user is None or not user.is_active:
await websocket.close(code=WS_UNAUTHENTICATED)
return
+112
View File
@@ -0,0 +1,112 @@
import uuid
from httpx import ASGITransport, AsyncClient
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from tests.conftest import login_as, register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
async def test_login_creates_a_session(client, db_session):
username = _unique("alice")
await register_user(
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
)
resp = await client.post(
"/api/auth/login",
json={"username_or_email": username, "password": "password123"},
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36"},
)
assert resp.status_code == 200, resp.text
sessions = (await client.get("/api/auth/sessions")).json()
assert len(sessions) == 1
assert sessions[0]["is_current"] is True
assert sessions[0]["device_label"] == "Chrome on Windows"
assert sessions[0]["ip_address"]
async def test_logout_revokes_the_session(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
assert len((await client.get("/api/auth/sessions")).json()) == 1
await client.post("/api/auth/logout")
# The important part is server-side: the session row itself is gone
# from the active list (this request also 401s since this client's own
# cookie was cleared, but that alone wouldn't prove the *row* is dead).
resp = await client.get("/api/auth/sessions")
assert resp.status_code == 401
async def test_x_forwarded_for_takes_priority_over_direct_peer(client, db_session):
username = _unique("alice")
await register_user(
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
)
await client.post(
"/api/auth/login",
json={"username_or_email": username, "password": "password123"},
headers={"X-Forwarded-For": "203.0.113.7, 10.0.0.1"},
)
sessions = (await client.get("/api/auth/sessions")).json()
assert sessions[0]["ip_address"] == "203.0.113.7"
async def test_revoking_another_session_logs_it_out(client, app, db_session):
username = _unique("alice")
await register_and_login(client, db_session, username=username)
# A second "device" -- a separate client hitting the same app instance
# (so it shares the DB-override wiring), its own independent cookie.
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as other_device:
await login_as(other_device, username)
assert len((await other_device.get("/api/auth/sessions")).json()) == 2
mine = (await client.get("/api/auth/sessions")).json()
assert len(mine) == 2
not_current = next(s for s in mine if not s["is_current"])
revoke = await client.delete(f"/api/auth/sessions/{not_current['id']}")
assert revoke.status_code == 204
# The other device's own cookie is now dead.
resp = await other_device.get("/api/auth/sessions")
assert resp.status_code == 401
# And the revoked session no longer shows up for the account at all.
remaining = (await client.get("/api/auth/sessions")).json()
assert len(remaining) == 1
assert remaining[0]["is_current"] is True
async def test_cannot_revoke_another_users_session(client, app, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
my_session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
await register_and_login(bob_client, db_session, username=_unique("bob"))
resp = await bob_client.delete(f"/api/auth/sessions/{my_session_id}")
assert resp.status_code == 404
# Untouched -- still exactly one active session for alice.
assert len((await client.get("/api/auth/sessions")).json()) == 1
async def test_revoking_own_current_session_works_then_logs_this_client_out(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
# Revoking your own *current* session is allowed (a remote sign-out of
# this same device is a legitimate, if odd, thing to do).
first = await client.delete(f"/api/auth/sessions/{session_id}")
assert first.status_code == 204
# A second call with the same (now-dead) cookie can't even reach the
# revoke check -- get_current_user itself already 401s.
second = await client.delete(f"/api/auth/sessions/{session_id}")
assert second.status_code == 401
+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