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:
@@ -74,21 +74,33 @@ vi.mock('./routes/ListDetail.js', () => ({
|
|||||||
ListDetail: () => <div data-testid="list-detail">ListDetail</div>,
|
ListDetail: () => <div data-testid="list-detail">ListDetail</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./routes/LoginPage.js', () => ({
|
||||||
|
LoginPage: () => <div data-testid="login-page">LoginPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the API client — this is the key mock for the gate
|
// Mock the API client — this is the key mock for the gate
|
||||||
vi.mock('./api/client.js', () => ({
|
vi.mock('./api/client.js', () => ({
|
||||||
fetchSetupStatus: vi.fn(),
|
fetchSetupStatus: vi.fn(),
|
||||||
fetchMe: vi.fn(),
|
fetchMe: vi.fn(),
|
||||||
|
// Phase 19: fetchAuthMode is queried in App.tsx for the /login gate
|
||||||
|
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
|
||||||
SetupAlreadyLockedError: class SetupAlreadyLockedError extends Error {
|
SetupAlreadyLockedError: class SetupAlreadyLockedError extends Error {
|
||||||
readonly name = 'SetupAlreadyLockedError';
|
readonly name = 'SetupAlreadyLockedError';
|
||||||
},
|
},
|
||||||
SessionExpiredError: class SessionExpiredError extends Error {
|
SessionExpiredError: class SessionExpiredError extends Error {
|
||||||
readonly name = 'SessionExpiredError';
|
readonly name = 'SessionExpiredError';
|
||||||
},
|
},
|
||||||
|
LoginError: class LoginError extends Error {
|
||||||
|
readonly name = 'LoginError';
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(`Login failed: ${code}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { fetchSetupStatus, fetchMe } from './api/client.js';
|
import { fetchSetupStatus, fetchMe, fetchAuthMode } from './api/client.js';
|
||||||
import type { Mock } from 'vitest';
|
import type { Mock } from 'vitest';
|
||||||
import App from './App.js';
|
import App from './App.js';
|
||||||
|
|
||||||
@@ -113,6 +125,7 @@ function renderApp(queryClient: QueryClient) {
|
|||||||
|
|
||||||
const mockFetchSetupStatus = fetchSetupStatus as Mock;
|
const mockFetchSetupStatus = fetchSetupStatus as Mock;
|
||||||
const mockFetchMe = fetchMe as Mock;
|
const mockFetchMe = fetchMe as Mock;
|
||||||
|
const _mockFetchAuthMode = fetchAuthMode as Mock; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -129,6 +142,7 @@ describe('App — setup-status gate', () => {
|
|||||||
color: '#4a90d9',
|
color: '#4a90d9',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
needsProviderSetup: false,
|
needsProviderSetup: false,
|
||||||
|
hasLocalCredential: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+42
-1
@@ -52,18 +52,33 @@ import { ListsIndex } from './routes/ListsIndex.js';
|
|||||||
import { ListDetail } from './routes/ListDetail.js';
|
import { ListDetail } from './routes/ListDetail.js';
|
||||||
import { AdminPage } from './routes/AdminPage.js';
|
import { AdminPage } from './routes/AdminPage.js';
|
||||||
import { SetupPage } from './routes/SetupPage.js';
|
import { SetupPage } from './routes/SetupPage.js';
|
||||||
|
import { LoginPage } from './routes/LoginPage.js';
|
||||||
import { BottomTabBar } from './components/BottomTabBar.js';
|
import { BottomTabBar } from './components/BottomTabBar.js';
|
||||||
import { AppNav } from './components/AppNav.js';
|
import { AppNav } from './components/AppNav.js';
|
||||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
||||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
||||||
import { SetupBanner } from './components/SetupBanner.js';
|
import { SetupBanner } from './components/SetupBanner.js';
|
||||||
import { SettingsSheet } from './components/SettingsSheet.js';
|
import { SettingsSheet } from './components/SettingsSheet.js';
|
||||||
import { fetchMe, fetchSetupStatus } from './api/client.js';
|
import { fetchMe, fetchSetupStatus, fetchAuthMode } from './api/client.js';
|
||||||
|
|
||||||
function isPhone(): boolean {
|
function isPhone(): boolean {
|
||||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OidcRedirect — tiny helper that triggers a top-level navigation to /api/login.
|
||||||
|
*
|
||||||
|
* Used in the auth gate when localEnabled === false and oidcEnabled === true —
|
||||||
|
* the OIDC-only mode that was the app's only auth path before Phase 19.
|
||||||
|
* A top-level navigation (not a React Router navigate) is required because
|
||||||
|
* /api/login responds with a 302 redirect to the external OIDC provider,
|
||||||
|
* which browsers cannot follow as a fetch/XHR (T-07-04).
|
||||||
|
*/
|
||||||
|
function OidcRedirect() {
|
||||||
|
window.location.replace('/api/login');
|
||||||
|
return <div aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const phone = isPhone();
|
const phone = isPhone();
|
||||||
@@ -98,6 +113,16 @@ export default function App() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auth mode query — fetched pre-auth (no session required).
|
||||||
|
// Determines whether to show /login (localEnabled) or OIDC redirect (!localEnabled && oidcEnabled).
|
||||||
|
// staleTime 60s: auth mode changes rarely; re-fetches on new tab/focus.
|
||||||
|
const authModeQuery = useQuery({
|
||||||
|
queryKey: ['authMode'],
|
||||||
|
queryFn: fetchAuthMode,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
// isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403.
|
// isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403.
|
||||||
// While meQuery is loading, isAdmin is false/undefined → admin route redirects
|
// While meQuery is loading, isAdmin is false/undefined → admin route redirects
|
||||||
// (loading gate: no flash of admin content for non-admins).
|
// (loading gate: no flash of admin content for non-admins).
|
||||||
@@ -166,6 +191,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* /login route — standalone login page, no AppNav/BottomTabBar shell (UI-SPEC §Surface 1).
|
||||||
|
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
|
||||||
|
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
|
||||||
|
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={<LoginPage authMode={authModeQuery.data} />}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* All other routes are gated on setup completion */}
|
{/* All other routes are gated on setup completion */}
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
@@ -176,6 +210,13 @@ export default function App() {
|
|||||||
) : setupComplete === false ? (
|
) : setupComplete === false ? (
|
||||||
// Not configured: full-app redirect to /setup (no nav shell rendered)
|
// Not configured: full-app redirect to /setup (no nav shell rendered)
|
||||||
<Navigate to="/setup" replace />
|
<Navigate to="/setup" replace />
|
||||||
|
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
|
||||||
|
// Unauthenticated + localEnabled: redirect to /login
|
||||||
|
<Navigate to="/login" replace />
|
||||||
|
) : meQuery.isError && !meQuery.isLoading && !authModeQuery.data?.localEnabled && authModeQuery.data?.oidcEnabled ? (
|
||||||
|
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
|
||||||
|
// Use a render side-effect via useEffect isn't available here; use a helper element
|
||||||
|
<OidcRedirect />
|
||||||
) : (
|
) : (
|
||||||
// Setup complete: render the normal authenticated app shell
|
// Setup complete: render the normal authenticated app shell
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
/**
|
||||||
|
* LoginPage — standalone /login route (Phase 19, D-04).
|
||||||
|
*
|
||||||
|
* UI-SPEC §Surface Architecture Surfaces 1–10:
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user