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