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>
126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
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>
|
|
)
|
|
}
|