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
+25 -7
View File
@@ -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
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "احراز هویت",
@@ -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 <CustomerLayout>{children}</CustomerLayout>;
return (
<RoleGuard expected={APP_ROLES.CUSTOMER}>
<CustomerLayout>{children}</CustomerLayout>
</RoleGuard>
);
}
@@ -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 <AdminLayout>{children}</AdminLayout>;
return (
<RoleGuard expected={APP_ROLES.ADMIN}>
<AdminLayout>{children}</AdminLayout>
</RoleGuard>
);
}
@@ -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 <NurseLayout>{children}</NurseLayout>;
return (
<RoleGuard expected={APP_ROLES.NURSE}>
<NurseLayout>{children}</NurseLayout>
</RoleGuard>
);
}
@@ -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 <PartnerLayout>{children}</PartnerLayout>;
return (
<RoleGuard>
<PartnerLayout>{children}</PartnerLayout>
</RoleGuard>
);
}
@@ -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<AuthAccountErrorProps> = ({ onRetry, isRetrying }) => {
const t = useTranslations('auth');
return (
<Stack
sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 2, px: 2, textAlign: 'center' }}
>
<BrandMark />
<AppIcon icon="warning" size={40} color="var(--bal-warning)" />
<Typography variant="h6" component="h1">
{t('account_error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
{t('account_error_body')}
</Typography>
<AppButton
color="primary"
variant="contained"
onClick={onRetry}
disabled={isRetrying}
startIcon={isRetrying ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{t('account_error_retry')}
</AppButton>
</Stack>
);
};
export default AuthAccountError;
@@ -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 = <div data-testid="shell">shell content</div>;
function renderGuard(expected?: 'customer' | 'nurse' | 'admin') {
render(
<ThemeProvider>
<RoleGuard expected={expected}>{CHILD}</RoleGuard>
</ThemeProvider>,
);
}
describe('<RoleGuard/>', () => {
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();
});
});
+63
View File
@@ -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<RoleGuardProps> = ({ 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 <AuthSplash message={t('routing_title')} />;
if (hydration.status === 'error')
return <AuthAccountError onRetry={hydration.retry} isRetrying={hydration.isRetrying} />;
// Role mismatch — hold the neutral splash while the redirect above navigates away.
if (!allowed) return <AuthSplash message={t('routing_title')} />;
return <>{children}</>;
};
export default RoleGuard;
+2
View File
@@ -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';
+5 -3
View File
@@ -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();
@@ -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' };
}
+2
View File
@@ -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';