---
phase: 19-local-auth-no-oidc-mode
plan: 04
type: execute
wave: 4
depends_on: ["19-02", "19-03"]
files_modified:
  - 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
autonomous: false
requirements: [AUTH-LOCAL-12, AUTH-LOCAL-13, AUTH-LOCAL-14, AUTH-LOCAL-15]

must_haves:
  truths:
    - "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"
  artifacts:
    - path: "apps/pwa/src/routes/LoginPage.tsx"
      provides: "standalone /login page (Surfaces 1-10)"
      min_lines: 80
    - path: "apps/pwa/src/components/BrandSlot.tsx"
      provides: "Phase-17 brand seam component"
      exports: ["BrandSlot"]
      min_lines: 15
    - path: "apps/pwa/src/App.tsx"
      provides: "auth-mode fetch gate + /login route"
      contains: "authMode"
  key_links:
    - from: "apps/pwa/src/App.tsx"
      to: "apps/pwa/src/api/client.ts"
      via: "fetchAuthMode() gates the /login redirect"
      pattern: "authMode"
    - from: "apps/pwa/src/routes/LoginPage.tsx"
      to: "apps/pwa/src/api/client.ts"
      via: "fetchLocalLogin posts credentials; LoginError code drives the error state"
      pattern: "fetchLocalLogin"
    - from: "apps/pwa/src/components/SettingsSheet.tsx"
      to: "apps/pwa/src/api/client.ts"
      via: "hasLocalCredential from /api/me gates Change-password / Link-OIDC rows"
      pattern: "hasLocalCredential"
---

<objective>
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).
</objective>

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

<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
</context>

<tasks>

<task type="auto">
  <name>Task 1: client.ts fetch fns + LoginError + MeUser.hasLocalCredential, and BrandSlot</name>
  <read_first>
    - 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")
  </read_first>
  <files>apps/pwa/src/api/client.ts, apps/pwa/src/components/BrandSlot.tsx, apps/pwa/src/styles/tokens.css</files>
  <action>
    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<void>` — 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<void>` — 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.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>client.ts exposes auth-mode/login/logout fetchers + LoginError + MeUser.hasLocalCredential; BrandSlot renders the Phase-17-ready placeholder; brand-seam tokens defined.</done>
</task>

<task type="auto">
  <name>Task 2: LoginPage + App.tsx routing gate</name>
  <read_first>
    - 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)
  </read_first>
  <files>apps/pwa/src/routes/LoginPage.tsx, apps/pwa/src/App.tsx</files>
  <action>
    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: `<BrandSlot />` above the login card (Surface 2/3). Card heading `<h2>Sign in</h2>`. 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; `<h1>` 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.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa build && pnpm --filter @familysync/pwa test</automated>
  </verify>
  <acceptance_criteria>
    - `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)
  </acceptance_criteria>
  <done>/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.</done>
</task>

<task type="auto">
  <name>Task 3: AdminPage LOCAL ACCOUNTS + SettingsSheet change-password / link-OIDC</name>
  <read_first>
    - 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
  </read_first>
  <files>apps/pwa/src/routes/AdminPage.tsx, apps/pwa/src/components/SettingsSheet.tsx</files>
  <action>
    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.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test && pnpm --filter @familysync/pwa lint</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>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.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 4: playwright-cli walkthrough of the login + admin/settings surfaces</name>
  <action>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.</action>
  <what-built>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.</what-built>
  <how-to-verify>
    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.
  </how-to-verify>
  <resume-signal>Type "approved" if the flows render and behave per the UI-SPEC, or describe what differs.</resume-signal>
</task>

</tasks>

<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>

<verification>
- `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
</verification>

<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>

<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md` when done
</output>
