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

21 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
19-local-auth-no-oidc-mode 04 execute 4
19-02
19-03
apps/pwa/src/components/BrandSlot.tsx
apps/pwa/src/routes/LoginPage.tsx
apps/pwa/src/api/client.ts
apps/pwa/src/App.tsx
apps/pwa/src/routes/AdminPage.tsx
apps/pwa/src/components/SettingsSheet.tsx
apps/pwa/src/styles/tokens.css
false
AUTH-LOCAL-12
AUTH-LOCAL-13
AUTH-LOCAL-14
AUTH-LOCAL-15
truths artifacts key_links
An unauthenticated user with no valid session lands on /login (when localEnabled) and sees the brand slot + username/password form
Submitting valid credentials logs the user in and navigates into the app; the local-session cookie is set by the API
401/429/423/5xx each render their distinct copy from the UI-SPEC; invalid-credentials does not say which field is wrong
When oidcEnabled, an 'or' divider + 'Login with OIDC' button appear; the word 'Authelia' never appears
An admin sees a LOCAL ACCOUNTS section to add a member and a per-member Reset-password action
A local user sees Change-password (and, when oidcEnabled, Link OIDC identity) in Settings; OIDC-only users do not
path provides min_lines
apps/pwa/src/routes/LoginPage.tsx standalone /login page (Surfaces 1-10) 80
path provides exports min_lines
apps/pwa/src/components/BrandSlot.tsx Phase-17 brand seam component
BrandSlot
15
path provides contains
apps/pwa/src/App.tsx auth-mode fetch gate + /login route authMode
from to via pattern
apps/pwa/src/App.tsx apps/pwa/src/api/client.ts fetchAuthMode() gates the /login redirect authMode
from to via pattern
apps/pwa/src/routes/LoginPage.tsx apps/pwa/src/api/client.ts fetchLocalLogin posts credentials; LoginError code drives the error state fetchLocalLogin
from to via pattern
apps/pwa/src/components/SettingsSheet.tsx apps/pwa/src/api/client.ts hasLocalCredential from /api/me gates Change-password / Link-OIDC rows hasLocalCredential
Build the PWA local-login UI and the account-management surfaces per the approved UI-SPEC: the standalone `/login` page (brand slot + form + error states + optional OIDC button), the App.tsx auth-mode routing gate, the client.ts fetch functions + typed `LoginError`, the AdminPage LOCAL ACCOUNTS additions, and the SettingsSheet change-password / link-OIDC rows.

Purpose: This is the first real login UI in the app — end-user-facing, phone-first, must be slick for the non-technical Apple member (CLAUDE.md). It is layout/glue/state code (type: execute, not TDD). Verification leans on the project's playwright-cli convention for desktop/Chromium-driveable flows rather than human checkpoints.

Output: LoginPage, BrandSlot, edited App.tsx + client.ts + AdminPage + SettingsSheet + tokens.css brand-seam vars.

Derived REQ-IDs covered: AUTH-LOCAL-12 (LoginPage), AUTH-LOCAL-13 (admin UI), AUTH-LOCAL-14 (settings UI), AUTH-LOCAL-15 (routing gate). D-04 (login UI exists), D-02 (chooser), D-06 (no Authelia), D-12 (link confirm copy).

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md @.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md @.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md @.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md @.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md @.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md Task 1: client.ts fetch fns + LoginError + MeUser.hasLocalCredential, and BrandSlot - apps/pwa/src/api/client.ts (fetchMe lines ~74-84; handleAuthResponse lines ~51-58; SessionExpiredError class lines ~33-39; MeUser interface lines ~62-68) - apps/pwa/src/routes/SetupPage.tsx (ShieldCheck header block — BrandSlot analog) - .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/pwa/src/api/client.ts + §apps/pwa/src/components/BrandSlot.tsx (exact code patterns) - .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md §Brand Slot + Surface 2 (placeholder structure, copy "FamilySync" / "Family calendar & lists") apps/pwa/src/api/client.ts, apps/pwa/src/components/BrandSlot.tsx, apps/pwa/src/styles/tokens.css In apps/pwa/src/api/client.ts add: `fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }>` (plain GET /api/auth/mode, no credentials needed). `fetchLocalLogin({ username, password }): Promise` — POST /api/auth/local/login with credentials:'include', redirect:'manual'; map status 401→`new LoginError('invalid')`, 429→`LoginError('rate-limit')`, 423→`LoginError('locked')`, other non-ok→`LoginError('server')`. `fetchLocalLogout(): Promise` — POST /api/auth/local/logout. Add the typed `class LoginError extends Error` with `readonly code: 'invalid'|'rate-limit'|'locked'|'server'` (mirror the SessionExpiredError class shape incl. Object.setPrototypeOf). Add `hasLocalCredential: boolean` to the `MeUser` interface. Optionally add `fetchChangePassword`, `fetchCreateMember`, `fetchAdminResetPassword`, `fetchLinkOidc` following the same fetch+throw pattern (used by Tasks 2/3).
Create apps/pwa/src/components/BrandSlot.tsx exporting `BrandSlot()` — the placeholder structure from the UI-SPEC: a 48px circle (`var(--brand-logo-size)` / `var(--brand-logo-bg)` / `var(--brand-logo-border-radius)`) with white "FS" initials, an `<h1>FamilySync</h1>` (Display 24/600), and a tagline "Family calendar & lists" (Body 15/400, secondary). No `<img>` yet (Phase 17 seam). No props.

