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

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