diff --git a/backend/app/api/endpoints/account_security.py b/backend/app/api/endpoints/account_security.py
index d54916d8..c03a1ab8 100644
--- a/backend/app/api/endpoints/account_security.py
+++ b/backend/app/api/endpoints/account_security.py
@@ -23,6 +23,7 @@ from app.models.account import Account
from app.models.refresh_token import RefreshToken
from app.models.user import User
from app.schemas.account_security import (
+ ActiveUser,
RevokeSessionsRequest,
RevokeSessionsResponse,
SessionPolicyResponse,
@@ -32,7 +33,9 @@ from app.schemas.account_security import (
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
-def _policy_response(account: Account) -> SessionPolicyResponse:
+def _policy_response(
+ account: Account, active_users: list[ActiveUser]
+) -> SessionPolicyResponse:
eff_idle, eff_abs = resolve_session_policy(account)
return SessionPolicyResponse(
idle_minutes=account.session_idle_minutes,
@@ -43,6 +46,7 @@ def _policy_response(account: Account) -> SessionPolicyResponse:
idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX,
absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN,
absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX,
+ active_users=active_users,
)
@@ -52,13 +56,33 @@ async def _load_account(db: AsyncSession, account_id) -> Account:
).scalar_one()
+async def _load_active_users(db: AsyncSession, account_id) -> list[ActiveUser]:
+ """Return distinct users in this account who currently hold an
+ un-revoked refresh token. See plan §4.7."""
+ from app.models.refresh_token import RefreshToken
+
+ stmt = (
+ select(User.id, User.name, User.email, User.last_login)
+ .join(RefreshToken, RefreshToken.user_id == User.id)
+ .where(User.account_id == account_id, RefreshToken.revoked_at.is_(None))
+ .distinct()
+ .order_by(User.last_login.desc().nulls_last())
+ )
+ rows = (await db.execute(stmt)).all()
+ return [
+ ActiveUser(user_id=row.id, name=row.name, email=row.email, last_login_at=row.last_login)
+ for row in rows
+ ]
+
+
@router.get("", response_model=SessionPolicyResponse)
async def get_session_policy(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
account = await _load_account(db, current_user.account_id)
- return _policy_response(account)
+ active_users = await _load_active_users(db, current_user.account_id)
+ return _policy_response(account, active_users)
@router.patch("", response_model=SessionPolicyResponse)
@@ -139,7 +163,8 @@ async def update_session_policy(
)
await db.commit()
await db.refresh(account)
- return _policy_response(account)
+ active_users = await _load_active_users(db, account.id)
+ return _policy_response(account, active_users)
@router.post("/revoke-sessions", response_model=RevokeSessionsResponse)
diff --git a/backend/app/schemas/account_security.py b/backend/app/schemas/account_security.py
index f70d607b..d66a8366 100644
--- a/backend/app/schemas/account_security.py
+++ b/backend/app/schemas/account_security.py
@@ -2,11 +2,28 @@
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.
@@ -32,6 +49,10 @@ class SessionPolicyResponse(BaseModel):
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.
diff --git a/backend/tests/test_session_policy.py b/backend/tests/test_session_policy.py
index 2d7c3f99..b1fd0e67 100644
--- a/backend/tests/test_session_policy.py
+++ b/backend/tests/test_session_policy.py
@@ -203,7 +203,7 @@ class TestSessionPolicyEndpoint:
@pytest.mark.asyncio
async def test_get_returns_defaults_and_bounds(
- self, client: AsyncClient, auth_headers: dict
+ self, client: AsyncClient, auth_headers: dict, test_user: dict
):
response = await client.get(
"/api/v1/accounts/me/security", headers=auth_headers
@@ -221,6 +221,19 @@ class TestSessionPolicyEndpoint:
assert body["absolute_minutes_min"] == 60
assert body["absolute_minutes_max"] == 129600
+ # active_users reflects users with un-revoked refresh tokens.
+ # auth_headers logged the owner in once, so they should appear.
+ assert isinstance(body["active_users"], list)
+ assert len(body["active_users"]) >= 1
+ emails = [u["email"] for u in body["active_users"]]
+ assert test_user["email"] in emails
+ # Schema check on one row.
+ first = body["active_users"][0]
+ assert "user_id" in first
+ assert "name" in first
+ assert "email" in first
+ assert "last_login_at" in first
+
@pytest.mark.asyncio
async def test_patch_persists_override_and_returns_new_state(
self, client: AsyncClient, auth_headers: dict
diff --git a/docs/plans/2026-05-13-session-expiration-policy.md b/docs/plans/2026-05-13-session-expiration-policy.md
index 4431bd4e..184c5a67 100644
--- a/docs/plans/2026-05-13-session-expiration-policy.md
+++ b/docs/plans/2026-05-13-session-expiration-policy.md
@@ -172,12 +172,19 @@ Each of the four token-issuing endpoints (login, login/json, both OAuth callback
New endpoint module: `backend/app/api/endpoints/account_security.py`
```
-GET /accounts/me/security → returns {idle_minutes, absolute_minutes, effective_idle_minutes, effective_absolute_minutes, system_min/max bounds}
+GET /accounts/me/security → returns {
+ idle_minutes, absolute_minutes,
+ effective_idle_minutes, effective_absolute_minutes,
+ system_min/max bounds,
+ active_users: [{user_id, name, email, last_login_at}, ...]
+ }
PATCH /accounts/me/security → owner only; validates bounds + invariant; writes account row
```
`require_account_owner` from `app/api/deps.py:189` enforces ownership. Returns the *effective* values (after defaults applied) so the frontend doesn't have to know about NULL semantics.
+**`active_users` field** (added during plan-design-review pass on 2026-05-13): the GET response includes a list of users with at least one un-revoked refresh token in this account. Query: `SELECT DISTINCT u.id, u.email, u.name, u.last_login FROM users u JOIN refresh_tokens rt ON rt.user_id = u.id WHERE u.account_id = :acct AND rt.revoked_at IS NULL`. The frontend uses this to render the "Active sessions" section with names + relative last-login timestamps (see §4.8) rather than a faceless count. Caveat: `last_login` updates only at login, not on refresh — so the relative timestamp is honest about "when they signed in," not "last touched the app." Per-refresh activity needs the deferred `refresh_tokens.last_used_at` follow-up (§9).
+
### 4.8 Frontend changes
**Response-field naming (single scheme, used everywhere):**
@@ -197,6 +204,12 @@ ISO strings (not Unix ints) for consistency with the rest of the API surface, wh
- "soon" fires at T-5min on whichever window comes first.
- Pairs with a top-of-app `` mounted in `AppLayout.tsx`.
+**SessionExpiryToast — differentiated by `reason`** (locked during plan-design-review):
+- **`reason === "idle"`** (idle window is closer): warning-amber tone. Copy: *"Your session times out in 5 minutes."* Action button: `[Stay signed in]` → triggers a manual `/auth/refresh` call (resets the idle window). On success, toast dismisses + the store updates `idleExpiresAt`. On failure (e.g. absolute cap is also nearby and the refresh hits `session_expired_absolute`), fall through to the standard 401-handling redirect.
+- **`reason === "absolute"`** (absolute window is closer): info-cyan tone (matching the `?reason=session_expired` banner). Copy: *"Your session ends at HH:MM for security. You'll need to sign in again."* No action button — nothing the user can do extends an absolute cap. Optional secondary action: `[Sign in now]` link to `/login` for users who want to re-auth proactively.
+- Toast does not auto-dismiss (persists until acted on or window expires).
+- Re-fires only after a successful `/auth/refresh` extends the idle window past T-5min and we cross back into "soon" later. Does not nag.
+
**Modified:** `frontend/src/api/client.ts` interceptor
- On 401 with `detail="session_expired_absolute"` **or** `detail="session_expired_idle"`: **skip the refresh attempt**, flush tokens, redirect to `/login?reason=session_expired`. (Both surfaces go through the same banner — users don't need to distinguish the two.)
- On 401 with `detail="invalid_refresh_token"` or any other detail: current behavior (drop to `/login` without the reason banner).
@@ -211,14 +224,44 @@ ISO strings (not Unix ints) for consistency with the rest of the API surface, wh
- The `setTokens({...})` call at `OAuthCallbackPage.tsx:102` currently passes `{access_token, refresh_token, token_type}` from the `OAuthCallbackResponse`. Add `idle_expires_at` and `absolute_expires_at` to the spread so OAuth-issued sessions get the same expiry metadata as password logins.
**New page:** `frontend/src/pages/account/AccountSecuritySettingsPage.tsx`
-- Lives under existing `/account` routing with `requireRoleOwner` style guard.
-- Two preset tiers — **Strict (3d/14d)** and **Standard (7d/30d)** — plus a **Custom** tier with two numeric inputs (idle/absolute in days).
-- Hint copy showing the system min/max from the GET response.
-- Save → PATCH → toast.
-- Below the form, an info line: *"Policy changes apply to new logins. Existing sessions continue under the policy in effect at their login time. To force-logout existing sessions, use the actions below."*
-- A separate "**Active sessions**" section with two actions (see §4.11):
- - **Sign out everyone except me** (secondary button) — revokes other users' sessions in this account, leaves the caller signed in.
- - **Sign out everyone, including me** (destructive-style button) — revokes all sessions for the account; the caller is immediately redirected to `/login`. Confirmation modal required.
+- Lives under existing `/account` routing with `requireRoleOwner` style guard. Card lives in `AccountSettingsPage.tsx` grid alongside Branding / Chat Retention; **hidden entirely for non-owners** (matches existing role-conditional rendering at `AccountSettingsPage.tsx:597-651`).
+- Page shell matches `ChatRetentionSettingsPage.tsx`: `max-w-2xl mx-auto py-8 px-6`, header row with Lucide icon + Bricolage 22px page title, `card-flat rounded-2xl p-6 space-y-6` body.
+- **Vertical order (top → bottom):**
+ 1. Page header (Lucide `Shield` icon + "Session Security")
+ 2. One-line intro paragraph (`text-muted-foreground`): *"Control how long sessions can last before users must sign in again."*
+ 3. **Session policy** card: three radios (Strict / Standard / Custom) with effective minute values visible per option ("Strict — 3d idle, 14d absolute"), then two numeric inputs (Idle minutes, Absolute minutes). **Inputs are always visible; disabled when a preset is selected.** Below inputs: hint text showing the system min/max from the GET response. Save button (primary) + inline `text-emerald-400 "Settings saved"` success ping for 3s after save (matching `ChatRetentionSettingsPage.tsx:112-114`).
+ 4. Info line directly below Save: *"New policy applies the next time each person signs in. Use **Active sessions** below to force it immediately."* (`text-muted-foreground`, bold on "Active sessions" — anchor link or just visual emphasis).
+ 5. Visual divider (1px `border-default`).
+ 6. **Active sessions** section (see below for details).
+- **Initial GET loading state:** centered `Loader2 animate-spin` page-body, matching `ChatRetentionSettingsPage.tsx:46-51`.
+- **Inline validation** on Custom inputs: debounced 300ms; red border (`border-danger`) + small error text below field; Save button disabled when any field is invalid. Server-side 422 from PATCH surfaces via the existing axios interceptor toast.
+
+**Active sessions section (within the same page):**
+- GET response includes `active_users: [{user_id, name, email, last_login_at}, ...]` — backend addition; see §4.7.
+- Section header: "Active sessions"
+- Subhead: "N people are signed in to this account." (singular: "Only you are signed in.")
+- Active-users list: one row per active user — `name (email) · logged in 2d ago` (relative time from `last_login_at`). Caller's own row marked with a small "(you)" tag.
+- Buttons below the list — count-aware:
+ - **count > 1:** Two ghost buttons side-by-side — `[Sign out everyone except me]` and `[Sign me out and everyone else]` (the latter uses `text-danger` color to telegraph the self-impact).
+ - **count = 1 (solo owner):** Hide the "except me" button (it would revoke 0 — confusing). Show only `[Sign me out everywhere]` (still useful — signs the owner out from their other devices).
+
+**Bulk-revoke confirmation modal** (via `components/common/Modal.tsx`):
+- **scope=others:** title *"Sign out other users?"* · body *"This signs out the N other active users in your account. They'll need to sign in again. You stay signed in."* · buttons `[Cancel]` (ghost) + `[Sign out N users]` (`text-danger`).
+- **scope=all:** title *"Sign out everyone?"* · body *"This signs out all N active users including yourself. Everyone will need to sign in again."* · buttons `[Cancel]` (ghost) + `[Sign out everyone]` (`text-danger`).
+- After success: modal closes, `toast.success("Signed out N sessions")`. For scope=all: 1.5s delay → `useAuthStore.getState().logout()` + `window.location = '/login'` (no banner — they just did this, they know why they're here).
+
+**Modified:** `AccountSettingsPage.tsx`
+- Add a "Session Security" link card to the existing grid (owner-only visibility, alongside Branding / Chat Retention). Lucide `Shield` icon.
+
+**New login page banner:** when `?reason=session_expired` is present, show a small info-tone banner **above the email/password form**:
+- Background: `info-dim` (cyan-dim, `rgba(103,232,249,0.10)` dark / `rgba(8,145,178,0.07)` light per DESIGN-SYSTEM.md)
+- Text color: `info` text token
+- Border: `1px solid info-dim`
+- Padding: 12px 16px, `radius-sm` (5px)
+- Icon: Lucide `Info` (16px, info color, left-aligned)
+- Copy: *"You were signed out for security. Sign back in to continue."*
+- Not dismissable — disappears naturally when the user submits the form (the query string clears on navigate).
+- Note: this is the first cyan info-tone banner in the app; sets the precedent we'll reuse for future neutral system messages.
**Modified:** `AccountSettingsPage.tsx`
- Add a "Session Security" link card to the existing grid (owner-only visibility).
@@ -412,7 +455,7 @@ Follow-up issues to file after this plan is approved (not blocking this PR):
5. `feat(api): add GET/PATCH /accounts/me/security endpoint` (router, schemas, owner gate, bounds + partial-override invariant validation, audit logging on PATCH).
6. `feat(api): add POST /accounts/me/security/revoke-sessions` (bulk-revoke endpoint with `scope=all|others`, single-UPDATE implementation, audit logging, tests #17–#22).
7. `feat(ui): handle session_expired_{idle,absolute} in axios interceptor + authStore` (new fields persisted, legacy-state migration, redirect to `/login?reason=session_expired`).
-8. `feat(ui): add AccountSecuritySettingsPage + AppLayout toast + login banner` (Strict/Standard/Custom presets, Active Sessions section with two revoke buttons + confirmation modal, `useAuthSessionExpiry`, expiry-soon toast, `?reason=session_expired` banner).
+8. `feat: AccountSecuritySettingsPage + active-users list + toasts + login banner` (Strict/Standard/Custom presets with always-visible-disabled Custom inputs, count-aware Active Sessions section with name/email/last-login rows, differentiated SessionExpiryToast for idle-vs-absolute, cyan info-tone login banner, scope=all auto-redirect-after-toast UX. Includes a small backend addition: `active_users` field on `GET /accounts/me/security` — see §4.7).
9. `docs: add decision entry + update CURRENT-STATE auth surface` (`.ai/DECISIONS.md`, `CURRENT-STATE.md`).
Each commit independently passes `pytest --override-ini="addopts="` and `npm run build`. The two backend behavior gates (#2 and #4) ship behind no flag — they're the point of the work — but they're sequenced so any rollback is a single commit.
@@ -433,3 +476,18 @@ Each commit independently passes `pytest --override-ini="addopts="` and `npm run
- [ ] Final approval on commit sequence in §10.
- [ ] No conflict with Phase O cutover sequencing (this can ship before OR after EIN/Stripe lands; independent path).
- [ ] File the kill-all-sessions follow-up issue per §9 before implementation begins, so the Account Security page can link to it (or leave the support-contact copy in place).
+
+---
+
+## GSTACK REVIEW REPORT
+
+| Review | Trigger | Why | Runs | Status | Findings |
+|--------|---------|-----|------|--------|----------|
+| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | not run |
+| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | not run |
+| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | not run (the plan itself was eng-reviewed inline across 7 commits — backend complete & green) |
+| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (PLAN) | score: 4/10 → 9/10, 7 decisions added |
+| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |
+
+**UNRESOLVED:** 0 design decisions; 3 plan-level checklist items remain (system bounds, commit sequence, Phase O sequencing — none block design).
+**VERDICT:** DESIGN CLEARED — page layout, state coverage, post-revoke flow, toast logic, login banner tone, and form copy all locked. Commit 8 has a complete spec.
diff --git a/frontend/src/api/accountSecurity.ts b/frontend/src/api/accountSecurity.ts
new file mode 100644
index 00000000..cd7166ba
--- /dev/null
+++ b/frontend/src/api/accountSecurity.ts
@@ -0,0 +1,49 @@
+import apiClient from './client'
+
+export interface ActiveUser {
+ user_id: string
+ name: string
+ email: string
+ last_login_at: string | null
+}
+
+export interface SessionPolicyResponse {
+ idle_minutes: number | null
+ absolute_minutes: number | null
+ effective_idle_minutes: number
+ effective_absolute_minutes: number
+ idle_minutes_min: number
+ idle_minutes_max: number
+ absolute_minutes_min: number
+ absolute_minutes_max: number
+ active_users: ActiveUser[]
+}
+
+export interface SessionPolicyUpdateRequest {
+ idle_minutes: number | null
+ absolute_minutes: number | null
+}
+
+export interface RevokeSessionsResponse {
+ revoked_count: number
+}
+
+export const accountSecurityApi = {
+ async get(): Promise {
+ const response = await apiClient.get('/accounts/me/security')
+ return response.data
+ },
+
+ async update(body: SessionPolicyUpdateRequest): Promise {
+ const response = await apiClient.patch('/accounts/me/security', body)
+ return response.data
+ },
+
+ async revokeSessions(scope: 'all' | 'others'): Promise {
+ const response = await apiClient.post(
+ '/accounts/me/security/revoke-sessions',
+ { scope },
+ )
+ return response.data
+ },
+}
diff --git a/frontend/src/components/account/RevokeSessionsModal.tsx b/frontend/src/components/account/RevokeSessionsModal.tsx
new file mode 100644
index 00000000..9fe853fb
--- /dev/null
+++ b/frontend/src/components/account/RevokeSessionsModal.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react'
+import { Modal } from '@/components/common/Modal'
+import { cn } from '@/lib/utils'
+
+interface RevokeSessionsModalProps {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: () => Promise
+ scope: 'all' | 'others'
+ activeUserCount: number
+}
+
+/**
+ * Confirmation modal for bulk session revocation. Two scopes:
+ *
+ * - "others" — revokes other users' sessions, caller stays signed in.
+ * - "all" — revokes everyone including the caller; the parent handles
+ * the post-revoke auto-redirect to /login (see plan §4.8 D4).
+ */
+export function RevokeSessionsModal({
+ isOpen,
+ onClose,
+ onConfirm,
+ scope,
+ activeUserCount,
+}: RevokeSessionsModalProps) {
+ const [busy, setBusy] = useState(false)
+
+ const isAll = scope === 'all'
+ const otherCount = isAll ? activeUserCount : Math.max(activeUserCount - 1, 0)
+
+ const title = isAll ? 'Sign out everyone?' : 'Sign out other users?'
+ const body = isAll
+ ? `This signs out all ${activeUserCount} active users including yourself. Everyone will need to sign in again.`
+ : `This signs out the ${otherCount} other active users in your account. They'll need to sign in again. You stay signed in.`
+ const confirmLabel = isAll
+ ? 'Sign out everyone'
+ : otherCount === 1
+ ? 'Sign out 1 user'
+ : `Sign out ${otherCount} users`
+
+ const handleConfirm = async () => {
+ setBusy(true)
+ try {
+ await onConfirm()
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+ undefined : onClose}
+ title={title}
+ size="sm"
+ footer={
+
+
+
+
+ }
+ >
+
{body}
+
+ )
+}
diff --git a/frontend/src/components/common/SessionExpiryToast.tsx b/frontend/src/components/common/SessionExpiryToast.tsx
new file mode 100644
index 00000000..23ab37e3
--- /dev/null
+++ b/frontend/src/components/common/SessionExpiryToast.tsx
@@ -0,0 +1,125 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { AlertCircle, Info, X } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { useAuthSessionExpiry } from '@/hooks/useAuthSessionExpiry'
+import { authApi } from '@/api/auth'
+import { useAuthStore } from '@/store/authStore'
+
+/**
+ * Top-of-app notice that fires when the session is within 5 minutes of
+ * idle OR absolute expiry. Behavior differs by which window is closer
+ * (per docs/plans/2026-05-13-session-expiration-policy.md §4.8):
+ *
+ * - Idle: warning-amber tone, "Stay signed in" button hits /auth/refresh.
+ * - Absolute: info-cyan tone, no action — re-auth is required.
+ *
+ * Persists until the user dismisses, refreshes, or the window expires.
+ */
+export function SessionExpiryToast() {
+ const { warning, reason, idleExpiresAt, absoluteExpiresAt } = useAuthSessionExpiry()
+ const setTokens = useAuthStore((s) => s.setTokens)
+ const navigate = useNavigate()
+ const [busy, setBusy] = useState(false)
+ const [dismissed, setDismissed] = useState(false)
+
+ if (warning !== 'soon' || dismissed) return null
+
+ const handleStay = async () => {
+ setBusy(true)
+ try {
+ const refreshed = await authApi.refresh()
+ localStorage.setItem('access_token', refreshed.access_token)
+ localStorage.setItem('refresh_token', refreshed.refresh_token)
+ setTokens(refreshed)
+ setDismissed(true)
+ } catch {
+ // The axios interceptor handles the redirect on session_expired_*;
+ // if we land here, something else went wrong — just close the toast.
+ setDismissed(true)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const handleSignInNow = () => navigate('/login')
+
+ // ── Format the deadline for the absolute case ──
+ const deadline = reason === 'idle' ? idleExpiresAt : absoluteExpiresAt
+ const deadlineLabel = deadline
+ ? deadline.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
+ : ''
+
+ const isIdle = reason === 'idle'
+ const Icon = isIdle ? AlertCircle : Info
+
+ return (
+
+
+
+
+ {isIdle
+ ? 'Your session times out in 5 minutes.'
+ : `Your session ends at ${deadlineLabel} for security.`}
+
+
+ {isIdle
+ ? 'Click to stay signed in.'
+ : "You'll need to sign in again."}
+
+
+ {isIdle ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
index 952bf776..5eaaf104 100644
--- a/frontend/src/components/layout/AppLayout.tsx
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -12,6 +12,7 @@ import { EmailVerificationBanner } from './EmailVerificationBanner'
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
import { FeedbackWidget } from '@/components/common/FeedbackWidget'
+import { SessionExpiryToast } from '@/components/common/SessionExpiryToast'
import { cn } from '@/lib/utils'
export function AppLayout() {
@@ -69,6 +70,7 @@ export function AppLayout() {
return (
<>
+
['token']): ExpiryState {
+ const idleStr = token?.idle_expires_at
+ const absStr = token?.absolute_expires_at
+ if (!idleStr || !absStr) {
+ return { idleExpiresAt: null, absoluteExpiresAt: null, warning: 'none', reason: null }
+ }
+ const idle = new Date(idleStr)
+ const abs = new Date(absStr)
+ const now = Date.now()
+ const idleMs = idle.getTime() - now
+ const absMs = abs.getTime() - now
+
+ // Closer window wins.
+ const reason: ExpiryReason = idleMs <= absMs ? 'idle' : 'absolute'
+ const closestMs = Math.min(idleMs, absMs)
+
+ let warning: ExpiryWarning = 'none'
+ if (closestMs <= 0) warning = 'now'
+ else if (closestMs <= SOON_MS) warning = 'soon'
+
+ return { idleExpiresAt: idle, absoluteExpiresAt: abs, warning, reason }
+}
+
+/**
+ * Track how close the active session is to its idle/absolute deadline.
+ *
+ * Returns `warning: "soon"` within 5 min of whichever window comes first,
+ * and `reason: "idle" | "absolute"` so callers can choose the right UX
+ * (idle is recoverable via /auth/refresh; absolute is not). Re-evaluates
+ * every 30 seconds while authenticated; cheap (single Date subtraction).
+ *
+ * See docs/plans/2026-05-13-session-expiration-policy.md §4.8.
+ */
+export function useAuthSessionExpiry(): ExpiryState {
+ const token = useAuthStore((s) => s.token)
+ const [state, setState] = useState(() => computeState(token))
+
+ useEffect(() => {
+ setState(computeState(token))
+ if (!token?.idle_expires_at || !token?.absolute_expires_at) return
+ const interval = window.setInterval(() => setState(computeState(token)), 30_000)
+ return () => window.clearInterval(interval)
+ }, [token])
+
+ return state
+}
diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx
index 35e79945..8b888f36 100644
--- a/frontend/src/pages/AccountSettingsPage.tsx
+++ b/frontend/src/pages/AccountSettingsPage.tsx
@@ -17,6 +17,7 @@ import {
Plug,
RefreshCw,
Server,
+ Shield,
UserCog,
X,
} from 'lucide-react'
@@ -632,6 +633,12 @@ export function AccountSettingsPage() {
title="Chat retention"
description="Conversation retention and assistant data lifecycle"
/>
+ }
+ title="Session security"
+ description="Session-expiration policy and active sessions"
+ />
}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index 1a85b337..420fb8e2 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
+import { Info } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PasswordInput } from '@/components/common/PasswordInput'
@@ -17,6 +18,11 @@ export function LoginPage() {
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'
+ // When the user lands here after the session-policy axios interceptor
+ // forcibly logged them out, show a calm info-tone banner above the form.
+ // See docs/plans/2026-05-13-session-expiration-policy.md §4.8.
+ const showSessionExpiredBanner = new URLSearchParams(location.search).get('reason') === 'session_expired'
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLocalError('')
@@ -60,6 +66,15 @@ export function LoginPage() {
+ {showSessionExpiredBanner && (
+
+
+
+ You were signed out for security. Sign back in to continue.
+