In apps/pwa/src/styles/tokens.css add the brand-seam custom properties under `:root` with placeholder defaults: `--brand-logo-bg: var(--color-member-0)`, `--brand-logo-text: #ffffff`, `--brand-logo-size: 48px`, `--brand-logo-border-radius: 50%`. Phase 17 overrides these values only.
pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test - `pnpm --filter @familysync/pwa typecheck` exits 0 - Source assertion: `grep -c "class LoginError" apps/pwa/src/api/client.ts` == 1 with the 4 codes - Source assertion: `grep -c "hasLocalCredential" apps/pwa/src/api/client.ts` >= 1 (MeUser extended) - Source assertion: `grep -c "--brand-logo-size" apps/pwa/src/styles/tokens.css` == 1 - Negative assertion: `grep -ci "authelia" apps/pwa/src/components/BrandSlot.tsx` == 0 client.ts exposes auth-mode/login/logout fetchers + LoginError + MeUser.hasLocalCredential; BrandSlot renders the Phase-17-ready placeholder; brand-seam tokens defined. Task 2: LoginPage + App.tsx routing gate - apps/pwa/src/routes/SetupPage.tsx (pageStyle/contentColStyle/cardStyle/primaryBtnStyle/ghostBtnStyle/inputStyle/labelStyle — copy verbatim; useMutation + error-state pattern) - apps/pwa/src/App.tsx (setupQuery gate lines ~72-79 + /setup route lines ~156-167; AuthSplash usage; meQuery) - apps/pwa/src/components/BrandSlot.tsx (from Task 1) - apps/pwa/src/api/client.ts (fetchAuthMode, fetchLocalLogin, LoginError — from Task 1) - .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 1-10 + Interaction Contract + Accessibility Contract + Copywriting Contract (exact copy, ids, aria, focus, tab order, show/hide toggle) - .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/pwa/src/routes/LoginPage.tsx + §apps/pwa/src/App.tsx (password show/hide, gate logic) apps/pwa/src/routes/LoginPage.tsx, apps/pwa/src/App.tsx Create apps/pwa/src/routes/LoginPage.tsx — a standalone full-page route (no AppNav/BottomTabBar/SetupBanner), copying SetupPage's page/card/input/button styles. Layout: `` above the login card (Surface 2/3). Card heading `

Sign in

`. Username field (Surface 4: id="login-username", label "Username", autoComplete="username", spellCheck=false, autoCapitalize="none", autoCorrect="off"). Password field with show/hide toggle (Surface 5: id="login-password", autoComplete="current-password", paddingRight 44, Eye/EyeOff button with aria-label + aria-pressed, 44px tap target; toggle resets to hidden on blur). Error/lockout banner (Surface 6: id="login-error", role="status", aria-live="polite", aria-atomic="true") with the four copy variants keyed off LoginError.code (invalid → "Incorrect username or password." and both inputs get destructive border, no field blamed; rate-limit → "Too many attempts. Please wait a moment and try again." + submit disabled; locked → "This account is temporarily locked. Contact your admin to reset access." + submit disabled; server → "Something went wrong. Please try again." + submit re-enabled). Submit button (Surface 7: full-width filled accent, "Sign in"/"Signing in…" with Loader2, minHeight 44, disabled until both fields non-empty). Forgot-password helper (Surface 10: "Forgot your password? Ask your admin." non-interactive). Method divider + OIDC button (Surfaces 8/9) rendered only when `authMode.oidcEnabled` — "or" divider then outlined "Login with OIDC" (ShieldCheck icon; NEVER "Authelia"); on click initiate the OIDC flow (top-level nav to /api/login). useMutation(fetchLocalLogin) → onSuccess `window.location.replace('/')`, onError set the LoginError code into local error state. Focus username on mount; move focus to the error heading on error; Enter in username → password, Enter in password → submit. role="main" on content column; `

