--- phase: 19-local-auth-no-oidc-mode plan: "04" subsystem: pwa-auth-ui status: complete tags: [pwa, auth, login-ui, admin, settings, local-auth] requirements_covered: [AUTH-LOCAL-12, AUTH-LOCAL-13, AUTH-LOCAL-14, AUTH-LOCAL-15] dependency_graph: requires: - 19-02 (API /api/auth/mode, /api/auth/local/login, /api/admin/members, /api/me/password) - 19-03 (localAuthMiddleware, session cookie, /api/auth/local/logout) provides: - LoginPage (Surfaces 1-10): standalone /login route with brand slot, form, error states, OIDC button - App.tsx auth-mode gate: routes unauthenticated local users to /login; OIDC-only to /api/login - AdminPage LOCAL ACCOUNTS: add-member form (Surface 11A), per-member reset-password sheet (Surface 11B) - SettingsSheet: change-password row + sheet (Surface 12), link-OIDC row + confirmation sheet (Surface 13) - BrandSlot component + brand-seam CSS tokens for Phase 17 override seam affects: - apps/pwa/src/api/client.ts (LoginError, fetchAuthMode, fetchLocalLogin, fetchLocalLogout, fetchChangePassword, fetchCreateMember, fetchAdminResetPassword, fetchLinkOidc, hasLocalCredential on MeUser/AdminMember) - apps/pwa/src/App.tsx (authModeQuery + auth gate + /login route) - apps/pwa/src/routes/AdminPage.tsx (LOCAL ACCOUNTS section, ResetPasswordSheet) - apps/pwa/src/components/SettingsSheet.tsx (change-password + link-OIDC rows + sub-sheets) tech_stack: added: [] patterns: - LoginError typed class (mirrors SessionExpiredError; code union 'invalid'|'rate-limit'|'locked'|'server') - BrandSlot component with CSS custom property seam for Phase 17 brand override - fetchLocalLogin maps HTTP status codes to LoginError codes before surfacing to UI - authModeQuery in App.tsx gates /login redirect and OidcRedirect rendering - InstructionSheet.test.tsx wrapped in QueryClientProvider (Rule 1 fix: SettingsSheet now uses useQuery) key_files: created: - apps/pwa/src/components/BrandSlot.tsx - apps/pwa/src/routes/LoginPage.tsx modified: - apps/pwa/src/api/client.ts - apps/pwa/src/styles/tokens.css - apps/pwa/src/App.tsx - apps/pwa/src/routes/AdminPage.tsx - apps/pwa/src/components/SettingsSheet.tsx - apps/pwa/src/components/InstructionSheet.test.tsx - apps/pwa/src/App.test.tsx decisions: - "OidcRedirect rendered as a React element (not a useEffect) to avoid render-inside-render conflict; window.location.replace in render body is safe for a top-level redirect-only component" - "SettingsSheet reads meQuery(['me']) and authModeQuery(['authMode']) with same keys as App.tsx; TanStack deduplicates the requests — no prop drilling needed" - "ResetPasswordSheet inlined in AdminPage.tsx rather than extracted to separate file; component is only used in one place and matches CredentialSheet locality pattern" - "fetchCreateMember throws HTTP error message so onError can detect '409' string for username-taken copy" metrics: duration: "~90 min (continued from previous session)" completed: "2026-06-17" tasks_completed: 3 files_modified: 8 files_created: 2 tests_added: 0 tests_modified: 2 test_suite_result: "263 tests passed (0 failed)" --- # Phase 19 Plan 04: PWA Login UI + Account Management Surfaces Summary PWA-side login UI built: standalone LoginPage with brand slot + form + 4 error states + optional OIDC button (Surfaces 1-10); App.tsx auth-mode gate added; AdminPage LOCAL ACCOUNTS section with add-member form and per-member reset-password sheet (Surfaces 11A/11B); SettingsSheet change-password and link-OIDC rows with nested bottom sheets (Surfaces 12/13). ## Tasks Completed | Task | Name | Commit | Files | |------|------|--------|-------| | 1 | client.ts fetch fns + LoginError + BrandSlot + tokens | `869cdc2` | client.ts, BrandSlot.tsx, tokens.css | | 2 | LoginPage (Surfaces 1-10) + App.tsx gate + /login route | `32d0408` | LoginPage.tsx, App.tsx, App.test.tsx | | 3 | AdminPage LOCAL ACCOUNTS + SettingsSheet surfaces 12/13 | `19c45eb` | AdminPage.tsx, SettingsSheet.tsx, InstructionSheet.test.tsx, client.ts, App.test.tsx | ## What Was Built ### Task 1: client.ts + BrandSlot + tokens.css **client.ts additions:** - `class LoginError extends Error` with `readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server'` — mirrors `SessionExpiredError` pattern including `Object.setPrototypeOf` fix - `hasLocalCredential: boolean` added to `MeUser` interface - `hasLocalCredential: boolean` added to `AdminMember` interface - `fetchAuthMode()` — plain GET /api/auth/mode, no credentials; returns `{ localEnabled, oidcEnabled }` - `fetchLocalLogin({ username, password })` — POST /api/auth/local/login with credentials:'include', redirect:'manual'; maps 401 to LoginError('invalid'), 429 to LoginError('rate-limit'), 423 to LoginError('locked'), non-ok to LoginError('server') - `fetchLocalLogout()` — POST /api/auth/local/logout - `fetchChangePassword({ currentPassword, newPassword })` — POST /api/me/password - `fetchCreateMember({ displayName, username, password })` — POST /api/admin/members - `fetchAdminResetPassword(memberId, newPassword)` — POST /api/admin/members/:id/reset-password - `fetchLinkOidc()` — POST /api/me/link-oidc; returns `{ redirectUrl: string }` **BrandSlot.tsx:** Phase-17-ready placeholder component. 48px circle with CSS custom properties (`--brand-logo-bg`, `--brand-logo-text`, `--brand-logo-size`, `--brand-logo-border-radius`). "FS" initials. h1 "FamilySync" (24px/600). Tagline "Family calendar & lists" (15px/400, secondary). No img tag. No dangerouslySetInnerHTML. No "Authelia". **tokens.css:** Brand-seam block added under `:root`: `--brand-logo-bg`, `--brand-logo-text`, `--brand-logo-size`, `--brand-logo-border-radius`, `--brand-app-name`. Phase 17 overrides these. ### Task 2: LoginPage + App.tsx gate **LoginPage.tsx (465 lines):** - Standalone full-page route (same pattern as SetupPage — no AppNav/BottomTabBar) - Accepts `authMode?: { localEnabled: boolean; oidcEnabled: boolean }` prop - Style: 400px max-width column, inline CSSProperties throughout (no shadcn) - Surface 1: BrandSlot at top - Surface 4: username field (type="text", autoFocus, autoComplete="username", spellCheck=false, autoCapitalize="none") - Surface 5: password field with show/hide toggle (Eye/EyeOff); onBlur resets to hidden - Surface 6 error states: invalid / rate-limit / locked / server — distinct copy per UI-SPEC - Surface 7: "Sign in" button (Loader2 spinner while pending); disabled on empty fields, rate-limit, locked - Surface 8: "or" divider (shown when oidcEnabled) - Surface 9: "Login with OIDC" button with ShieldCheck icon (shown when oidcEnabled) - Surface 10: "Forgot your password? Ask your admin." — non-interactive p tag - Focus management: autoFocus on username, useEffect focuses error heading on error change, Enter in username navigates to password field, Enter in password submits - Security: no "Authelia", no dangerouslySetInnerHTML, no field-level blame, password only in controlled state **App.tsx additions:** - `OidcRedirect` helper component: `window.location.replace('/api/login')` in render body - `authModeQuery` with `fetchAuthMode`, `staleTime: 60_000` - Auth gate in `*` route: `meQuery.isError + localEnabled` navigates to /login; `meQuery.isError + !localEnabled + oidcEnabled` renders OidcRedirect - `/login` route as standalone sibling of `/setup` ### Task 3: AdminPage LOCAL ACCOUNTS + SettingsSheet surfaces 12/13 **AdminPage LOCAL ACCOUNTS section:** - Surface 11A — "Add member" inline form: display name, username, initial password, confirm password; `useMutation(fetchCreateMember)`; client-side mismatch/short validation + server-side 409 username-taken detection; success clears form + invalidates `['admin', 'members']` and `['me']` - Surface 11B — "Reset password" button in MemberRow: conditioned on `member.hasLocalCredential`; captures trigger button ref for focus-return; opens ResetPasswordSheet - `ResetPasswordSheet` component: bottom sheet (role=dialog, aria-modal, Escape closes, focus heading on open); new password + confirm fields; admin-reset mutation; focus returns to trigger on close **SettingsSheet additions:** - `useQuery(['me'])` and `useQuery(['authMode'])` inside SettingsSheet — TanStack deduplicates with App.tsx queries - "Account" section label + "Change password" row (gated on `hasLocalCredential`) - "Link OIDC identity" row (gated on `hasLocalCredential && oidcEnabled`) - `ChangePasswordSheet`: current password + new password + confirm; change-password mutation; error copies for mismatch/wrong-current/server - `LinkOidcSheet`: confirmation dialog; body copy uses "your local password will be removed" (passive — no "delete"); "Continue with OIDC" triggers fetchLinkOidc then redirects; no "Authelia" anywhere ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] InstructionSheet.test.tsx broke after SettingsSheet gained useQuery** - **Found during:** Task 3 verification - **Issue:** SettingsSheet now calls useQuery for `['me']` and `['authMode']`. InstructionSheet.test.tsx rendered SettingsSheet without a QueryClientProvider, causing `Error: No QueryClient set, use QueryClientProvider to set one`. - **Fix:** Added `renderWithQueryClient()` helper wrapping `QueryClientProvider`; added `vi.mock('../api/client.js')` with the four new fetch functions. - **Files modified:** `apps/pwa/src/components/InstructionSheet.test.tsx` - **Commit:** `19c45eb` **2. [Rule 1 - Bug] Stale eslint-disable directive in App.test.tsx** - **Found during:** Task 3 lint run - **Issue:** `_mockFetchAuthMode` uses `_` prefix naming which already suppresses unused-vars; the explicit eslint-disable comment became an "unused disable directive" error under `--max-warnings 0`. - **Fix:** Removed the `eslint-disable-line` comment. - **Files modified:** `apps/pwa/src/App.test.tsx` - **Commit:** `19c45eb` ## Playwright-CLI Walkthrough Results The dev environment has `DEV_AUTH_BYPASS=true` which makes `/api/me` always return a valid user. The `/api/auth/mode` endpoint returns 404 (plan 19-02 routes not yet active in this dev stack). playwright-cli confirms: - Navigating to /login when authenticated redirects to calendar shell (correct behavior) - No React or TypeScript errors in the browser console Full login-form visual/functional verification requires the production-mode stack (no DEV_AUTH_BYPASS, plan 19-02 deployed). This is the checkpoint:human-verify scope. ## Verification: checkpoint:human-verify Required The following surfaces require human verification on the deployed production stack: - Surface 1-10: /login page renders + form interaction + 4 error state variants + OIDC button gate - Surface 11A: add-member form creates a member and it appears in the list - Surface 11B: reset-password sheet opens per-member, submits successfully - Surface 12: change-password sheet validates current password and updates - Surface 13: link-OIDC confirmation shows "local password will be removed" copy then initiates redirect ## Self-Check: PASSED Files created exist: - apps/pwa/src/components/BrandSlot.tsx — FOUND - apps/pwa/src/routes/LoginPage.tsx — FOUND Commits exist: - 869cdc2 (Task 1) — FOUND - 32d0408 (Task 2) — FOUND - 19c45eb (Task 3) — FOUND Tests: 263 passed, 0 failed Typecheck: Clean (tsc --noEmit) Lint: Clean (0 errors, 0 warnings, --max-warnings 0) ## Known Stubs - `BrandSlot` shows "FS" initials and no logo image — intentional Phase 17 seam, not a stub. Phase 17 will override `--brand-logo-*` CSS tokens and may add an `` tag. ## Threat Flags | Flag | File | Description | |------|------|-------------| | threat_flag: credential-in-controlled-state | apps/pwa/src/routes/LoginPage.tsx | Password in useState (controlled input); mitigated: never copied to localStorage/sessionStorage, cleared on success/error/blur | | threat_flag: credential-in-controlled-state | apps/pwa/src/components/SettingsSheet.tsx | currentPassword/newPassword in useState for ChangePasswordSheet; same mitigations |