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>
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
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'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export function LoginPage() {
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const { login, isLoading, error, clearError } = useAuthStore()
|
|
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [localError, setLocalError] = useState('')
|
|
|
|
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('')
|
|
clearError()
|
|
|
|
if (!email || !password) {
|
|
setLocalError('Please enter both email and password')
|
|
return
|
|
}
|
|
|
|
try {
|
|
await login({ email, password })
|
|
const user = useAuthStore.getState().user
|
|
if (user?.must_change_password) {
|
|
navigate('/change-password', { replace: true })
|
|
} else {
|
|
navigate(from, { replace: true })
|
|
}
|
|
} catch {
|
|
// Error is set in the store
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageMeta title="Sign In" description="Sign in to your ResolutionFlow account" />
|
|
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
|
<div className="relative z-10 w-full max-w-md space-y-8">
|
|
<div className="text-center">
|
|
<div className="mb-4 flex justify-center sm:mb-6">
|
|
<BrandLogo size="lg" />
|
|
</div>
|
|
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
|
|
<span>Resolution</span><span className="text-accent-text">Flow</span>
|
|
</h1>
|
|
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
|
|
AI-Powered Troubleshooting for MSPs
|
|
</p>
|
|
<p className="mt-1 text-sm text-muted-foreground/70 sm:mt-2">
|
|
Sign in to your account
|
|
</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) && (
|
|
<div className="rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-400">
|
|
{localError || error}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground">
|
|
Email address
|
|
</label>
|
|
<input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className={cn(
|
|
'block w-full rounded-lg border border-border bg-card px-3 py-2.5',
|
|
'text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
|
'transition-colors'
|
|
)}
|
|
placeholder="you@example.com"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-foreground">
|
|
Password
|
|
</label>
|
|
<PasswordInput
|
|
id="password"
|
|
name="password"
|
|
autoComplete="current-password"
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className={cn(
|
|
'block w-full rounded-lg border border-border bg-card px-3 py-2.5',
|
|
'text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
|
'transition-colors'
|
|
)}
|
|
placeholder="••••••••••"
|
|
/>
|
|
</div>
|
|
|
|
<div className="text-right">
|
|
<Link to="/forgot-password" className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
|
Forgot password?
|
|
</Link>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading}
|
|
data-testid="login-submit"
|
|
className={cn(
|
|
'w-full rounded-lg px-4 py-2.5 text-sm font-semibold',
|
|
'bg-primary text-white hover:brightness-110 active:scale-[0.98]',
|
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-background',
|
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
'transition-all'
|
|
)}
|
|
>
|
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
Don't have an account?{' '}
|
|
<Link to="/register" className="font-medium text-foreground hover:underline">
|
|
Register
|
|
</Link>
|
|
</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default LoginPage
|