Files
familysync/apps/pwa/src/routes/LoginPage.tsx
T
Lucas BergerandClaude Opus 4.8 b6490feff4
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
fix(19): satisfy CI fast-checks + secret scan
Lint (eslint --max-warnings 0):
- index.ts: disable no-unsafe-argument on the type-only Context mismatch when
  delegating to the OIDC handler inside the local-session skip wrapper
- localAuth.ts: handleLogout is sync (no await) — drop async (require-await)
- devBypass.ts: disable detect-possible-timing-attacks on the public well-known
  dev-placeholder string compare (not a secret comparison)
- remove dead code / unused bindings flagged by no-unused-vars: makeTestApp
  (localSession.test), makeUnauthContext + BrowserContext import (login.spec),
  unused memberId (admin.test), unused txSelectCount counter (me.test)
- localAuthMiddleware.test / me.test: fix unused + reflow-detached
  eslint-disable directives

Format: prettier --write across the 20 Phase-19 files that were never formatted.

Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char
TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key
regex hit on "credential atomically, 409-equivalent"). Neither is a real secret.

Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks),
PWA 266/266, API 452/452.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:05:15 -04:00

464 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 }} />
Too many failed attempts for this account. Try again in about 15 minutes, or
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>
);
}