feat: AccountSecuritySettingsPage + active-users list + toast + login banner

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>
This commit is contained in:
2026-05-13 17:07:14 -04:00
parent aad554bb9c
commit c7cd711859
13 changed files with 846 additions and 14 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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 `<SessionExpiryToast />` 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.

View File

@@ -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<SessionPolicyResponse> {
const response = await apiClient.get<SessionPolicyResponse>('/accounts/me/security')
return response.data
},
async update(body: SessionPolicyUpdateRequest): Promise<SessionPolicyResponse> {
const response = await apiClient.patch<SessionPolicyResponse>('/accounts/me/security', body)
return response.data
},
async revokeSessions(scope: 'all' | 'others'): Promise<RevokeSessionsResponse> {
const response = await apiClient.post<RevokeSessionsResponse>(
'/accounts/me/security/revoke-sessions',
{ scope },
)
return response.data
},
}

View File

@@ -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<void>
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 (
<Modal
isOpen={isOpen}
onClose={busy ? () => undefined : onClose}
title={title}
size="sm"
footer={
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={busy}
className={cn(
'rounded-md border px-4 py-2 text-sm font-medium',
'border-border text-foreground hover:bg-card-hover',
'disabled:opacity-50',
)}
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={busy}
className={cn(
'rounded-md border px-4 py-2 text-sm font-medium',
'border-danger/40 bg-danger/10 text-danger hover:bg-danger/15',
'disabled:opacity-50',
)}
>
{busy ? 'Working…' : confirmLabel}
</button>
</div>
}
>
<p className="text-sm text-foreground">{body}</p>
</Modal>
)
}

View File

@@ -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 (
<div
role="status"
aria-live="polite"
className={cn(
'fixed top-4 right-4 z-50 max-w-md rounded-lg border p-4 shadow-lg',
'flex items-start gap-3',
isIdle
? 'bg-warning-dim border-warning/30 text-warning'
: 'bg-info-dim border-info/30 text-info',
)}
>
<Icon size={18} className="mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{isIdle
? 'Your session times out in 5 minutes.'
: `Your session ends at ${deadlineLabel} for security.`}
</p>
<p className="mt-0.5 text-xs opacity-90">
{isIdle
? 'Click to stay signed in.'
: "You'll need to sign in again."}
</p>
<div className="mt-2 flex items-center gap-2">
{isIdle ? (
<button
type="button"
onClick={handleStay}
disabled={busy}
className={cn(
'rounded-md px-3 py-1.5 text-xs font-medium',
'bg-warning/20 text-warning border border-warning/40 hover:bg-warning/30',
'disabled:opacity-50',
)}
>
{busy ? 'Refreshing…' : 'Stay signed in'}
</button>
) : (
<button
type="button"
onClick={handleSignInNow}
className={cn(
'rounded-md px-3 py-1.5 text-xs font-medium',
'bg-info/20 text-info border border-info/40 hover:bg-info/30',
)}
>
Sign in now
</button>
)}
<button
type="button"
onClick={() => setDismissed(true)}
className="text-xs opacity-70 hover:opacity-100"
>
Dismiss
</button>
</div>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
className="shrink-0 opacity-70 hover:opacity-100"
>
<X size={14} />
</button>
</div>
)
}

View File

