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

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