6.6 KiB
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:
- Role hydration conflated "loading" with "no role." A fresh
/mein-flight fell throughuseActorRole()'sDEFAULT_ROLE = customerfallback, so a nurse/admin was shown the customer shell for a beat — or forever, if/mefailed. This was the core bug. - 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/passwordadmin/qw123321the phone-OTP frontend can't use.
What was built
Frontend (client/ — the bulk)
useRoleHydration()(services/auth/hooks/useRoleHydration.ts) — a discriminatedloading | error | readyoveruseMe. This is the resolved-vs-pending distinction the phase demands:readyonly once/meresolves (carrying the collapsedappRoles);erroronly when/mehas no data (a background refetch that fails while a cached identity exists staysready— don't downgrade a known nurse on a blip). Exported from theservices/authbarrel.RoleGuard(components/auth/RoleGuard.tsx, tested) — wraps every private shell. Onloading→ neutral brandAuthSplash(never the customer shell as a stand-in); onerror→AuthAccountErrorwith retry (never a silent customer fallback); on role mismatch →router.replace(resolveRoleDestination(me))- a
guard_deniedtoast. Takesexpected?: AppRole; the partner portal passes none (hydration-only — partner isn't anAppRole, it self-gates viauseMyPartnerCenter). It is UX/chrome, not security — the server still authorizes every endpoint; a dual customer+nurse session holds both roles and moves freely.
- a
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).
- warning + retry). Distinct from
- Wired the four shells —
(customer)/nurse/adminlayouts wrap inRoleGuard expected={APP_ROLES.*};partnerwraps in a role-lessRoleGuard. The guard sits outside the shell component so its nav chrome never renders during load/redirect. - Doc hardening —
useActorRole()'sDEFAULT_ROLEfallback is now documented as a last resort (the guard ensures hydration before a shell renders), never the loading state. No behavior change there (f15'suseAdminCapabilitiesstill reads the same session roleCodes). - i18n —
auth.guard_denied/account_error_title/account_error_body/account_error_retryin bothen.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) and09120000021(finance). An admin persona is just a phone user + a server-granted admin role (no profile) via the existingCreateUserAsync; idempotent (phone-guarded) like every other persona, Development-only. This is the sanctioned path to/adminthrough the normal phone-OTP login. Seeding two roles makesuseAdminCapabilitiesgating demonstrable — thefinanceoperator's sidebar shows only the money consoles.
What's now testable, and exactly how (DoD)
Run the app per the RUNBOOK, then:
- Nurse →
/nurse: log in as09120000001(verified nurse) → nurse shell + dashboard. - Customer →
/: log in as09120000010→ family app. Tap "become a nurse" (SelectRole) →POST me/select_rolein Network → after the/merefetch you're routed to/nurse. - Admin →
/admin: log in as09120000020→ admin console (all consoles incl. RBAC). Log in as09120000021→/adminwith only the finance consoles in the sidebar (useAdminCapabilities). - Mis-role redirect: as a pure customer, visit
/nurse→ redirected to/with theguard_deniedtoast. - 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.
/partneris a separate authz scope not derivable fromme.roles, soresolveRoleDestinationcan't route a partner admin there on login./partneris reachable by direct navigation (theservices/partnerCentermock resolves a center foruseMyPartnerCenter, so the shell renders rather than access-denied), and theRoleGuarddoesn't block it. The real login→/partnerneeds a/mesignal — 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 = falseuntouched) — 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";
MeResultgains noactiveRole. A dual customer+nurse session is disambiguated by the client-carried intended role (A1/B1 switch), defaulting to the family app;RoleGuardlets a dual-role user move between shells. - REQ-038 filed — a
/mepartner-center-admin signal for partner login-routing (see above).
Gate
- client:
npm run checkgreen;npm run test:ci -- RoleGuardgreen (8/8);en.json/fa.jsonin sync. - server:
dotnet build Baya.sln0 errors (warnings all pre-existing NuGet advisories / a migration's CS8632);DemoWorldSeederTests4/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
/mesignal + seed) — likely a small backend refinement phase. - Cross-actor hard route guarding is still server-side only;
RoleGuardis deliberately chrome-level UX.