---
phase: 19-local-auth-no-oidc-mode
created: 2026-06-17T18:25:00Z
updated: 2026-06-17T21:20:00Z
status: complete
source: verification + plan-checkpoints
gaps: []
findings_routed_to_phase_17: [F-01, F-02, F-03, F-04]
---

## Live UAT Session (resumed 2026-06-17, post code-review-fix)

**Stack configured for local-auth / no-OIDC mode** (Phase 19's canonical deployment):
- API rebuilt from the phase-19 branch (all 18 code-review fixes live; verified `initialPassword` + 403 present in running `dist`).
- `DEV_AUTH_BYPASS=false` and `OIDC_ISSUER=""` via throwaway `docker-compose.uat.yml` override
  (tracked files untouched; restore the normal bypass stack after UAT).
- Seeded local admin: **username `uatadmin` / password `UATtest1234!`** (user id 2, is_admin=1).
- PWA on host Vite at **http://localhost:5173**.

**Automated API smoke (pre-checks):**
- ✅ `POST /api/auth/local/login` (uatadmin) → 200 + `local-session` cookie.
- ✅ Wrong password → 401 `{"error":"Invalid credentials"}` (generic, no field blame).
- ✅ Authenticated `GET /api/me` → `{id:2, isAdmin:true, hasLocalCredential:true}`.
- ⚠️ Unauth `GET /api/me` → **500 `Invalid session`** (not 401) in no-OIDC mode: the OIDC guard
  is mounted whenever bypass is off and errors trying to redirect with a blank issuer. **Cosmetic**
  — PWA gates on `meQuery.isError && localEnabled` (App.tsx:213) so it still redirects to `/login`.
  Candidate follow-up: short-circuit the OIDC guard to a clean 401 when no issuer is configured.

**Note on Item 1 OIDC button:** the "OIDC button appears when oidcEnabled" sub-check can't be
exercised on this box (no reachable Authelia → blanked). Covered at unit/e2e level. This session
verifies the no-OIDC local-auth surface, which is the phase's primary deliverable.

# Phase 19: Local Auth (No-OIDC Mode) — User Acceptance Tests

All automated verification passed (API 446/446, PWA 266/266, e2e desktop 42 passed
/ 3 skipped, typecheck clean; VERIFICATION.md status: passed, 21/21 must-haves).
The single blocker found during verification (admin reset-password URL mismatch) was
fixed and confirmed live (commit `53da4be`).

The items below are the remaining **human / live-stack** checks that cannot be driven
from the dev `DEV_AUTH_BYPASS` harness or a headless box. They do not block automated
goal achievement but should be confirmed before shipping.

## UAT Items

### 1. Login page visual + flow (real, non-bypass stack)
- **Test:** Run the stack with OIDC/Authelia configured and `DEV_AUTH_BYPASS` **off**.
  Visit the app unauthenticated → confirm redirect to `/login`. Verify the brand slot
  ("FS" mark, "FamilySync", "Family calendar & lists"), the form (username auto-focus,
  password show/hide), wrong-creds single error ("Incorrect username or password."),
  correct-creds navigation into the app, and the OIDC button only when `oidcEnabled`.
- **Expected:** All surfaces per 19-UI-SPEC; no "Authelia" text anywhere; error copy
  never blames a specific field.
- **Why human:** The unauth login-gate redirect is unreachable under the bypass-only
  harness (covered at unit level in `App.test.tsx`); the full visual flow needs a real
  browser against a non-bypass deployment. (Desktop/Chromium portions are already e2e-
  covered via `login.spec.ts`.)
- **result: pass** (2026-06-17, live no-OIDC stack, operator-confirmed — all steps:
  redirect to /login, brand slot, username autofocus, password show/hide, generic
  wrong-creds error, successful login into the app). Setup-gate precedence confirmed:
  `setupComplete===true` so /login is reachable (App.tsx checks `/setup` redirect first).

### 2. Admin Reset-password sheet (live, end-to-end)
- **Test:** As an admin, open Admin → Local Accounts → Reset password for a member;
  submit a new password; confirm the member can then log in with it.
- **Expected:** `POST /api/admin/members/:id/password` returns 200; new password works.
- **Why human:** Needs a live stack with an admin session and a real local member.
  (URL fix already verified: route reachable, 404 eliminated.)
- **result: pass (functional) — with UX gap.** Operator created `testmember` + reset its
  password via the admin UI. Verified at DB/login level: member exists (user 3, non-admin);
  login with the **reset** pw (`MemberPass456!`) → 200; login with the **original**
  (`MemberPass123!`) → 401. So **CR-02 create-member + reset both work and persist.**
  BUT neither action showed a success confirmation (see Finding F-01).

### 3. Settings Change-password sheet (live, local user)
- **Test:** As a local user, Settings → Account → Change password; verify wrong current
  password shows "Current password is incorrect.", correct current updates, and the new
  password works on next login.
- **Expected:** Current-password verification enforced; update succeeds; re-login works.
- **Why human:** Needs a live stack with a local-user session.
- **result: pass** (2026-06-17, verified functionally via API on the live no-OIDC stack as
  `testmember`). CR-03 confirmed: wrong current password → **403 (not 401)** and the session
  stays valid (`/me`→200, no force-logout); correct current → 200; new password logs in (200),
  old password rejected (401).

### 4. Rate-limit / lockout test flakiness (harden)
- **Test:** Run `apps/api/tests/routes/localAuth.test.ts` Test 5 (10 failures → 423)
  ~10 times; characterize the intermittent failure the orchestrator observed (1 failure
  across 3 runs, then stable).
- **Expected:** Stable pass; if timing-dependent, harden the in-memory rate-limit test
  (e.g. fake timers / deterministic clock).
- **Why human:** Timing-dependent in-memory test; needs repeated runs to characterize.
- **result: resolved-by-fix.** The code-review fix (CR-04/WR-06/IN-03) rewrote the limiter and
  made the lockout test **deterministic** — it back-dates `lockedAt` instead of using wall-clock
  timers (`localAuth.test.ts:280`), structurally removing the timing flakiness. The fixer ran the
  full API suite **452/452** (incl. Test 5/5b). Couldn't be re-run in this session's shell (no
  test-DB root creds — `ER_ACCESS_DENIED`); confirm via CI or `set -a; . ./.env; set +a;
  DB_HOST=127.0.0.1 pnpm --filter @familysync/api test`.

### 5. CI harness green + D-15 image boundary (push, outward-facing)
- **Test:** Push the branch and open the PR so Gitea CI runs. Confirm the `harness` job
  (iphone + pixel + desktop, incl. `login.spec.ts`) is green, the `api` job is green,
  and the published-image hygiene checks (no `apps/api/scripts/` or `apps/pwa/e2e/` in
  the prod image) pass.
- **Expected:** CI all green; no dev artifact in the shipped image (D-14/D-15).
- **Why human:** Pushing to the remote / triggering CI is an outward-facing action the
  operator owns. (Plan 19-05's blocking checkpoint.)
- **result: deferred to `/gsd-ship`** (operator decision 2026-06-17). The push/PR/CI run +
  image-hygiene gate is owned by the ship workflow, not this UAT session.

## Live Session Findings (2026-06-17)

Surfaced by the operator during Test 2. **Operator decision (2026-06-17): route ALL four UI
findings — including the logout button — to Phase 17 (UI Optimization & Polish), which has not yet
kicked off. None block Phase 19**, whose auth machinery is functionally complete and verified.

All four added to `17-CONTEXT.md` (Phase-17 branch):

- **F-02 — No logout button in the UI (functional-UI).** Logout is fully plumbed — endpoint
  `POST/GET /api/auth/local/logout` returns 200 and clears the cookie (BL-02 verified live), and
  `fetchLocalLogout()` exists in `apps/pwa/src/api/client.ts:127` — but **no component calls it**
  (grep of `apps/pwa/src` finds zero logout buttons/handlers). Phase 17 wires a logout control to
  the existing client function (no backend work). Per operator: a UI concern, not a Phase-19 blocker.
- **F-01 — Admin create/reset give no success feedback.** Both succeed (verified at DB/login level)
  but show no success toast/confirmation. Add success feedback to the admin local-account flows.
- **F-03 — Dialogs/popups render at bottom-center instead of properly centered (cosmetic).** Fits
  Phase 17's fixed-chrome/sheet-positioning sweep.
- **F-04 — Admin UI navigation is clunky and needs a rework.** UX-polish item for Phase 17.

**Status:** Phase 19 UAT **complete (functional)** — Tests 1–3 pass (live), Test 4 resolved-by-fix,
Test 5 deferred to `/gsd-ship`. No Phase-19 blockers. F-01–F-04 carried to Phase 17.
