Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 |
|
|
|
|
|
|
|
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 Errorwithreadonly code: 'invalid' | 'rate-limit' | 'locked' | 'server'— mirrorsSessionExpiredErrorpattern includingObject.setPrototypeOffixhasLocalCredential: booleanadded toMeUserinterfacehasLocalCredential: booleanadded toAdminMemberinterfacefetchAuthMode()— 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/logoutfetchChangePassword({ currentPassword, newPassword })— POST /api/me/passwordfetchCreateMember({ displayName, username, password })— POST /api/admin/membersfetchAdminResetPassword(memberId, newPassword)— POST /api/admin/members/:id/reset-passwordfetchLinkOidc()— 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:
OidcRedirecthelper component:window.location.replace('/api/login')in render bodyauthModeQuerywithfetchAuthMode,staleTime: 60_000- Auth gate in
*route:meQuery.isError + localEnablednavigates to /login;meQuery.isError + !localEnabled + oidcEnabledrenders OidcRedirect /loginroute 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 ResetPasswordSheetcomponent: 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'])anduseQuery(['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/serverLinkOidcSheet: 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, causingError: No QueryClient set, use QueryClientProvider to set one. - Fix: Added
renderWithQueryClient()helper wrappingQueryClientProvider; addedvi.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:
_mockFetchAuthModeuses_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-linecomment. - 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
BrandSlotshows "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 |