From 63beb74650dfc87111977e3819169a1f102b71c7 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 17:26:07 -0400 Subject: [PATCH] docs(19-04): complete PWA login UI + account management surfaces plan --- .../19-04-SUMMARY.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md 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 |