From b26643527d3fa146e88c7f3301c55d21ddbcd536 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Fri, 28 Aug 2026 20:22:50 -0600 Subject: [PATCH] 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 --- ...add_sessions_table_for_active_sessions_.py | 44 +++++++ backend/app/dependencies.py | 17 ++- backend/app/models/__init__.py | 2 + backend/app/models/session.py | 43 +++++++ backend/app/routers/auth.py | 52 +++++++- backend/app/routers/signup.py | 3 +- backend/app/schemas/session.py | 20 ++++ backend/app/services/session_service.py | 100 ++++++++++++++++ backend/app/services/user_agent_service.py | 46 +++++++ backend/app/ws/chat.py | 11 +- backend/tests/test_sessions.py | 112 ++++++++++++++++++ frontend/src/api/auth.ts | 12 +- frontend/src/components/Modal.css | 19 +++ frontend/src/components/ProfileModal.tsx | 77 +++++++++++- frontend/src/types.ts | 10 ++ 15 files changed, 554 insertions(+), 14 deletions(-) create mode 100644 backend/alembic/versions/319c30e24cd9_add_sessions_table_for_active_sessions_.py create mode 100644 backend/app/models/session.py create mode 100644 backend/app/schemas/session.py create mode 100644 backend/app/services/session_service.py create mode 100644 backend/app/services/user_agent_service.py create mode 100644 backend/tests/test_sessions.py diff --git a/backend/alembic/versions/319c30e24cd9_add_sessions_table_for_active_sessions_.py b/backend/alembic/versions/319c30e24cd9_add_sessions_table_for_active_sessions_.py new file mode 100644 index 0000000..ea40804 --- /dev/null +++ b/backend/alembic/versions/319c30e24cd9_add_sessions_table_for_active_sessions_.py @@ -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 ### diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 1d591f0..cca55f0 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 226f785..3587601 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..cd26e25 --- /dev/null +++ b/backend/app/models/session.py @@ -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") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 58b23c7..af16b08 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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") diff --git a/backend/app/routers/signup.py b/backend/app/routers/signup.py index 434b369..ddd10a3 100644 --- a/backend/app/routers/signup.py +++ b/backend/app/routers/signup.py @@ -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 diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py new file mode 100644 index 0000000..6f99429 --- /dev/null +++ b/backend/app/schemas/session.py @@ -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 diff --git a/backend/app/services/session_service.py b/backend/app/services/session_service.py new file mode 100644 index 0000000..5ca5af5 --- /dev/null +++ b/backend/app/services/session_service.py @@ -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() diff --git a/backend/app/services/user_agent_service.py b/backend/app/services/user_agent_service.py new file mode 100644 index 0000000..fa3e7e0 --- /dev/null +++ b/backend/app/services/user_agent_service.py @@ -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}" diff --git a/backend/app/ws/chat.py b/backend/app/ws/chat.py index fae5725..854a3e4 100644 --- a/backend/app/ws/chat.py +++ b/backend/app/ws/chat.py @@ -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 diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py new file mode 100644 index 0000000..51b64b2 --- /dev/null +++ b/backend/tests/test_sessions.py @@ -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 diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index a33df25..a195473 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -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 { return apiFetch('/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 { + return apiFetch('/api/auth/sessions') +} + +export function revokeSession(sessionId: string): Promise { + return apiFetch(`/api/auth/sessions/${sessionId}`, { method: 'DELETE' }) +} + export function me(): Promise { return apiFetch('/api/auth/me') } diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index 8a9ecf3..4466e9b 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -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; diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index 7b8f8fd..9c3ebd8 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -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(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([]) + const [sessionsError, setSessionsError] = useState(null) + const [revokingSessionId, setRevokingSessionId] = useState(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) { + +
+ +
Active sessions
+ {sessionsError &&

{sessionsError}

} + {sessions.length === 0 ? ( +

No active sessions.

+ ) : ( + sessions.map((session) => ( +
+
+
+ {session.device_label} + {session.is_current && ' · This device'} +
+
+ {session.ip_address ?? 'Unknown location'} · last active{' '} + {new Date(session.last_seen_at).toLocaleString()} +
+
+ +
+ )) + )} ) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5384447..5020d12 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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