refinement phase 2

This commit is contained in:
hamid
2026-07-13 01:14:35 +03:30
parent 0b45ec51f4
commit 1ce36f9414
22 changed files with 519 additions and 27 deletions
@@ -12,6 +12,23 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
## refinement-phase-2 — Auth & role-aware navigation ("only customer side" fix) — 2026-07-13
- **Shipped:** the resolved-vs-pending role fix. `useRoleHydration()` (`services/auth`, `loading|error|ready`
over `useMe`) + `RoleGuard` (wraps every private shell; tested) + `AuthAccountError`. Shells now: neutral
splash while `/me` loads (never the customer shell), explicit `/me`-failed recovery (never a silent customer
fallback), and role-mismatch **redirect** to `resolveRoleDestination` + `guard_denied` toast. `(customer)`/
`nurse`/`admin` guard `expected={APP_ROLES.*}`; `partner` is hydration-only (self-gates via
useMyPartnerCenter). i18n `auth.guard_denied`/`account_error_*` (en+fa). Backend (a little): 2 phone-OTP
admins added to the demo seeder (`09120000020` super_admin / `09120000021` finance) so `/admin` is reachable
via phone-OTP + `useAdminCapabilities` gating is demonstrable.
- **Consumes:** the real b2 auth (`/me`, `me/select_role`) — no new contract. `USE_AUTH_MOCK` stays false.
- **Mocked client-side:** none new. Partner login-routing deferred (`/partner` reachable by direct nav via the
existing partnerCenter mock).
- **Gate:** npm run check green · RoleGuard.test.tsx 8/8 · en/fa in sync · server build 0 errors ·
DemoWorldSeederTests 4/4.
- **Requests filed:** yes — **REQ-004 resolved** (client owns active-role); **REQ-038 filed** (a `/me`
partner-center-admin signal for partner login-routing).
## frontend-phase-15-b15 — Admin backoffice & partner-center consoles — 2026-07-10 — **MVP COMPLETE**
- **Shipped:** the internal **operational cockpit** — the role-gated admin backoffice (desktop sidebar shell) +
the separately-scoped **partner-center portal**. Two new domains: **`services/admin`** (config / holidays /
@@ -93,7 +93,12 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
app. If the backend intends to persist a "current role", the router should prefer it. Also note: verify_otp
returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
flagging in case that changes.
- **Status:** open
- **Resolution (refinement-phase-2, 2026-07-13):** **The client owns the active-role choice**`MeResult`
will *not* gain an `activeRole`. A dual customer+nurse session is disambiguated by the client-carried
intended role (the A1 vs B1 login switch), defaulting to the family app; `RoleGuard` lets a dual-role user
move freely between shells. No server change needed. If the backend ever wants to persist a "current role",
file a new REQ and the router will prefer it. **Confirmed the `/me` `id`-hydration note still holds.**
- **Status:** resolved (client owns it; no backend change)
## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02
- **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional
@@ -579,4 +584,19 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
DTO. The client defaults to `[]` meanwhile. `services/reviews` moderation methods are mock-primary; `moderateReview`
maps the live `PATCH reviews/{id}/status` and the queue maps `GET admin/reviews/moderation_queue`.
- **Why:** moderators see the tags a review carries before publishing/hiding.
## REQ-038 — Signal on `/me` that the caller administers a partner center (partner auto-routing) — filed by refinement-phase-2 — 2026-07-13
- **Need:** add a boolean/id on `MeResult` — e.g. `administersPartnerCenterId: number | null` (or a plain
`isPartnerCenterAdmin: bool`) — indicating the signed-in user is the admin of a `partner_center`.
- **Why:** the partner portal (`/partner`) is a **separate authz scope** — a center admin is not a Balinyaar
admin, and partner-ness is **not** derivable from `me.roles`. So `resolveRoleDestination` (the login role
router) can't route a partner admin to `/partner` on login the way it routes customer/nurse/admin. Today
`/partner` is only reachable by **direct navigation** (in dev the `services/partnerCenter` mock resolves a
center for `useMyPartnerCenter`, so the shell renders instead of access-denied); there is no seeded real
partner-center↔user association and no `/me`-level signal, so login→`/partner` can't be delivered. With this
field the router gains a partner branch and the demo seed can associate a phone user with a seeded center.
- **Proposed shape:** `MeResult.administersPartnerCenterId?: number | null`; when non-null,
`resolveRoleDestination` routes to `ROUTES.PARTNER`. Pair with a Development seed (a partner-center + a
`demo_partner_*` phone user linked as its admin) so the actor is reachable end-to-end like the others.
- **Status:** open (partner login-routing deferred; `/partner` reachable by direct nav + the partnerCenter mock)
- **Status:** open
@@ -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.