Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
Showing only changes of commit 63beb74650 - Show all commits
@@ -0,0 +1,197 @@
---
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 `<img>` 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 |