Files
familysync/apps/pwa/src/routes/LoginPage.tsx
T
Lucas Berger 32d0408774 feat(19-04): LoginPage (Surfaces 1-10) + App.tsx authModeQuery gate + /login route
- Create LoginPage with BrandSlot, username/password form, show/hide toggle
- Four error states: invalid credentials, rate-limit, locked, server (all per UI-SPEC)
- OIDC method divider + 'Login with OIDC' button rendered only when oidcEnabled
- Accessibility: role=main, h1 in BrandSlot, h2 Sign in, aria-live error banner, 44px targets
- Focus management: username autofocus, Enter navigates username→password→submit
- App.tsx: add authModeQuery (queryKey ['authMode'], staleTime 60s)
- App.tsx: add /login standalone route (sibling of /setup, no AppNav/BottomTabBar)
- App.tsx: login gate after setup gate — meQuery error + localEnabled → Navigate /login
- App.tsx: OidcRedirect helper for OIDC-only mode (meQuery error + !localEnabled + oidcEnabled)
- Fix App.test.tsx to include fetchAuthMode mock and hasLocalCredential in user fixture
2026-06-17 17:14:18 -04:00

466 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* LoginPage — standalone /login route (Phase 19, D-04).
*
* UI-SPEC §Surface Architecture Surfaces 110:
* Surface 1: Full-page standalone route (no AppNav/BottomTabBar/SetupBanner)
* Surface 2: BrandSlot — app logo placeholder + name + tagline (Phase 17 seam)
* Surface 3: Login card — "Sign in" heading
* Surface 4: Username field (id="login-username", spellCheck/autoCapitalize/autoCorrect off)
* Surface 5: Password field with show/hide toggle (Eye/EyeOff, 44px tap target)
* Surface 6: Error/lockout banner (role="status", aria-live="polite", 4 error variants)
* Surface 7: Primary "Sign in" / "Signing in…" submit button (full-width)
* Surface 8: Method divider ("or") — rendered only when oidcEnabled
* Surface 9: "Login with OIDC" outlined button — rendered only when oidcEnabled
* Surface 10: "Forgot your password? Ask your admin." helper (informational only)
*
* Auth gate: App.tsx renders this route when meQuery returns 401 AND localEnabled.
* On success: window.location.replace('/') — the cookie is set by the API server.
*
* Security:
* T-19-18: password field is controlled state only; never written to localStorage/sessionStorage
* T-19-19: single shared "Incorrect username or password." — no field-level blame
* T-19-20: plain-text JSX children; no dangerouslySetInnerHTML (T-05-24)
* T-19-21: D-06 — UI never renders the provider name; uses generic "Login with OIDC"
*/
import { useState, useRef, useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { AlertCircle, Eye, EyeOff, Loader2, ShieldCheck } from 'lucide-react';
import { fetchLocalLogin, LoginError } from '../api/client.js';
import { BrandSlot } from '../components/BrandSlot.js';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface LoginPageProps {
authMode?: { localEnabled: boolean; oidcEnabled: boolean };
}
// ── Styles (copied from SetupPage.tsx — UI-SPEC §Design System) ───────────────
const pageStyle: React.CSSProperties = {
minHeight: '100dvh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
background: 'var(--color-surface, #ffffff)',
fontFamily: 'var(--font-family-base)',
color: 'var(--color-text-primary, #111318)',
};
const contentColStyle: React.CSSProperties = {
maxWidth: '400px', // login card is narrower than setup wizard (UI-SPEC Surface 1)
width: '100%',
margin: '0 auto',
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
};
const cardStyle: React.CSSProperties = {
background: 'var(--color-surface, #ffffff)',
border: '1px solid var(--color-border, #e2e4e9)',
borderRadius: '8px',
padding: 'var(--space-6, 24px)',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
};
const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
background: disabled ? 'var(--color-border, #e2e4e9)' : 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: disabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
width: '100%',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-2, 8px)',
});
const inputStyle = (hasError: boolean): React.CSSProperties => ({
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${hasError ? 'var(--color-destructive, #dc2626)' : 'var(--color-border, #e2e4e9)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary, #111318)',
background: 'var(--color-surface, #ffffff)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
});
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
marginBottom: 'var(--space-1, 4px)',
};
// ── LoginPage ─────────────────────────────────────────────────────────────────
export function LoginPage({ authMode }: LoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loginError, setLoginError] = useState<
'invalid' | 'rate-limit' | 'locked' | 'server' | null
>(null);
// Ref for moving focus to the error heading on error (UI-SPEC §Focus Management)
const errorHeadingRef = useRef<HTMLDivElement>(null);
// Ref for password field so Enter in username moves focus there
const passwordRef = useRef<HTMLInputElement>(null);
const oidcEnabled = authMode?.oidcEnabled ?? false;
// Move focus to error banner heading when error state activates
useEffect(() => {
if (loginError && errorHeadingRef.current) {
errorHeadingRef.current.focus();
}
}, [loginError]);
const loginMutation = useMutation({
mutationFn: () => fetchLocalLogin({ username, password }),
onSuccess: () => {
// Cookie is set by the API; replace to clear the /login URL from history
window.location.replace('/');
},
onError: (err) => {
if (err instanceof LoginError) {
setLoginError(err.code);
} else {
setLoginError('server');
}
},
});
const isLoading = loginMutation.isPending;
const bothNonEmpty = username.trim().length > 0 && password.length > 0;
const submitDisabled =
isLoading ||
!bothNonEmpty ||
loginError === 'rate-limit' ||
loginError === 'locked';
// Derive whether inputs should show error state
const inputHasError = loginError === 'invalid';
function handleSubmit() {
if (submitDisabled) return;
setLoginError(null);
loginMutation.mutate();
}
function handleUsernameKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault();
passwordRef.current?.focus();
}
}
function handlePasswordKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}
return (
<div style={pageStyle}>
<div style={contentColStyle} role="main">
{/* Surface 2 — Brand Slot (above the login card, in the flow) */}
<BrandSlot />
{/* Surface 3 — Login Card */}
<div style={cardStyle}>
<h2
style={{
margin: '0 0 var(--space-6, 24px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
}}
>
Sign in
</h2>
{/* Surface 4 — Username field */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="login-username" style={labelStyle}>
Username
</label>
<input
id="login-username"
type="text"
autoFocus
autoComplete="username"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleUsernameKeyDown}
aria-describedby={loginError ? 'login-error' : undefined}
style={inputStyle(inputHasError)}
/>
</div>
{/* Surface 5 — Password field with show/hide toggle */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="login-password" style={labelStyle}>
Password
</label>
<div style={{ position: 'relative' }}>
<input
id="login-password"
ref={passwordRef}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handlePasswordKeyDown}
onBlur={() => setShowPassword(false)}
aria-describedby={loginError ? 'login-error' : undefined}
style={{ ...inputStyle(inputHasError), paddingRight: '44px' }}
/>
{/* Show/hide toggle button — 44px tap target (UI-SPEC Surface 5) */}
<button
type="button"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
onClick={() => setShowPassword((v) => !v)}
style={{
position: 'absolute',
right: 0,
top: 0,
height: '100%',
minWidth: '44px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text-muted, #9ca3af)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
}}
>
{showPassword ? (
<EyeOff size={16} aria-hidden="true" />
) : (
<Eye size={16} aria-hidden="true" />
)}
</button>
</div>
</div>
{/* Surface 6 — Error / lockout banner */}
{loginError && (
<div
id="login-error"
role="status"
aria-live="polite"
aria-atomic="true"
style={{ marginBottom: 'var(--space-4, 16px)' }}
>
{loginError === 'invalid' && (
<div
ref={errorHeadingRef}
tabIndex={-1}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
outline: 'none',
}}
>
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
Incorrect username or password.
</div>
)}
{loginError === 'rate-limit' && (
<div
ref={errorHeadingRef}
tabIndex={-1}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
background: 'var(--color-surface-dim, #f7f7f8)',
borderRadius: 'var(--space-1, 4px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
outline: 'none',
}}
>
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
Too many attempts. Please wait a moment and try again.
</div>
)}
{loginError === 'locked' && (
<div
ref={errorHeadingRef}
tabIndex={-1}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
background: 'var(--color-surface-dim, #f7f7f8)',
borderRadius: 'var(--space-1, 4px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
outline: 'none',
}}
>
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
This account is temporarily locked. Contact your admin to reset access.
</div>
)}
{loginError === 'server' && (
<div
ref={errorHeadingRef}
tabIndex={-1}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
outline: 'none',
}}
>
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
Something went wrong. Please try again.
</div>
)}
</div>
)}
{/* Surface 7 — Primary submit button */}
<button
type="button"
onClick={handleSubmit}
disabled={submitDisabled}
style={primaryBtnStyle(submitDisabled)}
>
{isLoading && (
<Loader2
size={16}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
{isLoading ? 'Signing in…' : 'Sign in'}
</button>
{/* Surface 10 — Forgot password helper (informational only, not interactive) */}
<p
style={{
margin: 0,
marginTop: 'var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
textAlign: 'center',
lineHeight: 'var(--text-label-line-height, 1.4)',
}}
>
Forgot your password? Ask your admin.
</p>
</div>
{/* Surfaces 8 & 9 — Method divider + OIDC button (only when oidcEnabled) */}
{oidcEnabled && (
<>
{/* Surface 8 — Method divider */}
<div
aria-hidden="true"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
marginTop: 'var(--space-4, 16px)',
marginBottom: 'var(--space-4, 16px)',
}}
>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--color-border, #e2e4e9)',
}}
/>
<span
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
flexShrink: 0,
}}
>
or
</span>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--color-border, #e2e4e9)',
}}
/>
</div>
{/* Surface 9 — OIDC login button — uses generic copy per D-06 */}
<button
type="button"
onClick={() => {
// Initiate OIDC authorization-code flow (same redirect as today's OIDC-only mode)
window.location.href = '/api/login';
}}
style={{
width: '100%',
minHeight: '44px',
background: 'transparent',
border: '1px solid var(--color-member-0, #4a90d9)',
color: 'var(--color-member-0, #4a90d9)',
borderRadius: 'var(--space-1, 4px)',
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-2, 8px)',
}}
>
<ShieldCheck size={16} aria-hidden="true" />
Login with OIDC
</button>
</>
)}
</div>
</div>
);
}