From 1ce36f9414f80d4af28c2758cc6d1b29db5b97ec Mon Sep 17 00:00:00 2001 From: hamid Date: Mon, 13 Jul 2026 01:14:35 +0330 Subject: [PATCH] refinement phase 2 --- client/CLAUDE.md | 32 +++++-- client/messages/en.json | 6 +- client/messages/fa.json | 6 +- .../(private-routes)/(customer)/layout.tsx | 10 +- .../(private-routes)/admin/layout.tsx | 11 ++- .../(private-routes)/nurse/layout.tsx | 11 ++- .../(private-routes)/partner/layout.tsx | 11 ++- .../src/components/auth/AuthAccountError.tsx | 48 ++++++++++ client/src/components/auth/RoleGuard.test.tsx | 93 +++++++++++++++++++ client/src/components/auth/RoleGuard.tsx | 63 +++++++++++++ client/src/components/auth/index.ts | 2 + client/src/hooks/auth.ts | 8 +- .../services/auth/hooks/useRoleHydration.ts | 28 ++++++ client/src/services/auth/index.ts | 2 + dev/post-phase/refinement/RUNBOOK.md | 14 ++- dev/shared-working-context/frontend/STATUS.md | 17 ++++ .../frontend/requests/for-backend.md | 22 ++++- .../reports/refinement-phase-2-report.md | 85 +++++++++++++++++ server/CLAUDE.md | 7 +- .../Services/Seeding/DemoWorldDefinitions.cs | 22 +++++ .../Services/Seeding/DemoWorldSeeder.cs | 25 ++++- .../Baya.Test.Api/DemoWorldSeederTests.cs | 23 +++++ 22 files changed, 519 insertions(+), 27 deletions(-) create mode 100644 client/src/components/auth/AuthAccountError.tsx create mode 100644 client/src/components/auth/RoleGuard.test.tsx create mode 100644 client/src/components/auth/RoleGuard.tsx create mode 100644 client/src/services/auth/hooks/useRoleHydration.ts create mode 100644 dev/shared-working-context/reports/refinement-phase-2-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 12ca995..3daad0c 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -117,7 +117,7 @@ client/ │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me) │ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment - │ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout + │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → CustomerLayout │ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges) │ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3) │ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params @@ -155,7 +155,7 @@ client/ │ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging │ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell - │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout + │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout │ │ │ ├── page.tsx # /nurse (dashboard) │ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail) │ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) @@ -174,7 +174,7 @@ client/ │ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx │ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here │ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). - │ │ │ ├── layout.tsx # 'use client' — wraps AdminLayout (capability-gated nav) + │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav) │ │ │ ├── page.tsx # /admin — f15 overview landing: a capability-gated grid of console cards │ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified) │ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket) @@ -189,7 +189,7 @@ client/ │ │ │ ├── users/page.tsx # /admin/users │ │ │ └── notifications/page.tsx # /admin/notifications │ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404). - │ │ ├── layout.tsx # 'use client' — wraps PartnerLayout (own partner nav) + │ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter) │ │ ├── page.tsx # /partner — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator │ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge) │ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries) @@ -237,7 +237,7 @@ client/ │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) │ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts │ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts - │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown + │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown ├── i18n/ │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' │ └── request.ts # getRequestConfig — loads messages/${locale}.json @@ -277,7 +277,7 @@ client/ │ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie │ └── index.ts # Re-exports constants ONLY (never server/client) ├── services/ # Domain services — no top-level barrel; import directly from the file - │ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync + │ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard) │ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper │ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam) │ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states @@ -408,7 +408,7 @@ async function MyServerComponent() { - `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard` - `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}` — `admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry` - `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell` -- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) +- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) - `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites - `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow` @@ -685,6 +685,24 @@ splash while `/me` loads so the wrong shell never flashes. The routing decision `resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware still owns the auth gate; the router only decides *which app*. +**Role-aware shell guard (resolved-vs-pending hydration).** Every private shell — `(customer)`, `nurse`, +`admin`, `partner` — wraps its layout in **`RoleGuard`** (`src/components/auth/RoleGuard.tsx`). This exists +because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no +nurse/admin role"**: a fresh `/me` in-flight used to fall through the `DEFAULT_ROLE = customer` fallback and +flash a nurse the customer app (or strand them there if `/me` failed). `RoleGuard` reads +**`useRoleHydration()`** (`services/auth`, a discriminated `loading | error | ready` over `useMe`) and: +- **loading** → a neutral brand splash (never the customer shell as a stand-in); +- **error** (`/me` failed, e.g. API down) → `AuthAccountError` with retry (never a silent customer fallback — + a transient error must not downgrade a nurse/admin); +- **role mismatch** → redirect to the caller's real app via `resolveRoleDestination` (the single "which app" + source) with a `guard_denied` toast, instead of rendering a shell they lack the role for. + +A shell passes `expected={APP_ROLES.*}`; the partner portal passes **no** `expected` (it isn't an `AppRole` +— it self-gates on `useMyPartnerCenter`, so `RoleGuard` there only hardens hydration). The guard is **UX/chrome, +not security** — the server authorizes every endpoint; a dual customer+nurse session holds both roles and moves +freely between the family and nurse apps. `useActorRole()`'s `DEFAULT_ROLE` fallback is now only a last resort +(the guard ensures roles are hydrated before a shell renders), never the loading state. + **Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone, roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()` (`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared diff --git a/client/messages/en.json b/client/messages/en.json index ce364fc..e2fb649 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -648,7 +648,11 @@ "role_customer_desc": "Book nurses and home care", "role_nurse": "Nurse", "role_nurse_desc": "Offer nursing services", - "continue": "Continue" + "continue": "Continue", + "guard_denied": "You don't have access to that area.", + "account_error_title": "Couldn't load your account", + "account_error_body": "We couldn't reach Balinyaar to load your account. Check your connection and try again.", + "account_error_retry": "Try again" }, "verification": { "title": "Verification", diff --git a/client/messages/fa.json b/client/messages/fa.json index 24cf5c8..1949ec4 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -648,7 +648,11 @@ "role_customer_desc": "برای رزرو پرستار و مراقبت در منزل", "role_nurse": "پرستار", "role_nurse_desc": "برای ارائه خدمات پرستاری", - "continue": "ادامه" + "continue": "ادامه", + "guard_denied": "شما به این بخش دسترسی ندارید.", + "account_error_title": "حساب شما بارگذاری نشد", + "account_error_body": "در ارتباط با بلینیار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.", + "account_error_retry": "تلاش مجدد" }, "verification": { "title": "احراز هویت", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/layout.tsx b/client/src/app/[locale]/(private-routes)/(customer)/layout.tsx index 88a1b6e..0570746 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/layout.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/layout.tsx @@ -1,12 +1,20 @@ 'use client'; import type { ReactNode } from 'react'; import { CustomerLayout } from '@/layout'; +import { RoleGuard } from '@/components/auth'; +import { APP_ROLES } from '@/constants'; /* * Customer (family) route group — the primary mobile-first experience with the * 5-tab bottom nav. A route group `(customer)` adds chrome without adding a URL * segment, so these screens live at the app root (/, /bookings, /patients, …). + * RoleGuard gates it on a resolved customer role: a pure nurse lands on /nurse, + * a role-less user on /select-role — never the family app as a loading stand-in. */ export default function CustomerRouteLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/client/src/app/[locale]/(private-routes)/admin/layout.tsx b/client/src/app/[locale]/(private-routes)/admin/layout.tsx index 8d1903e..d11b714 100644 --- a/client/src/app/[locale]/(private-routes)/admin/layout.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/layout.tsx @@ -1,11 +1,18 @@ 'use client'; import type { ReactNode } from 'react'; import { AdminLayout } from '@/layout'; +import { RoleGuard } from '@/components/auth'; +import { APP_ROLES } from '@/constants'; /* * Admin / backoffice route group (/admin/…) — desktop-oriented ops console (f15) - * with a persistent sidebar. + * with a persistent sidebar. RoleGuard gates the shell on the (collapsed) admin actor + * role; the per-console fine-grained gating stays with useAdminCapabilities inside. */ export default function AdminRouteLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/layout.tsx b/client/src/app/[locale]/(private-routes)/nurse/layout.tsx index bb5ff11..67da399 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/layout.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/layout.tsx @@ -1,11 +1,18 @@ 'use client'; import type { ReactNode } from 'react'; import { NurseLayout } from '@/layout'; +import { RoleGuard } from '@/components/auth'; +import { APP_ROLES } from '@/constants'; /* * Nurse route group (/nurse/…) — its own shell (dashboard, verification, EVV visits). - * A real path segment keeps nurse screens namespaced under /nurse. + * A real path segment keeps nurse screens namespaced under /nurse. RoleGuard redirects + * a caller without the nurse role home (with a toast); a nurse never flashes the wrong app. */ export default function NurseRouteLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/client/src/app/[locale]/(private-routes)/partner/layout.tsx b/client/src/app/[locale]/(private-routes)/partner/layout.tsx index 91e5425..939362b 100644 --- a/client/src/app/[locale]/(private-routes)/partner/layout.tsx +++ b/client/src/app/[locale]/(private-routes)/partner/layout.tsx @@ -1,12 +1,19 @@ 'use client'; import type { ReactNode } from 'react'; import { PartnerLayout } from '@/layout'; +import { RoleGuard } from '@/components/auth'; /* * Partner-center portal route group (/partner/…) — a separate authz scope from /admin (f15). A center * admin sees only their own center; tenancy is server-enforced and each portal page resolves the caller's - * own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state). + * own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state). The RoleGuard here takes + * no `expected` role — partner scope isn't an `AppRole`, so the guard only hardens `/me` hydration (neutral + * loading/error instead of the raw shell); the center-resolution gate stays with `useMyPartnerCenter`. */ export default function PartnerRouteLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/client/src/components/auth/AuthAccountError.tsx b/client/src/components/auth/AuthAccountError.tsx new file mode 100644 index 0000000..115f29a --- /dev/null +++ b/client/src/components/auth/AuthAccountError.tsx @@ -0,0 +1,48 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { CircularProgress, Stack, Typography } from '@mui/material'; +import { useTranslations } from 'next-intl'; + +import { AppButton } from '@/components'; +import AppIcon from '@/components/common/AppIcon'; +import BrandMark from './BrandMark'; + +interface AuthAccountErrorProps { + onRetry: () => void; + isRetrying: boolean; +} + +/** + * Shown by `RoleGuard` when `/me` fails on a private route (e.g. the API is unreachable). Surfacing an + * explicit "couldn't load your account" recovery is the deliberate alternative to silently defaulting to + * the customer shell — a transient error must never downgrade a nurse/admin to the family app. + * @component AuthAccountError + */ +const AuthAccountError: FunctionComponent = ({ onRetry, isRetrying }) => { + const t = useTranslations('auth'); + return ( + + + + + {t('account_error_title')} + + + {t('account_error_body')} + + : undefined} + > + {t('account_error_retry')} + + + ); +}; + +export default AuthAccountError; diff --git a/client/src/components/auth/RoleGuard.test.tsx b/client/src/components/auth/RoleGuard.test.tsx new file mode 100644 index 0000000..07c76dc --- /dev/null +++ b/client/src/components/auth/RoleGuard.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import type { RoleHydration } from '@/services/auth'; + +const mockReplace = jest.fn(); +const mockEnqueue = jest.fn(); + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: () => ({ replace: mockReplace }), +})); +jest.mock('next-intl', () => ({ useLocale: () => 'fa', useTranslations: () => (key: string) => key })); +jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: mockEnqueue }) })); + +let hydration: RoleHydration; +jest.mock('@/services/auth', () => ({ useRoleHydration: () => hydration })); + +import RoleGuard from './RoleGuard'; + +const CHILD =
shell content
; + +function renderGuard(expected?: 'customer' | 'nurse' | 'admin') { + render( + + {CHILD} + , + ); +} + +describe('', () => { + beforeEach(() => { + mockReplace.mockReset(); + mockEnqueue.mockReset(); + }); + + it('renders a neutral splash (not the shell) while /me is loading', () => { + hydration = { status: 'loading' }; + renderGuard('nurse'); + expect(screen.queryByTestId('shell')).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('renders the account-error state (not the shell) when /me failed', () => { + hydration = { status: 'error', retry: jest.fn(), isRetrying: false }; + renderGuard('nurse'); + expect(screen.queryByTestId('shell')).not.toBeInTheDocument(); + expect(screen.getByText('account_error_title')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('calls retry when the account-error button is pressed', async () => { + const retry = jest.fn(); + hydration = { status: 'error', retry, isRetrying: false }; + renderGuard('nurse'); + await userEvent.click(screen.getByText('account_error_retry')); + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('renders the shell when the session holds the expected role', () => { + hydration = { status: 'ready', me: { roles: ['nurse'] } as never, appRoles: ['nurse'] }; + renderGuard('nurse'); + expect(screen.getByTestId('shell')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('lets a dual customer+nurse session into the nurse shell', () => { + hydration = { status: 'ready', me: { roles: ['customer', 'nurse'] } as never, appRoles: ['customer', 'nurse'] }; + renderGuard('nurse'); + expect(screen.getByTestId('shell')).toBeInTheDocument(); + }); + + it('redirects (with a toast) a pure customer away from the nurse shell', async () => { + hydration = { status: 'ready', me: { roles: ['customer'] } as never, appRoles: ['customer'] }; + renderGuard('nurse'); + expect(screen.queryByTestId('shell')).not.toBeInTheDocument(); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/')); + expect(mockEnqueue).toHaveBeenCalledWith('guard_denied', { variant: 'warning' }); + }); + + it('redirects a role-less user to select-role', async () => { + hydration = { status: 'ready', me: { roles: [] } as never, appRoles: [] }; + renderGuard('customer'); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/select-role')); + }); + + it('with no expected role (partner scope) renders once /me resolves without redirecting', () => { + hydration = { status: 'ready', me: { roles: ['customer'] } as never, appRoles: ['customer'] }; + renderGuard(undefined); + expect(screen.getByTestId('shell')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/auth/RoleGuard.tsx b/client/src/components/auth/RoleGuard.tsx new file mode 100644 index 0000000..a9d4366 --- /dev/null +++ b/client/src/components/auth/RoleGuard.tsx @@ -0,0 +1,63 @@ +'use client'; +import { FunctionComponent, ReactNode, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; +import { useSnackbar } from 'notistack'; + +import { type AppRole } from '@/constants'; +import { useRoleHydration } from '@/services/auth'; +import { resolveRoleDestination } from '@/services/auth/routing'; +import AuthSplash from './AuthSplash'; +import AuthAccountError from './AuthAccountError'; + +interface RoleGuardProps { + /** + * The actor shell being entered. When set, a session that lacks the role is **redirected** to its real + * destination (chrome UX, not security — the server still authorizes every endpoint). Omit for a scope + * not keyed on `AppRole` (the partner portal, which self-gates via `useMyPartnerCenter`); the guard then + * only hardens hydration — a neutral loading/error state instead of the raw shell while `/me` resolves. + */ + expected?: AppRole; + children: ReactNode; +} + +/** + * The client-side role-aware navigation guard for the private shells (refinement-phase-2). It gates a shell + * on the resolved-vs-pending role state (`useRoleHydration`) so the wrong actor app never renders: + * - **loading** — a brand splash while `/me` is in flight (never the customer shell as a stand-in); + * - **error** — an explicit account-error recovery when `/me` failed (never a silent customer fallback); + * - **role mismatch** — redirect to the caller's real app (`resolveRoleDestination`, the single source of + * "which app") with a toast, rather than rendering a shell they lack the role for. + * + * A dual customer+nurse session holds both roles, so it passes either shell's guard and can move freely + * between the family and nurse apps. + * @component RoleGuard + */ +const RoleGuard: FunctionComponent = ({ expected, children }) => { + const t = useTranslations('auth'); + const router = useRouter(); + const locale = useLocale(); + const { enqueueSnackbar } = useSnackbar(); + const hydration = useRoleHydration(); + + const me = hydration.status === 'ready' ? hydration.me : null; + const appRoles = hydration.status === 'ready' ? hydration.appRoles : null; + const allowed = !expected || (appRoles?.includes(expected) ?? false); + // Stable across renders for a given identity (a string), so the redirect effect fires once, not per render. + const redirectTo = me && !allowed ? `/${locale}${resolveRoleDestination(me)}` : null; + + useEffect(() => { + if (!redirectTo) return; + enqueueSnackbar(t('guard_denied'), { variant: 'warning' }); + router.replace(redirectTo); + }, [redirectTo, enqueueSnackbar, t, router]); + + if (hydration.status === 'loading') return ; + if (hydration.status === 'error') + return ; + // Role mismatch — hold the neutral splash while the redirect above navigates away. + if (!allowed) return ; + return <>{children}; +}; + +export default RoleGuard; diff --git a/client/src/components/auth/index.ts b/client/src/components/auth/index.ts index 4b84a60..98c4a64 100644 --- a/client/src/components/auth/index.ts +++ b/client/src/components/auth/index.ts @@ -2,3 +2,5 @@ export { default as LoginFlow } from './LoginFlow'; export { default as RoleRouter } from './RoleRouter'; export { default as SelectRole } from './SelectRole'; export { default as AuthSplash } from './AuthSplash'; +export { default as RoleGuard } from './RoleGuard'; +export { default as AuthAccountError } from './AuthAccountError'; diff --git a/client/src/hooks/auth.ts b/client/src/hooks/auth.ts index 2c5407d..0561837 100644 --- a/client/src/hooks/auth.ts +++ b/client/src/hooks/auth.ts @@ -19,9 +19,11 @@ const ROLE_PRECEDENCE: AppRole[] = [APP_ROLES.ADMIN, APP_ROLES.NURSE, APP_ROLES. /** * The actor experience the current session should see, read from the session roles. * - * Roles are seeded by the server in f1-b2; until then sessions carry no roles and - * this returns DEFAULT_ROLE (customer) so the shells degrade gracefully. Route-group - * layouts use it to drive role-aware navigation and (later) access guards. + * Chrome-only signal: which nav/shell the shells build from the collapsed session roles. The DEFAULT_ROLE + * (customer) fallback is a **last resort**, not the loading state — the shells are wrapped in `RoleGuard` + * (refinement-phase-2), which holds a neutral splash until `/me` resolves and redirects a role mismatch, so + * this is only ever read once the roles are hydrated. Never gate "which app" on this fallback; that decision + * is `resolveRoleDestination` (via `RoleGuard`/`RoleRouter`), the single source of truth. */ export function useActorRole(): AppRole { const [state] = useAuth(); diff --git a/client/src/services/auth/hooks/useRoleHydration.ts b/client/src/services/auth/hooks/useRoleHydration.ts new file mode 100644 index 0000000..ff53a0d --- /dev/null +++ b/client/src/services/auth/hooks/useRoleHydration.ts @@ -0,0 +1,28 @@ +import { useMe } from './useMe'; +import { toAppRoles } from '../routing'; +import type { Me } from '../types'; +import type { AppRole } from '@/constants'; + +/** + * The resolved-vs-pending role state for a private route. The core refinement-phase-2 fix: a shell must + * distinguish **"/me hasn't resolved yet"** from **"the user has no nurse/admin role"** — conflating the + * two is what silently showed a nurse the customer app (a fresh `/me` in-flight fell through the + * `DEFAULT_ROLE = customer` fallback). This exposes that distinction so `RoleGuard` can render a neutral + * loading state while pending, an explicit error state when `/me` failed, and only route on a resolved + * role set. + * + * `error` fires only when `/me` has no data at all; a background refetch that fails while we still hold a + * cached identity keeps serving `ready` (don't downgrade a known nurse on a transient blip). + */ +export type RoleHydration = + | { status: 'loading' } + | { status: 'error'; retry: () => void; isRetrying: boolean } + | { status: 'ready'; me: Me; appRoles: AppRole[] }; + +export function useRoleHydration(): RoleHydration { + const { data: me, isError, isFetching, refetch } = useMe(); + + if (me) return { status: 'ready', me, appRoles: toAppRoles(me.roles) }; + if (isError) return { status: 'error', retry: () => void refetch(), isRetrying: isFetching }; + return { status: 'loading' }; +} diff --git a/client/src/services/auth/index.ts b/client/src/services/auth/index.ts index faba734..994c1c9 100644 --- a/client/src/services/auth/index.ts +++ b/client/src/services/auth/index.ts @@ -5,3 +5,5 @@ export { useRefresh } from './hooks/useRefresh'; export { useLogout } from './hooks/useLogout'; export { useSelectRole } from './hooks/useSelectRole'; export { useSessionRoleSync } from './hooks/useSessionRoleSync'; +export { useRoleHydration } from './hooks/useRoleHydration'; +export type { RoleHydration } from './hooks/useRoleHydration'; diff --git a/dev/post-phase/refinement/RUNBOOK.md b/dev/post-phase/refinement/RUNBOOK.md index 3ff804c..abfebbf 100644 --- a/dev/post-phase/refinement/RUNBOOK.md +++ b/dev/post-phase/refinement/RUNBOOK.md @@ -110,7 +110,16 @@ endpoint works). Use them to see the real path populated: | `09120000003` | nurse | مریم احمدی (female) | **unverified** — not discoverable in search | | `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address | | `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address | -| `admin` / `qw123321` | admin | seeded admin (username+password) | backoffice | +| `09120000020` | admin (`super_admin`) | نگار مدیری (female) | full backoffice — **lands on `/admin`**, sees every console incl. RBAC | +| `09120000021` | admin (`finance`) | کامران مالی (male) | scoped backoffice — lands on `/admin`, sidebar shows only the money consoles (`useAdminCapabilities` gating) | +| `admin` / `qw123321` | admin | reference super-admin (username+password) | not a phone-OTP login — the frontend uses the phone admins above | + +The **phone-OTP admins** (`09120000020` / `09120000021`, refinement-phase-2) are how you reach the `/admin` +console through the same web login flow as everyone else — admin sub-roles are server-granted, never +self-selectable via `me/select_role`. Log in with either phone exactly like a nurse/customer; role +hydration routes you to `/admin`. To reach the **nurse** app, log in as a verified nurse phone +(`09120000001`); a fresh customer can also become a nurse in-app (SelectRole → `me/select_role`) and is +then routed to `/nurse` after the next `/me`. Prove search works without the frontend: open Swagger → `GET /api/v1/search/nurses?service_category_id=1&city_id=101` returns the two verified nurses' variants; @@ -129,7 +138,8 @@ Prove search works without the frontend: open Swagger → `{ "data": { "phone": "09120000001", "code": "123456" }, ... }`. This endpoint returns **404 outside Development** and is superseded by real SMS in [Refinement Phase 8](refinement-phase-8-external-rails.md). -4. Enter the code and submit → you land on the customer home. +4. Enter the code and submit → role hydration routes you to the app for your role: a customer to the family + home (`/`), a nurse to `/nurse`, an admin to `/admin` (refinement-phase-2). 5. **Verify in DevTools → Network:** `POST /api/v1/auth/request_otp`, `POST /api/v1/auth/verify_otp`, and `GET /api/v1/me` all return **200** with the `ApiResult` envelope, and there is **no CORS error** in the console. That is the first real authenticated request between the two projects. diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index b9b6f57..cb348e3 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -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 / diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index ad0c554..fa10d41 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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 diff --git a/dev/shared-working-context/reports/refinement-phase-2-report.md b/dev/shared-working-context/reports/refinement-phase-2-report.md new file mode 100644 index 0000000..cc44f41 --- /dev/null +++ b/dev/shared-working-context/reports/refinement-phase-2-report.md @@ -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. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 7008b3e..eab193f 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -575,8 +575,11 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO - **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs` (+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference `HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials - + a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), and one - cross-category required demo option group (شیفت / *Shift Type*). It writes through the real entities and + + a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), **2 phone-OTP + admins** (refinement-phase-2: a `super_admin` + a scoped `finance` operator, so the `/admin` console is + reachable through the normal phone-OTP login and `useAdminCapabilities` gating is demonstrable — admin + sub-roles are server-granted, never self-selectable), and one cross-category required demo option group + (شیفت / *Shift Type*). It writes through the real entities and drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows), guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()` **only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs index ab09e69..9e6ba06 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs @@ -1,5 +1,6 @@ #nullable enable using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.User; using Baya.Domain.Entities.Verification; using Baya.Infrastructure.Persistence.Configuration.GeographyConfig; @@ -131,6 +132,19 @@ internal static class DemoWorldDefinitions ]), ]; + /// + /// Demo backoffice operators so the admin console is reachable through the same phone-OTP login the + /// customers/nurses use (admin sub-roles are server-granted, never self-selectable via select_role, + /// so they must be seeded). Two roles are seeded on purpose: a super_admin that sees every console + /// (including RBAC) and a scoped finance operator, so the frontend's useAdminCapabilities() + /// role-gating is demonstrable — the finance operator's sidebar shows only the money consoles. + /// + public static readonly AdminPersona[] Admins = + [ + new AdminPersona("09120000020", "demo_admin_root", "نگار", "مدیری", "female", RoleNames.SuperAdmin), + new AdminPersona("09120000021", "demo_admin_finance", "کامران", "مالی", "male", RoleNames.Finance), + ]; + public static readonly CustomerPersona[] Customers = [ new CustomerPersona( @@ -185,6 +199,14 @@ internal sealed record NursePersona( VariantDef[] Variants, AreaDef[] Areas); +internal sealed record AdminPersona( + string Phone, + string UserName, + string Name, + string FamilyName, + string Gender, + string RoleName); + internal sealed record CredentialDef(string Type, string IssuingAuthority, string Number); internal sealed record BankDef(string BankName, string AccountHolderName, string Iban, bool MatchedNationalId); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldSeeder.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldSeeder.cs index 29fc360..9fb2568 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldSeeder.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldSeeder.cs @@ -48,19 +48,24 @@ internal sealed class DemoWorldSeeder( if (await EnsureCustomerAsync(persona, cancellationToken)) seededCustomers++; + var seededAdmins = 0; + foreach (var persona in DemoWorldDefinitions.Admins) + if (await EnsureAdminAsync(persona, cancellationToken)) + seededAdmins++; + // Re-derive the whole search projection from source. Idempotent (drops + rebuilds), and the single // place is_searchable is computed — verified+accepting nurses with an active variant surface; the // unverified nurse does not. var rebuild = await searchIndex.RebuildAsync(cancellationToken); - if (seededNurses == 0 && seededCustomers == 0) + if (seededNurses == 0 && seededCustomers == 0 && seededAdmins == 0) logger.LogInformation( "Demo world already seeded — no-op. Search index re-derived: {Nurses} nurses, {Rows} rows.", rebuild.NursesProcessed, rebuild.RowsWritten); else logger.LogInformation( - "Demo world seeded: {Nurses} nurse(s), {Customers} customer(s). Search index: {IndexNurses} nurses, {Rows} searchable-eligible rows.", - seededNurses, seededCustomers, rebuild.NursesProcessed, rebuild.RowsWritten); + "Demo world seeded: {Nurses} nurse(s), {Customers} customer(s), {Admins} admin(s). Search index: {IndexNurses} nurses, {Rows} searchable-eligible rows.", + seededNurses, seededCustomers, seededAdmins, rebuild.NursesProcessed, rebuild.RowsWritten); } private async Task<(long GroupId, IReadOnlyDictionary ValueIdByCode)> EnsureShiftTypeGroupAsync( @@ -262,6 +267,20 @@ internal sealed class DemoWorldSeeder( return true; } + /// true if the admin was newly created; false if it already existed. + private async Task EnsureAdminAsync(AdminPersona persona, CancellationToken cancellationToken) + { + if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null) + return false; + + // An admin is just a phone user + a server-granted admin role — no NurseProfile/CustomerProfile. This + // is the only way to reach the /admin console through the phone-OTP login (admin sub-roles are never + // self-selectable via me/select_role). + await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName, + persona.Gender, nationalId: null, persona.RoleName, cancellationToken); + return true; + } + private async Task CreateUserAsync( string userName, string phone, string name, string familyName, string gender, string? nationalId, string roleName, CancellationToken cancellationToken) diff --git a/server/src/Tests/Baya.Test.Api/DemoWorldSeederTests.cs b/server/src/Tests/Baya.Test.Api/DemoWorldSeederTests.cs index 5c9c8a1..34a4ded 100644 --- a/server/src/Tests/Baya.Test.Api/DemoWorldSeederTests.cs +++ b/server/src/Tests/Baya.Test.Api/DemoWorldSeederTests.cs @@ -1,5 +1,7 @@ using System.Net; +using Baya.Application.Contracts.Identity; using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.User; using Baya.Infrastructure.Persistence; using Baya.Infrastructure.Persistence.Services.Seeding; using Microsoft.EntityFrameworkCore; @@ -87,4 +89,25 @@ public class DemoWorldSeederTests(BayaApiFactory factory) : IClassFixture().CountAsync()); Assert.Equal(DemoWorldDefinitions.Customers.Length, await db.Set().CountAsync()); } + + [Fact] + public async Task Seed_AdminPersonas_AreReachableWithTheirGrantedRoles() + { + await RunSeederAsync(); + + using var scope = factory.Services.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService(); + + // The super_admin can reach the /admin console (routes there) and sees the RBAC console. + var root = await users.GetUserByPhoneNumber("09120000020"); + Assert.NotNull(root); + Assert.Contains(RoleNames.SuperAdmin, await users.GetRoleAsync(root)); + + // The scoped finance operator carries only the finance role — the fine grain useAdminCapabilities gates on. + var finance = await users.GetUserByPhoneNumber("09120000021"); + Assert.NotNull(finance); + var financeRoles = await users.GetRoleAsync(finance); + Assert.Contains(RoleNames.Finance, financeRoles); + Assert.DoesNotContain(RoleNames.SuperAdmin, financeRoles); + } }