docs(19-04): complete PWA login UI + account management surfaces plan
This commit is contained in:
@@ -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 |
|
||||||
Reference in New Issue
Block a user