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.database import get_db
from app.models import RoomMembership, RoomRole, User from app.models import RoomMembership, RoomRole, User
from app.services.bot_service import resolve_token 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} _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 request.state.api_token = token
return user return user
user_id = request.session.get("user_id") session_id = request.session.get("session_id")
if not user_id: if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated") 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 # Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
# be read without a MissingGreenlet -- selectinload skips the second # be read without a MissingGreenlet -- selectinload skips the second
# query entirely when active_custom_theme_id is null (the common case), # 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. # so this costs nothing for users who've never set a custom theme.
user = await db.get( 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: if user is None or not user.is_active:
request.session.clear() 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.password_reset import PasswordReset
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
from app.models.room import Room from app.models.room import Room
from app.models.session import Session
from app.models.site_invite import SiteInvite from app.models.site_invite import SiteInvite
from app.models.smtp_settings import SmtpSettings from app.models.smtp_settings import SmtpSettings
from app.models.upload_settings import UploadSettings from app.models.upload_settings import UploadSettings
@@ -35,6 +36,7 @@ __all__ = [
"MessageRoomReference", "MessageRoomReference",
"InviteStatus", "InviteStatus",
"PasswordReset", "PasswordReset",
"Session",
"SiteInvite", "SiteInvite",
"SmtpSettings", "SmtpSettings",
"UploadSettings", "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 fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -7,6 +9,7 @@ from app.dependencies import get_current_user
from app.models import User from app.models import User
from app.schemas.auth import LoginRequest from app.schemas.auth import LoginRequest
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
from app.schemas.session import SessionRead
from app.schemas.user import ProfileUpdate, UserRead from app.schemas.user import ProfileUpdate, UserRead
from app.services.auth_service import ( from app.services.auth_service import (
AccountDeactivatedError, AccountDeactivatedError,
@@ -22,7 +25,15 @@ from app.services.password_service import (
request_password_reset, request_password_reset,
validate_reset_token, 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.upload_settings_service import format_mb, get_upload_settings
from app.services.user_agent_service import describe_user_agent
from app.storage import ( from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES, ALLOWED_IMAGE_CONTENT_TYPES,
InvalidImageError, InvalidImageError,
@@ -55,12 +66,15 @@ async def login(
except AccountDeactivatedError: except AccountDeactivatedError:
raise HTTPException(status_code=401, detail="Account is deactivated") 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 return user
@router.post("/logout", status_code=204) @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() request.session.clear()
return Response(status_code=204) return Response(status_code=204)
@@ -218,5 +232,37 @@ async def complete_reset_password_endpoint(
except PasswordResetInvalidError: except PasswordResetInvalidError:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired") 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 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.site_invite import SignupComplete, SignupValidateRead
from app.schemas.user import UserRead from app.schemas.user import UserRead
from app.services.auth_service import DuplicateUserError from app.services.auth_service import DuplicateUserError
from app.services.session_service import start_session
from app.services.site_invite_service import ( from app.services.site_invite_service import (
SiteInviteInvalidError, SiteInviteInvalidError,
complete_signup, complete_signup,
@@ -39,5 +40,5 @@ async def complete_signup_endpoint(
except DuplicateUserError: except DuplicateUserError:
raise HTTPException(status_code=409, detail="That username or email is already taken") 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 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, toggle_reaction,
) )
from app.services.room_service import mark_room_read from app.services.room_service import mark_room_read
from app.services.session_service import resolve_session
router = APIRouter(tags=["ws"]) router = APIRouter(tags=["ws"])
@@ -75,11 +76,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
return return
user, api_token = resolved user, api_token = resolved
else: else:
user_id_raw = websocket.session.get("user_id") session_id_raw = websocket.session.get("session_id")
if not user_id_raw: if not session_id_raw:
await websocket.close(code=WS_UNAUTHENTICATED) await websocket.close(code=WS_UNAUTHENTICATED)
return 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: if user is None or not user.is_active:
await websocket.close(code=WS_UNAUTHENTICATED) await websocket.close(code=WS_UNAUTHENTICATED)
return 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 { 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 // 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 // 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' }) 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> { export function me(): Promise<User> {
return apiFetch<User>('/api/auth/me') return apiFetch<User>('/api/auth/me')
} }
+19
View File
@@ -429,6 +429,25 @@
color: var(--ds-muted); 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 { .profile-modal-avatar-row {
display: flex; display: flex;
align-items: center; align-items: center;
+74 -3
View File
@@ -1,5 +1,14 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' 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 { ApiError } from '../api/client'
import { import {
activateCustomTheme, activateCustomTheme,
@@ -12,7 +21,7 @@ import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar' import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme' 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 { ThemeBuilderModal } from './ThemeBuilderModal'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import './Modal.css' import './Modal.css'
@@ -44,7 +53,7 @@ interface ProfileModalProps {
} }
export function ProfileModal({ onClose }: ProfileModalProps) { export function ProfileModal({ onClose }: ProfileModalProps) {
const { user, updateUser } = useAuth() const { user, updateUser, logout } = useAuth()
const [displayName, setDisplayName] = useState(user?.display_name ?? '') const [displayName, setDisplayName] = useState(user?.display_name ?? '')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [savingName, setSavingName] = useState(false) const [savingName, setSavingName] = useState(false)
@@ -66,6 +75,10 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [passwordSuccess, setPasswordSuccess] = useState(false) const [passwordSuccess, setPasswordSuccess] = useState(false)
const [savingPassword, setSavingPassword] = 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(() => { useEffect(() => {
listCustomThemes() listCustomThemes()
.then(setCustomThemes) .then(setCustomThemes)
@@ -73,6 +86,12 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
// Non-critical -- the saved-themes list just stays empty; presets // Non-critical -- the saved-themes list just stays empty; presets
// and everything else in this modal still work fine. // 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 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 avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null
@@ -464,6 +504,37 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</button> </button>
</div> </div>
</form> </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>
</div> </div>
) )
+10
View File
@@ -42,6 +42,16 @@ export interface User {
created_at: string 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 { export interface UserDirectoryEntry {
id: string id: string
username: string username: string