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
+42 -1
View File
@@ -52,18 +52,33 @@ import { ListsIndex } from './routes/ListsIndex.js';
import { ListDetail } from './routes/ListDetail.js';
import { AdminPage } from './routes/AdminPage.js';
import { SetupPage } from './routes/SetupPage.js';
import { LoginPage } from './routes/LoginPage.js';
import { BottomTabBar } from './components/BottomTabBar.js';
import { AppNav } from './components/AppNav.js';
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
import { SetupBanner } from './components/SetupBanner.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 {
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() {
const [settingsOpen, setSettingsOpen] = useState(false);
const phone = isPhone();
@@ -98,6 +113,16 @@ export default function App() {
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.
// While meQuery is loading, isAdmin is false/undefined → admin route redirects
// (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 */}
<Route
path="*"
@@ -176,6 +210,13 @@ export default function App() {
) : setupComplete === false ? (
// Not configured: full-app redirect to /setup (no nav shell rendered)
<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
<>