Files
resolutionflow/frontend/src/pages/LoginPage.tsx
Michael Chihlas c7cd711859 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>
2026-05-13 17:07:14 -04:00

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