refinement phase 2
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user