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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user