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
This commit is contained in:
Lucas Berger
2026-06-17 17:14:18 -04:00
parent 869cdc26c8
commit 32d0408774
3 changed files with 522 additions and 2 deletions
+465
View File
@@ -0,0 +1,465 @@
/**
* 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>
);
}