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