From c7cd7118592db7ed30575ee56d5c0185fb8c684a Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 17:07:14 -0400 Subject: [PATCH] feat: AccountSecuritySettingsPage + active-users list + toast + login banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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 --- backend/app/api/endpoints/account_security.py | 31 +- backend/app/schemas/account_security.py | 21 ++ backend/tests/test_session_policy.py | 15 +- .../2026-05-13-session-expiration-policy.md | 78 +++- frontend/src/api/accountSecurity.ts | 49 +++ .../account/RevokeSessionsModal.tsx | 89 +++++ .../components/common/SessionExpiryToast.tsx | 125 +++++++ frontend/src/components/layout/AppLayout.tsx | 2 + frontend/src/hooks/useAuthSessionExpiry.ts | 66 ++++ frontend/src/pages/AccountSettingsPage.tsx | 7 + frontend/src/pages/LoginPage.tsx | 15 + .../account/AccountSecuritySettingsPage.tsx | 353 ++++++++++++++++++ frontend/src/router.tsx | 9 + 13 files changed, 846 insertions(+), 14 deletions(-) create mode 100644 frontend/src/api/accountSecurity.ts create mode 100644 frontend/src/components/account/RevokeSessionsModal.tsx create mode 100644 frontend/src/components/common/SessionExpiryToast.tsx create mode 100644 frontend/src/hooks/useAuthSessionExpiry.ts create mode 100644 frontend/src/pages/account/AccountSecuritySettingsPage.tsx 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. +

+
+ )} +
{(error || localError) && ( diff --git a/frontend/src/pages/account/AccountSecuritySettingsPage.tsx b/frontend/src/pages/account/AccountSecuritySettingsPage.tsx new file mode 100644 index 00000000..9a759d81 --- /dev/null +++ b/frontend/src/pages/account/AccountSecuritySettingsPage.tsx @@ -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, { 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(null) + const [loading, setLoading] = useState(true) + const [preset, setPreset] = useState('strict') + const [idleMin, setIdleMin] = useState('') + const [absMin, setAbsMin] = useState('') + 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 ( +
+ +
+ ) + } + + const activeUserCount = data.active_users.length + const solo = activeUserCount <= 1 + + return ( +
+
+ +

Session Security

+
+

+ Control how long sessions can last before users must sign in again. +

+ + {/* ── Policy card ────────────────────────────────────────────────── */} +
+
+ Policy + + {(['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 ( + + ) + })} +
+ + {/* Custom inputs — always visible, disabled outside Custom */} +
+
+ + 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)', + }} + /> +

+ {validation.idleErr && !customDisabled + ? validation.idleErr + : `Between ${data.idle_minutes_min} and ${data.idle_minutes_max} min`} +

+
+
+ + 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)', + }} + /> +

+ {validation.absErr && !customDisabled + ? validation.absErr + : `Between ${data.absolute_minutes_min} and ${data.absolute_minutes_max} min`} +

+
+
+ +
+ + {success && Settings saved} +
+ +

+ New policy applies the next time each person signs in. Use{' '} + Active sessions below to force it + immediately. +

+
+ + {/* ── Active sessions card ───────────────────────────────────────── */} +
+
+

Active sessions

+

+ {solo + ? 'Only you are signed in to this account.' + : `${activeUserCount} people are signed in to this account.`} +

+
+ +
    + {data.active_users.map((u) => { + const isMe = u.user_id === currentUserId + return ( +
  • +
    +
    + {u.name} + {isMe && ( + + (you) + + )} +
    +
    + {u.email} · last signed in {relativeFromNow(u.last_login_at)} +
    +
    +
  • + ) + })} +
+ +
+ {!solo && ( + + )} + +
+
+ + setModalScope(null)} + onConfirm={handleRevokeConfirm} + /> +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 02ce56f2..cadd0032 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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([ ), }, + { + path: 'security', + element: ( + + {page(AccountSecuritySettingsPage)} + + ), + }, { path: 'target-lists', element: page(TargetListsPage) }, { path: 'integrations',