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>
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|