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:
@@ -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" />}
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
353
frontend/src/pages/account/AccountSecuritySettingsPage.tsx
Normal file
353
frontend/src/pages/account/AccountSecuritySettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user