+ Sign in +
+ + {/* Surface 4 — Username field */} ++ Forgot your password? Ask your admin. +
+diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md b/.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md
new file mode 100644
index 0000000..39e4b45
--- /dev/null
+++ b/.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md
@@ -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 `` 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 |
diff --git a/apps/pwa/src/App.test.tsx b/apps/pwa/src/App.test.tsx
index 496d4ed..e375f0d 100644
--- a/apps/pwa/src/App.test.tsx
+++ b/apps/pwa/src/App.test.tsx
@@ -74,21 +74,33 @@ vi.mock('./routes/ListDetail.js', () => ({
ListDetail: () =>
+ Family calendar & lists +
++ {"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."} +
+ + {/* Secondary note */} ++ {"This can't be undone from the app. Contact your admin if you need to revert."} +
+ + {/* Error (post-fetch) */} + {error && ( ++ Forgot your password? Ask your admin. +
+