refinement phase 2
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user