@@ -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 (
<>
<SessionExpiryToast />
<div
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
data-testid="app-shell"

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { useAuthStore } from '@/store/authStore'
const SOON_MS = 5 * 60 * 1000 // 5 minutes
export type ExpiryWarning = 'none' | 'soon' | 'now'
export type ExpiryReason = 'idle' | 'absolute'
interface ExpiryState {
idleExpiresAt: Date | null
absoluteExpiresAt: Date | null
warning: ExpiryWarning
/**
* Which window is the closer (and therefore the active) deadline. Used by
* SessionExpiryToast to pick the right copy + action button: idle gets
* "Stay signed in" (calls /auth/refresh); absolute is informational only.
*/
reason: ExpiryReason | null
}
function computeState(token: ReturnType<typeof useAuthStore.getState>['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<ExpiryState>(() => 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
}

View File

@@ -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"
/>
<SettingsRow
to="/account/security"
icon={<Shield className="h-4 w-4" />}
title="Session security"
description="Session-expiration policy and active sessions"
/>
<SettingsRow
to="/account/categories"
icon={<FolderTree className="h-4 w-4" />}

View File

@@ -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() {
</p>
</div>
{showSessionExpiredBanner && (
<div className="rounded-md border border-info/30 bg-info-dim px-4 py-3 flex items-start gap-2">
<Info size={16} className="text-info mt-0.5 shrink-0" />
<p className="text-sm text-info">
You were signed out for security. Sign back in to continue.
</p>
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-6" data-testid="login-form">
<div className="card-flat p-6 space-y-4">
{(error || localError) && (

View File

@@ -0,0 +1,353 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2, Save, Shield } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { useAuthStore } from '@/store/authStore'
import { accountSecurityApi, type SessionPolicyResponse } from '@/api/accountSecurity'
import { RevokeSessionsModal } from '@/components/account/RevokeSessionsModal'
type Preset = 'strict' | 'standard' | 'custom'
const PRESETS: Record<Exclude<Preset, 'custom'>, { idle: number; absolute: number; label: string; sub: string }> = {
strict: { idle: 4320, absolute: 20160, label: 'Strict', sub: '3 days idle · 14 days absolute' },
standard: { idle: 10080, absolute: 43200, label: 'Standard', sub: '7 days idle · 30 days absolute' },
}
function detectPreset(idle: number, absolute: number): Preset {
if (idle === PRESETS.strict.idle && absolute === PRESETS.strict.absolute) return 'strict'
if (idle === PRESETS.standard.idle && absolute === PRESETS.standard.absolute) return 'standard'
return 'custom'
}
function relativeFromNow(iso: string | null): string {
if (!iso) return 'unknown'
const diffMs = Date.now() - new Date(iso).getTime()
const m = Math.round(diffMs / 60_000)
if (m < 1) return 'just now'
if (m < 60) return `${m}m ago`
const h = Math.round(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.round(h / 24)
if (d < 30) return `${d}d ago`
return new Date(iso).toLocaleDateString()
}
export default function AccountSecuritySettingsPage() {
const currentUserId = useAuthStore((s) => s.user?.id) ?? null
const [data, setData] = useState<SessionPolicyResponse | null>(null)
const [loading, setLoading] = useState(true)
const [preset, setPreset] = useState<Preset>('strict')
const [idleMin, setIdleMin] = useState<string>('')
const [absMin, setAbsMin] = useState<string>('')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [modalScope, setModalScope] = useState<'all' | 'others' | null>(null)
useEffect(() => {
void load()
}, [])
const load = async () => {
setLoading(true)
try {
const res = await accountSecurityApi.get()
setData(res)
const eff = res
const detected = detectPreset(eff.effective_idle_minutes, eff.effective_absolute_minutes)
setPreset(detected)
setIdleMin(String(eff.effective_idle_minutes))
setAbsMin(String(eff.effective_absolute_minutes))
} catch {
toast.error('Could not load security settings')
} finally {
setLoading(false)
}
}
const customDisabled = preset !== 'custom'
// Sync the inputs to the chosen preset so the visible values track the radio.
const handlePresetChange = (next: Preset) => {
setPreset(next)
if (next === 'strict') {
setIdleMin(String(PRESETS.strict.idle))
setAbsMin(String(PRESETS.strict.absolute))
} else if (next === 'standard') {
setIdleMin(String(PRESETS.standard.idle))
setAbsMin(String(PRESETS.standard.absolute))
}
}
const validation = useMemo(() => {
if (!data) return { idleErr: null, absErr: null, ok: false }
const idle = parseInt(idleMin, 10)
const abs = parseInt(absMin, 10)
let idleErr: string | null = null
let absErr: string | null = null
if (!Number.isFinite(idle)) idleErr = 'Required'
else if (idle < data.idle_minutes_min || idle > data.idle_minutes_max) {
idleErr = `Between ${data.idle_minutes_min} and ${data.idle_minutes_max}`
}
if (!Number.isFinite(abs)) absErr = 'Required'
else if (abs < data.absolute_minutes_min || abs > data.absolute_minutes_max) {
absErr = `Between ${data.absolute_minutes_min} and ${data.absolute_minutes_max}`
}
if (!idleErr && !absErr && idle > abs) {
idleErr = 'Idle cannot exceed absolute'
}
return { idleErr, absErr, ok: !idleErr && !absErr }
}, [data, idleMin, absMin])
const handleSave = async () => {
if (!validation.ok || !data) return
setSaving(true)
setSuccess(false)
try {
const body =
preset === 'custom'
? { idle_minutes: parseInt(idleMin, 10), absolute_minutes: parseInt(absMin, 10) }
: preset === 'strict'
? { idle_minutes: PRESETS.strict.idle, absolute_minutes: PRESETS.strict.absolute }
: { idle_minutes: PRESETS.standard.idle, absolute_minutes: PRESETS.standard.absolute }
const updated = await accountSecurityApi.update(body)
setData(updated)
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} catch {
// Global axios interceptor surfaces 422 via toast.
} finally {
setSaving(false)
}
}
const handleRevokeConfirm = async () => {
if (!modalScope) return
const scope = modalScope
try {
const res = await accountSecurityApi.revokeSessions(scope)
toast.success(`Signed out ${res.revoked_count} sessions`)
setModalScope(null)
if (scope === 'all') {
// Per plan §4.8 D4: small delay so the user sees the toast,
// then clear local state and redirect to /login.
setTimeout(() => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
useAuthStore.getState().logout()
window.location.href = '/login'
}, 1500)
} else {
// scope=others: reload to reflect the new (shorter) active-users list.
await load()
}
} catch {
// global handler
}
}
if (loading || !data) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin text-primary" size={24} />
</div>
)
}
const activeUserCount = data.active_users.length
const solo = activeUserCount <= 1
return (
<div className="max-w-2xl mx-auto py-8 px-6 space-y-6">
<div className="flex items-center gap-3">
<Shield size={20} className="text-primary" />
<h1 className="text-xl font-heading font-bold text-foreground">Session Security</h1>
</div>
<p className="text-sm text-muted-foreground">
Control how long sessions can last before users must sign in again.
</p>
{/* ── Policy card ────────────────────────────────────────────────── */}
<div className="card-flat rounded-2xl p-6 space-y-5">
<fieldset className="space-y-3">
<legend className="text-sm font-medium text-foreground mb-1">Policy</legend>
{(['strict', 'standard', 'custom'] as const).map((p) => {
const isSelected = preset === p
const labels = p === 'custom'
? { label: 'Custom', sub: 'Set your own idle and absolute windows below' }
: PRESETS[p]
return (
<label
key={p}
className={cn(
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
isSelected
? 'border-primary/40 bg-primary/[0.06]'
: 'border-border hover:bg-card-hover',
)}
>
<input
type="radio"
name="preset"
value={p}
checked={isSelected}
onChange={() => handlePresetChange(p)}
className="mt-0.5"
/>
<div className="flex-1">
<div className="text-sm font-medium text-foreground">{labels.label}</div>
<div className="text-xs text-muted-foreground mt-0.5">{labels.sub}</div>
</div>
</label>
)
})}
</fieldset>
{/* Custom inputs — always visible, disabled outside Custom */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="font-sans text-xs uppercase tracking-widest text-muted-foreground block mb-1.5">
Idle minutes
</label>
<input
type="number"
value={idleMin}
onChange={(e) => setIdleMin(e.target.value)}
disabled={customDisabled}
min={data.idle_minutes_min}
max={data.idle_minutes_max}
className={cn(
'w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5',
'focus:outline-hidden focus:border-primary/30',
'disabled:opacity-50 disabled:cursor-not-allowed',
validation.idleErr && !customDisabled && 'border-danger/50',
)}
style={{
borderColor:
validation.idleErr && !customDisabled ? undefined : 'var(--color-border-default)',
}}
/>
<p className="text-[11px] text-muted-foreground mt-1">
{validation.idleErr && !customDisabled
? validation.idleErr
: `Between ${data.idle_minutes_min} and ${data.idle_minutes_max} min`}
</p>
</div>
<div>
<label className="font-sans text-xs uppercase tracking-widest text-muted-foreground block mb-1.5">
Absolute minutes
</label>
<input
type="number"
value={absMin}
onChange={(e) => setAbsMin(e.target.value)}
disabled={customDisabled}
min={data.absolute_minutes_min}
max={data.absolute_minutes_max}
className={cn(
'w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5',
'focus:outline-hidden focus:border-primary/30',
'disabled:opacity-50 disabled:cursor-not-allowed',
validation.absErr && !customDisabled && 'border-danger/50',
)}
style={{
borderColor:
validation.absErr && !customDisabled ? undefined : 'var(--color-border-default)',
}}
/>
<p className="text-[11px] text-muted-foreground mt-1">
{validation.absErr && !customDisabled
? validation.absErr
: `Between ${data.absolute_minutes_min} and ${data.absolute_minutes_max} min`}
</p>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving || !validation.ok}
className="bg-primary text-white font-semibold text-sm rounded-lg px-5 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all disabled:opacity-40 flex items-center gap-2"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Save Policy
</button>
{success && <span className="text-sm text-emerald-400">Settings saved</span>}
</div>
<p className="text-xs text-muted-foreground border-t border-border pt-3">
New policy applies the next time each person signs in. Use{' '}
<span className="font-medium text-foreground">Active sessions</span> below to force it
immediately.
</p>
</div>
{/* ── Active sessions card ───────────────────────────────────────── */}
<div className="card-flat rounded-2xl p-6 space-y-4">
<div>
<h2 className="text-sm font-medium text-foreground">Active sessions</h2>
<p className="text-xs text-muted-foreground mt-1">
{solo
? 'Only you are signed in to this account.'
: `${activeUserCount} people are signed in to this account.`}
</p>
</div>
<ul className="space-y-2">
{data.active_users.map((u) => {
const isMe = u.user_id === currentUserId
return (
<li
key={u.user_id}
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span className="truncate">{u.name}</span>
{isMe && (
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
(you)
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">
{u.email} · last signed in {relativeFromNow(u.last_login_at)}
</div>
</div>
</li>
)
})}
</ul>
<div className="flex flex-wrap gap-2 pt-2">
{!solo && (
<button
type="button"
onClick={() => setModalScope('others')}
className="rounded-md border border-border bg-transparent px-3 py-1.5 text-sm font-medium text-foreground hover:bg-card-hover"
>
Sign out everyone except me
</button>
)}
<button
type="button"
onClick={() => setModalScope('all')}
className="rounded-md border border-danger/40 bg-danger/10 px-3 py-1.5 text-sm font-medium text-danger hover:bg-danger/15"
>
{solo ? 'Sign me out everywhere' : 'Sign me out and everyone else'}
</button>
</div>
</div>
<RevokeSessionsModal
isOpen={modalScope !== null}
scope={modalScope ?? 'others'}
activeUserCount={activeUserCount}
onClose={() => setModalScope(null)}
onConfirm={handleRevokeConfirm}
/>
</div>
)
}

View File

@@ -101,6 +101,7 @@ const ProfileSettingsPage = lazyWithRetry(() => import('@/pages/account/ProfileS
const TeamCategoriesPage = lazyWithRetry(() => import('@/pages/account/TeamCategoriesPage'))
const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsPage'))
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
const AccountSecuritySettingsPage = lazyWithRetry(() => import('@/pages/account/AccountSecuritySettingsPage'))
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
@@ -341,6 +342,14 @@ export const router = sentryCreateBrowserRouter([
</ProtectedRoute>
),
},
{
path: 'security',
element: (
<ProtectedRoute requiredRole="owner">
{page(AccountSecuritySettingsPage)}
</ProtectedRoute>
),
},
{ path: 'target-lists', element: page(TargetListsPage) },
{
path: 'integrations',