Files
familysync/.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md
T

12 KiB

phase, plan, subsystem, status, tags, requirements_covered, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem status tags requirements_covered dependency_graph tech_stack key_files decisions metrics
19-local-auth-no-oidc-mode 04 pwa-auth-ui complete
pwa
auth
login-ui
admin
settings
local-auth
AUTH-LOCAL-12
AUTH-LOCAL-13
AUTH-LOCAL-14
AUTH-LOCAL-15
requires provides affects
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)
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
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)
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)
created modified
apps/pwa/src/components/BrandSlot.tsx
apps/pwa/src/routes/LoginPage.tsx
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
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
duration completed tasks_completed files_modified files_created tests_added tests_modified test_suite_result
~90 min (continued from previous session) 2026-06-17 3 8 2 0 2 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:

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