# Refinement Phase 2 — Auth & role-aware navigation (the "only customer side" fix) — Report (2026-07-13)

## The symptom, and the actual root cause
"There are nurse and admin pages, but running the frontend only ever shows the customer side." Auth was
already the one real domain (`USE_AUTH_MOCK = false`); the app only *looked* customer-only because of two
things, now fixed:
1. **Role hydration conflated "loading" with "no role."** A fresh `/me` in-flight fell through
   `useActorRole()`'s `DEFAULT_ROLE = customer` fallback, so a nurse/admin was shown the customer shell for a
   beat — or forever, if `/me` failed. **This was the core bug.**
2. **The admin console was unreachable through the web login.** Admin sub-roles are server-granted (never
   self-selectable via `me/select_role`), and no *phone* user held one — the only admin was the
   username/password `admin`/`qw123321` the phone-OTP frontend can't use.

## What was built

### Frontend (client/ — the bulk)
- **`useRoleHydration()`** (`services/auth/hooks/useRoleHydration.ts`) — a discriminated
  `loading | error | ready` over `useMe`. This is the resolved-vs-pending distinction the phase demands:
  `ready` only once `/me` resolves (carrying the collapsed `appRoles`); `error` only when `/me` has **no**
  data (a background refetch that fails while a cached identity exists stays `ready` — don't downgrade a known
  nurse on a blip). Exported from the `services/auth` barrel.
- **`RoleGuard`** (`components/auth/RoleGuard.tsx`, **tested**) — wraps every private shell. On `loading` →
  neutral brand `AuthSplash` (never the customer shell as a stand-in); on `error` → `AuthAccountError` with
  retry (never a silent customer fallback); on **role mismatch** → `router.replace(resolveRoleDestination(me))`
  + a `guard_denied` toast. Takes `expected?: AppRole`; the partner portal passes none (hydration-only —
  partner isn't an `AppRole`, it self-gates via `useMyPartnerCenter`). It is **UX/chrome, not security** — the
  server still authorizes every endpoint; a dual customer+nurse session holds both roles and moves freely.
- **`AuthAccountError`** (`components/auth/AuthAccountError.tsx`) — the `/me`-failed recovery card (brand mark
  + warning + retry). Distinct from `RoleRouter`'s login-time error branch (which sends to `/login`).
- **Wired the four shells** — `(customer)`/`nurse`/`admin` layouts wrap in `RoleGuard expected={APP_ROLES.*}`;
  `partner` wraps in a role-less `RoleGuard`. The guard sits **outside** the shell component so its nav chrome
  never renders during load/redirect.
- **Doc hardening** — `useActorRole()`'s `DEFAULT_ROLE` fallback is now documented as a last resort (the guard
  ensures hydration before a shell renders), never the loading state. No behavior change there (f15's
  `useAdminCapabilities` still reads the same session roleCodes).
- **i18n** — `auth.guard_denied` / `account_error_title` / `account_error_body` / `account_error_retry` in
  both `en.json` + `fa.json`.

### Backend (server/ — a little, per §3.4)
- **Two phone-OTP admins added to the Development demo seeder** (`DemoWorldSeeder` + `DemoWorldDefinitions`):
  `09120000020` (`super_admin`) and `09120000021` (`finance`). An admin persona is just a phone user + a
  server-granted admin role (no profile) via the existing `CreateUserAsync`; idempotent (phone-guarded) like
  every other persona, Development-only. This is the sanctioned path to `/admin` through the normal phone-OTP
  login. Seeding **two** roles makes `useAdminCapabilities` gating demonstrable — the `finance` operator's
  sidebar shows only the money consoles.

## What's now testable, and exactly how (DoD)
Run the app per the RUNBOOK, then:
1. **Nurse → `/nurse`:** log in as `09120000001` (verified nurse) → nurse shell + dashboard.
2. **Customer → `/`:** log in as `09120000010` → family app. Tap "become a nurse" (SelectRole) → `POST
   me/select_role` in Network → after the `/me` refetch you're routed to `/nurse`.
3. **Admin → `/admin`:** log in as `09120000020` → admin console (all consoles incl. RBAC). Log in as
   `09120000021` → `/admin` with only the finance consoles in the sidebar (`useAdminCapabilities`).
4. **Mis-role redirect:** as a pure customer, visit `/nurse` → redirected to `/` with the `guard_denied` toast.
5. **Backend-down resilience:** stop the API, reload a nurse session → `AuthAccountError` (loading→error), **not**
   the customer app; restart + retry → recovers to `/nurse`.

Automated: `RoleGuard.test.tsx` (8 cases — loading/error/retry/allowed/dual-role/mismatch-redirect/role-less/
partner-no-expected). `DemoWorldSeederTests` +1 (admins reachable with their granted roles; total 4).

## What's mocked / deferred (honest gaps)
- **Partner login-routing is deferred.** `/partner` is a separate authz scope **not derivable from `me.roles`**,
  so `resolveRoleDestination` can't route a partner admin there on login. `/partner` **is** reachable by direct
  navigation (the `services/partnerCenter` mock resolves a center for `useMyPartnerCenter`, so the shell renders
  rather than access-denied), and the `RoleGuard` doesn't block it. The real login→`/partner` needs a `/me`
  signal — filed as **REQ-038** (`administersPartnerCenterId`) + a paired demo-seed association. No partner
  center was seeded this phase (would need real b15 partner↔user wiring that isn't runtime-verifiable here).
- **No new mock seam.** Auth stays 100% real (`USE_AUTH_MOCK = false` untouched) — deliberately, per §4: using
  the auth mock to fake roles would hide the very hydration bug this phase fixes.

## Contracts / tracker
- **REQ-004 resolved** — "the client owns the active-role choice"; `MeResult` gains no `activeRole`. A dual
  customer+nurse session is disambiguated by the client-carried intended role (A1/B1 switch), defaulting to the
  family app; `RoleGuard` lets a dual-role user move between shells.
- **REQ-038 filed** — a `/me` partner-center-admin signal for partner login-routing (see above).

## Gate
- **client:** `npm run check` green; `npm run test:ci -- RoleGuard` green (8/8); `en.json`/`fa.json` in sync.
- **server:** `dotnet build Baya.sln` 0 errors (warnings all pre-existing NuGet advisories / a migration's
  CS8632); `DemoWorldSeederTests` 4/4 pass over the SQLite harness. (A real SQL Server still couldn't boot in
  this env — same constraint as phase 1 — so the seeder DoD is proved through the test harness.)

## Follow-ups for later phases
- REQ-038 (partner `/me` signal + seed) — likely a small backend refinement phase.
- Cross-actor **hard** route guarding is still server-side only; `RoleGuard` is deliberately chrome-level UX.