` is the brand-slot app name.
In apps/pwa/src/App.tsx: add an `authModeQuery` (queryKey ['authMode'], fetchAuthMode, retry false, staleTime 60_000). Add a `/login` route rendering `<LoginPage authMode={authModeQuery.data} />` as a sibling of the `*` route (standalone, outside the app shell — same structure as /setup). Gate logic, applied AFTER the existing setup gate (setup wins): if the user is unauthenticated (meQuery 401/error) AND `authMode.localEnabled` → render `<Navigate to="/login" replace />`; if unauthenticated AND `!localEnabled && oidcEnabled` → top-level redirect to /api/login (today's OIDC-only behavior). Keep AuthSplash during auth-state loading. Do not change the setup gate precedence.
pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa build && pnpm --filter @familysync/pwa test - `pnpm --filter @familysync/pwa typecheck` and `build` exit 0 - Source assertion: `grep -c "fetchLocalLogin" apps/pwa/src/routes/LoginPage.tsx` >= 1 - Source assertion: LoginPage renders all four error copies (grep each UI-SPEC string) - Source assertion: `grep -c "authModeQuery" apps/pwa/src/App.tsx` >= 1 and a `/login` route is registered - Negative assertion: `grep -ci "authelia" apps/pwa/src/routes/LoginPage.tsx` == 0 - Negative assertion: login invalid-credentials copy does not name a specific field (single shared message — UI-SPEC Surface 6 variant 1) /login renders the brand slot + accessible username/password form with show/hide, four error states, optional OIDC button; App.tsx routes unauthenticated local-mode users to /login after the setup gate. Task 3: AdminPage LOCAL ACCOUNTS + SettingsSheet change-password / link-OIDC - apps/pwa/src/routes/AdminPage.tsx (sectionLabelStyle lines ~42-49; CredentialSheet open/trigger pattern lines ~53-100; membersQuery lines ~76-81; member-row action button pattern) - apps/pwa/src/components/SettingsSheet.tsx (bottom-sheet dialog lines ~146-178; Escape listener lines ~69-76; settings rows) - apps/pwa/src/components/CredentialSheet.tsx (useMutation + invalidateQueries lines ~115-144; focus-on-open lines ~97-102; error-state pattern) - apps/pwa/src/api/client.ts (fetchCreateMember / fetchAdminResetPassword / fetchChangePassword / fetchLinkOidc — from Task 1) - .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 11A/11B/12/13 + Copywriting Contract + Destructive Actions (exact copy, field labels, autoComplete values, two-step link confirm) - .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §AdminPage.tsx + §SettingsSheet.tsx apps/pwa/src/routes/AdminPage.tsx, apps/pwa/src/components/SettingsSheet.tsx In apps/pwa/src/routes/AdminPage.tsx add a "LOCAL ACCOUNTS" section (sectionLabelStyle) below the existing MEMBERS / SHARED CALENDAR sections. Surface 11A — inline "Add member" form: Display name, Username (autoComplete off, spellCheck false, autoCapitalize none), Initial password + Confirm password (autoComplete new-password), filled "Add member" submit disabled until required fields filled and passwords match; on success clear the form + invalidate ['admin','members'] and ['me']; error copy: username taken → "That username is already in use. Choose a different one.", mismatch → "Passwords do not match.", short → "Password is too short. Use at least 8 characters." Surface 11B — a per-member "Reset password" action button shown only for members with `hasLocalCredential`, opening a bottom-sheet/modal (CredentialSheet dialog pattern: role=dialog, aria-modal, Escape closes, focus returns to trigger) with New password + Confirm (autoComplete new-password, no current-password field), "Reset password" submit; success closes silently.
In apps/pwa/src/components/SettingsSheet.tsx add a "Change password" row shown only when `meData.user.hasLocalCredential` (Surface 12) opening a nested sheet with Current/New/Confirm fields (correct autoComplete values), submit disabled until filled + new/confirm match; error variants: wrong current → "Current password is incorrect.", mismatch → "Passwords do not match.", generic → "Something went wrong. Please try again." Add a "Link OIDC identity" row shown only when `hasLocalCredential` AND `oidcEnabled` (Surface 13) opening a confirmation sheet (NOT a form) with the exact body copy "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." + Cancel / "Continue with OIDC" (never "Authelia"); on Continue, close the sheet and initiate the OIDC link flow (fetchLinkOidc → follow the returned redirect). Reuse the existing bottom-sheet dialog + Escape + focus patterns; never echo a password; no dangerouslySetInnerHTML.
pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test && pnpm --filter @familysync/pwa lint - `pnpm --filter @familysync/pwa typecheck`, `test`, `lint` exit 0 - Source assertion: AdminPage gates the Reset-password action on `hasLocalCredential` (grep) - Source assertion: SettingsSheet gates Change-password on `hasLocalCredential` and Link-OIDC on `hasLocalCredential` + `oidcEnabled` (grep) - Source assertion: the link-confirm body uses "your local password will be removed" and does NOT use the word "delete" (negative grep `delete` in the link copy region) — UI-SPEC copy rule - Negative assertion: `grep -ci "authelia" apps/pwa/src/components/SettingsSheet.tsx` == 0 and in AdminPage.tsx == 0 Admin can add a member + reset member passwords; a local user can change their password and (when OIDC enabled) link an OIDC identity via a two-step confirmation; all gated by hasLocalCredential/oidcEnabled; no Authelia copy. Task 4: playwright-cli walkthrough of the login + admin/settings surfaces Drive the login + admin/settings flows with the playwright-cli skill against the host dev stack, then pause for human confirmation. This is a blocking checkpoint — no code change; the executor runs the browser walkthrough and waits for approval. The full local-login UI and account-management surfaces. Drive them in a desktop Chromium browser with the project's playwright-cli skill (CLAUDE.md convention: prefer automated browser checks over manual). The dev stack is reached via the Phase-7 dev-bypass; to test the real login form, clear the local-session cookie first (Plan 05 makes this possible). iOS-Safari-standalone behavior remains a separate device-only gate, not part of this check. 1. Start the host-side dev stack (API + PWA dev servers, DEV_AUTH_BYPASS=true). Use the playwright-cli skill to open the PWA. 2. Clear cookies / open an incognito context so no session exists → confirm the app redirects to /login and the brand slot + "Sign in" card render with username/password fields and the show/hide toggle. 3. Submit a wrong password (dev creds from Plan 05's seed: devuser / a wrong value) → confirm the single "Incorrect username or password." message and that neither field is individually blamed. 4. Submit the correct dev creds (devuser / devpass) → confirm navigation into the calendar. 5. With OIDC configured in app_config, reload /login → confirm the "or" divider + "Login with OIDC" button appear and the word "Authelia" appears nowhere. 6. As an admin, open /admin → confirm LOCAL ACCOUNTS section with Add-member form + a per-member Reset-password action. Open Settings → confirm Change-password and (when OIDC on) Link OIDC identity rows, with the non-alarming link copy. Type "approved" if the flows render and behave per the UI-SPEC, or describe what differs.

<threat_model>

Trust Boundaries

Boundary Description
browser DOM → password fields password values must never persist to localStorage/sessionStorage or be echoed
client state → API the PWA mirrors 401/429/423 but never derives auth; the server is authoritative

STRIDE Threat Register (ASVS L1, block on high)

Threat ID Category Component Disposition Mitigation Plan
T-19-18 Information Disclosure password in client storage mitigate password fields are React-controlled state only; never written to localStorage/sessionStorage (UI-SPEC Security Display Rules)
T-19-19 Information Disclosure field-level credential hint mitigate single "Incorrect username or password." copy; no field-specific error (timing-safe parity with the API)
T-19-20 Tampering XSS via rendered values mitigate plain-text JSX children; no dangerouslySetInnerHTML (project convention T-05-24)
T-19-21 Information Disclosure infra leak via provider branding mitigate D-06: UI never renders "Authelia"; generic "Login with OIDC"
T-19-22 Elevation of Privilege client-only admin gating accept client isAdmin/hasLocalCredential are UX-only; the server requireAdmin/session is the real boundary (documented prior decision)
</threat_model>
- `pnpm --filter @familysync/pwa typecheck && build && test && lint` all green - playwright-cli human checkpoint confirms the login flow, error parity, OIDC chooser, and admin/settings surfaces - No "Authelia" string in any PWA source touched by this plan

<success_criteria>

  • AUTH-LOCAL-12: /login renders + logs in + shows correct error states
  • AUTH-LOCAL-13: admin add-member + reset-password surfaces work
  • AUTH-LOCAL-14: settings change-password + link-OIDC surfaces work
  • AUTH-LOCAL-15: App.tsx gate routes unauthenticated local-mode users to /login (after setup gate) </success_criteria>

<artifacts_produced>

Artifacts this phase produces (Plan 04)

  • Component: LoginPage (apps/pwa/src/routes/LoginPage.tsx) + /login route
  • Component: BrandSlot (apps/pwa/src/components/BrandSlot.tsx) — Phase-17 seam
  • client.ts: fetchAuthMode, fetchLocalLogin, fetchLocalLogout, LoginError, MeUser.hasLocalCredential (+ create/reset/change/link fetchers)
  • App.tsx: authModeQuery gate + /login route
  • AdminPage LOCAL ACCOUNTS section (add member + reset password)
  • SettingsSheet Change-password + Link-OIDC rows
  • tokens.css brand-seam custom properties (--brand-logo-*) </artifacts_produced>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md` when done