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
@@ -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';