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:
49
frontend/src/api/accountSecurity.ts
Normal file
49
frontend/src/api/accountSecurity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface ActiveUser {
|
||||
user_id: string
|
||||
name: string
|
||||
email: string
|
||||
last_login_at: string | null
|
||||
}
|
||||
|
||||
export interface SessionPolicyResponse {
|
||||
idle_minutes: number | null
|
||||
absolute_minutes: number | null
|
||||
effective_idle_minutes: number
|
||||
effective_absolute_minutes: number
|
||||
idle_minutes_min: number
|
||||
idle_minutes_max: number
|
||||
absolute_minutes_min: number
|
||||
absolute_minutes_max: number
|
||||
active_users: ActiveUser[]
|
||||
}
|
||||
|
||||
export interface SessionPolicyUpdateRequest {
|
||||
idle_minutes: number | null
|
||||
absolute_minutes: number | null
|
||||
}
|
||||
|
||||
export interface RevokeSessionsResponse {
|
||||
revoked_count: number
|
||||
}
|
||||
|
||||
export const accountSecurityApi = {
|
||||
async get(): Promise<SessionPolicyResponse> {
|
||||
const response = await apiClient.get<SessionPolicyResponse>('/accounts/me/security')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(body: SessionPolicyUpdateRequest): Promise<SessionPolicyResponse> {
|
||||
const response = await apiClient.patch<SessionPolicyResponse>('/accounts/me/security', body)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async revokeSessions(scope: 'all' | 'others'): Promise<RevokeSessionsResponse> {
|
||||
const response = await apiClient.post<RevokeSessionsResponse>(
|
||||
'/accounts/me/security/revoke-sessions',
|
||||
{ scope },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
89
frontend/src/components/account/RevokeSessionsModal.tsx
Normal file
89
frontend/src/components/account/RevokeSessionsModal.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RevokeSessionsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => Promise<void>
|
||||
scope: 'all' | 'others'
|
||||
activeUserCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation modal for bulk session revocation. Two scopes:
|
||||
*
|
||||
* - "others" — revokes other users' sessions, caller stays signed in.
|
||||
* - "all" — revokes everyone including the caller; the parent handles
|
||||
* the post-revoke auto-redirect to /login (see plan §4.8 D4).
|
||||
*/
|
||||
export function RevokeSessionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
scope,
|
||||
activeUserCount,
|
||||
}: RevokeSessionsModalProps) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isAll = scope === 'all'
|
||||
const otherCount = isAll ? activeUserCount : Math.max(activeUserCount - 1, 0)
|
||||
|
||||
const title = isAll ? 'Sign out everyone?' : 'Sign out other users?'
|
||||
const body = isAll
|
||||
? `This signs out all ${activeUserCount} active users including yourself. Everyone will need to sign in again.`
|
||||
: `This signs out the ${otherCount} other active users in your account. They'll need to sign in again. You stay signed in.`
|
||||
const confirmLabel = isAll
|
||||
? 'Sign out everyone'
|
||||
: otherCount === 1
|
||||
? 'Sign out 1 user'
|
||||
: `Sign out ${otherCount} users`
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={busy ? () => undefined : onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'rounded-md border px-4 py-2 text-sm font-medium',
|
||||
'border-border text-foreground hover:bg-card-hover',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'rounded-md border px-4 py-2 text-sm font-medium',
|
||||
'border-danger/40 bg-danger/10 text-danger hover:bg-danger/15',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{busy ? 'Working…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-foreground">{body}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
125
frontend/src/components/common/SessionExpiryToast.tsx
Normal file
125
frontend/src/components/common/SessionExpiryToast.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertCircle, Info, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthSessionExpiry } from '@/hooks/useAuthSessionExpiry'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
/**
|
||||
* Top-of-app notice that fires when the session is within 5 minutes of
|
||||
* idle OR absolute expiry. Behavior differs by which window is closer
|
||||
* (per docs/plans/2026-05-13-session-expiration-policy.md §4.8):
|
||||
*
|
||||
* - Idle: warning-amber tone, "Stay signed in" button hits /auth/refresh.
|
||||
* - Absolute: info-cyan tone, no action — re-auth is required.
|
||||
*
|
||||
* Persists until the user dismisses, refreshes, or the window expires.
|
||||
*/
|
||||
export function SessionExpiryToast() {
|
||||
const { warning, reason, idleExpiresAt, absoluteExpiresAt } = useAuthSessionExpiry()
|
||||
const setTokens = useAuthStore((s) => s.setTokens)
|
||||
const navigate = useNavigate()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
if (warning !== 'soon' || dismissed) return null
|
||||
|
||||
const handleStay = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const refreshed = await authApi.refresh()
|
||||
localStorage.setItem('access_token', refreshed.access_token)
|
||||
localStorage.setItem('refresh_token', refreshed.refresh_token)
|
||||
setTokens(refreshed)
|
||||
setDismissed(true)
|
||||
} catch {
|
||||
// The axios interceptor handles the redirect on session_expired_*;
|
||||
// if we land here, something else went wrong — just close the toast.
|
||||
setDismissed(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignInNow = () => navigate('/login')
|
||||
|
||||
// ── Format the deadline for the absolute case ──
|
||||
const deadline = reason === 'idle' ? idleExpiresAt : absoluteExpiresAt
|
||||
const deadlineLabel = deadline
|
||||
? deadline.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
: ''
|
||||
|
||||
const isIdle = reason === 'idle'
|
||||
const Icon = isIdle ? AlertCircle : Info
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'fixed top-4 right-4 z-50 max-w-md rounded-lg border p-4 shadow-lg',
|
||||
'flex items-start gap-3',
|
||||
isIdle
|
||||
? 'bg-warning-dim border-warning/30 text-warning'
|
||||
: 'bg-info-dim border-info/30 text-info',
|
||||
)}
|
||||
>
|
||||
<Icon size={18} className="mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{isIdle
|
||||
? 'Your session times out in 5 minutes.'
|
||||
: `Your session ends at ${deadlineLabel} for security.`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs opacity-90">
|
||||
{isIdle
|
||||
? 'Click to stay signed in.'
|
||||
: "You'll need to sign in again."}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{isIdle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStay}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-xs font-medium',
|
||||
'bg-warning/20 text-warning border border-warning/40 hover:bg-warning/30',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{busy ? 'Refreshing…' : 'Stay signed in'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignInNow}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-xs font-medium',
|
||||
'bg-info/20 text-info border border-info/40 hover:bg-info/30',
|
||||
)}
|
||||
>
|
||||
Sign in now
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { EmailVerificationBanner } from './EmailVerificationBanner'
|
||||
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
|
||||
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
|
||||
import { FeedbackWidget } from '@/components/common/FeedbackWidget'
|
||||
import { SessionExpiryToast } from '@/components/common/SessionExpiryToast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AppLayout() {
|
||||
@@ -69,6 +70,7 @@ export function AppLayout() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionExpiryToast />
|
||||
<div
|
||||
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
|
||||
data-testid="app-shell"
|
||||
|
||||
66
frontend/src/hooks/useAuthSessionExpiry.ts
Normal file
66
frontend/src/hooks/useAuthSessionExpiry.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -101,6 +101,7 @@ const ProfileSettingsPage = lazyWithRetry(() => import('@/pages/account/ProfileS
|
||||
const TeamCategoriesPage = lazyWithRetry(() => import('@/pages/account/TeamCategoriesPage'))
|
||||
const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsPage'))
|
||||
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
||||
const AccountSecuritySettingsPage = lazyWithRetry(() => import('@/pages/account/AccountSecuritySettingsPage'))
|
||||
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
|
||||
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
|
||||
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
|
||||
@@ -341,6 +342,14 @@ export const router = sentryCreateBrowserRouter([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
element: (
|
||||
<ProtectedRoute requiredRole="owner">
|
||||
{page(AccountSecuritySettingsPage)}
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'target-lists', element: page(TargetListsPage) },
|
||||
{
|
||||
path: 'integrations',
|
||||
|
||||
Reference in New Issue
Block a user