Files
baya-monorepo/dev/shared-working-context/reports/refinement-phase-2-report.md
T
2026-07-13 01:14:35 +03:30

6.6 KiB

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 errorAuthAccountError with retry (never a silent customer fallback); on role mismatchrouter.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 hardeninguseActorRole()'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).
  • i18nauth.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.