Eighth commit in the session-expiration-policy series. Surfaces all
the owner controls and user-facing expiry UX that the prior commits
plumbed through, designed end-to-end via /plan-design-review (initial
4/10 -> final 9/10; 7 decisions locked in the plan).
Backend additions:
- accounts/me/security GET response gains active_users: list of
{user_id, name, email, last_login_at} for users in this account
with at least one un-revoked refresh token. Joined query on
refresh_tokens + users, distinct, ordered by last_login desc.
Drives the Active Sessions section.
Frontend additions:
- api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions.
- hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the
auth store, returns warning ('none'|'soon'|'now') + reason
('idle'|'absolute') so consumers can pick the right UX for the
closer window. Re-evaluates every 30s.
- components/common/SessionExpiryToast.tsx: top-of-app notice that
fires at T-5min. Idle case: warning-amber tone, [Stay signed in]
button hits authApi.refresh() and updates the store on success.
Absolute case: info-cyan tone, [Sign in now] link to /login (no
recoverable action). Dismissable, doesn't re-fire after dismissal.
- components/account/RevokeSessionsModal.tsx: confirmation modal for
the two bulk-revoke scopes. Title, body, and confirm-label vary by
scope; danger-styled confirm button.
- pages/account/AccountSecuritySettingsPage.tsx: the main page.
Header (Shield icon), intro, Policy card with Strict/Standard/Custom
radios + always-visible-disabled Custom inputs (idle/absolute
minutes) with inline validation, Save button + emerald success ping,
info note about 'applies at next login'. Active sessions card with
count-aware copy, list of {name, email, last-login-ago} rows
(caller tagged '(you)'), two buttons — 'except me' hidden when
count=1, 'sign me out and everyone else' uses danger-tinted styling.
- pages/AccountSettingsPage.tsx: 'Session security' row added to the
owner-only settings list.
- router.tsx: /account/security route, owner-gated via ProtectedRoute.
- pages/LoginPage.tsx: cyan info-tone banner above form when
?reason=session_expired is in the URL.
- components/layout/AppLayout.tsx: mounts <SessionExpiryToast />.
Scope=all bulk-revoke UX (the most jarring moment): on success,
toast.success(N sessions), 1.5s delay, then clear localStorage +
useAuthStore.logout() + window.location='/login' (no banner — the
owner just did this).
Backend tests: existing 22/22 still green plus the GET test now
asserts active_users is present + non-empty after login. Frontend:
tsc clean, authStore test 2/2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
78 lines
2.5 KiB
Python
78 lines
2.5 KiB
Python
"""Schemas for /accounts/me/security — session-policy management.
|
|
|
|
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11.
|
|
"""
|
|
from datetime import datetime
|
|
from typing import Literal, Optional
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class ActiveUser(BaseModel):
|
|
"""One row in the active-users list on GET /accounts/me/security.
|
|
|
|
Rendered as 'name (email) · logged in 2d ago' on the Account Security
|
|
page. `last_login_at` reflects the last successful sign-in, not the last
|
|
refresh-token use — that requires the deferred refresh_tokens.last_used_at
|
|
follow-up (see plan §9).
|
|
"""
|
|
|
|
user_id: UUID
|
|
name: str
|
|
email: str
|
|
last_login_at: Optional[datetime] = None
|
|
|
|
|
|
class SessionPolicyResponse(BaseModel):
|
|
"""GET /accounts/me/security — the policy in effect for this account.
|
|
|
|
Surfaces both the override (which may be NULL) and the effective value
|
|
(after defaults applied) so the frontend can show the current state
|
|
without re-implementing the defaults logic.
|
|
"""
|
|
|
|
# Per-account override values, NULL = "use system default."
|
|
idle_minutes: Optional[int] = Field(
|
|
default=None,
|
|
description="Account override; NULL means use the system default.",
|
|
)
|
|
absolute_minutes: Optional[int] = Field(default=None)
|
|
|
|
# Effective values after defaults applied (always non-NULL).
|
|
effective_idle_minutes: int
|
|
effective_absolute_minutes: int
|
|
|
|
# System-imposed bounds for the Custom-preset form inputs.
|
|
idle_minutes_min: int
|
|
idle_minutes_max: int
|
|
absolute_minutes_min: int
|
|
absolute_minutes_max: int
|
|
|
|
# Active sessions in this account — users with at least one un-revoked
|
|
# refresh token. Drives the Active Sessions section in the UI.
|
|
active_users: list[ActiveUser] = Field(default_factory=list)
|
|
|
|
|
|
class SessionPolicyUpdateRequest(BaseModel):
|
|
"""PATCH /accounts/me/security — set or clear the per-account override.
|
|
|
|
Pass `null` for either field to clear the override and fall back to the
|
|
system default. Both bounds checks and the idle <= absolute invariant
|
|
are validated against the *effective* values at the endpoint, since the
|
|
DB CHECK constraint only covers the both-set case.
|
|
"""
|
|
|
|
idle_minutes: Optional[int] = None
|
|
absolute_minutes: Optional[int] = None
|
|
|
|
|
|
class RevokeSessionsRequest(BaseModel):
|
|
"""POST /accounts/me/security/revoke-sessions — bulk-revoke refresh tokens."""
|
|
|
|
scope: Literal["all", "others"] = "all"
|
|
|
|
|
|
class RevokeSessionsResponse(BaseModel):
|
|
revoked_count: int
|