frontend phase 1: auth — phone-OTP login, role routing & session refresh
Replace the username/password stub with Balinyaar's real credential (phone-OTP) and add the role router every authenticated screen sits behind. - services/auth rewritten for OTP over the b2 contract: types/keys/constants, clientApi+mockApi behind a config'd seam, hooks (useRequestOtp/useVerifyOtp/ useMe/useRefresh/useLogout/useSelectRole/useSessionRoleSync). Stub removed. - A1/A2 customer login + B1/B2 nurse switch as one OTP flow at /login (PhoneStep/OtpStep: auto-verify, resend countdown, wrong/expired/lockout states). - Role router: pure resolveRoleDestination + RoleRouter -> family / nurse / admin / select-role, with a splash while /me loads (no wrong-shell flash). - SelectRole first-use screen at /select-role. - Widened AuthState (roles via SessionUser), hydrated from /me by useSessionRoleSync. - Fetch-layer silent token refresh (single-flight + one retry) + shared persistAuthTokens/clearAuthTokens; useRefresh as the on-demand path. - auth i18n namespace in both locales; tests for routing branches, countdown, OtpStep state machine, RoleRouter branches; fixed jest @/ -> src alias. - Docs: client/CLAUDE.md, frontend STATUS/report, for-backend REQ-002..004. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,15 @@ export function isIranianMobile(value: string): boolean {
|
||||
return /^09\d{9}$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks a mobile number for a "code sent to …" echo, keeping the first 4 and last 4 digits
|
||||
* (e.g. `09120001234` → `0912•••1234`). Short/partial input is returned unchanged.
|
||||
*/
|
||||
export function maskIranMobile(value: string): string {
|
||||
if (value.length < 8) return value;
|
||||
return `${value.slice(0, 4)}•••${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
export interface PhoneNumberFieldProps extends Omit<TextFieldProps, 'onChange' | 'value' | 'type'> {
|
||||
/** Controlled value — normalized ASCII digits, no formatting. */
|
||||
value: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PhoneNumberField from './PhoneNumberField';
|
||||
|
||||
export type { PhoneNumberFieldProps } from './PhoneNumberField';
|
||||
export { IRAN_MOBILE_LENGTH, isIranianMobile } from './PhoneNumberField';
|
||||
export { IRAN_MOBILE_LENGTH, isIranianMobile, maskIranMobile } from './PhoneNumberField';
|
||||
export { PhoneNumberField as default, PhoneNumberField };
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { Paper, Stack } from '@mui/material';
|
||||
import { AUTH_CARD_MAX_WIDTH } from './constants';
|
||||
import BrandMark from './BrandMark';
|
||||
|
||||
/**
|
||||
* Centered branded card that hosts each auth step (phone, OTP, role selection). Presentational
|
||||
* shell only — the step content is passed as children.
|
||||
* @component AuthCard
|
||||
*/
|
||||
const AuthCard: FunctionComponent<PropsWithChildren> = ({ children }) => (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: AUTH_CARD_MAX_WIDTH,
|
||||
p: { xs: 3, sm: 4 },
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<BrandMark withTagline />
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default AuthCard;
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { AppLoading } from '@/components';
|
||||
import BrandMark from './BrandMark';
|
||||
|
||||
interface AuthSplashProps {
|
||||
/** Already-translated status line (e.g. "Signing you in…"). */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-height branded loading splash shown while the role router resolves `/me` — prevents the
|
||||
* wrong actor shell from flashing before the destination is known.
|
||||
* @component AuthSplash
|
||||
*/
|
||||
const AuthSplash: FunctionComponent<AuthSplashProps> = ({ message }) => (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 2 }}>
|
||||
<BrandMark />
|
||||
<AppLoading />
|
||||
{message && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default AuthSplash;
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
|
||||
interface BrandMarkProps {
|
||||
/** Show the brand tagline under the wordmark. */
|
||||
withTagline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Balinyaar brand lockup for the auth screens: logo mark + wordmark (+ optional tagline).
|
||||
* Colours come from the palette so it tracks the color scheme.
|
||||
* @component BrandMark
|
||||
*/
|
||||
const BrandMark: FunctionComponent<BrandMarkProps> = ({ withTagline }) => {
|
||||
const t = useTranslations('common');
|
||||
return (
|
||||
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="logo" size={56} color="var(--bal-primary)" />
|
||||
<Typography variant="h5" component="p" sx={{ fontWeight: 700, color: 'primary.main' }}>
|
||||
{t('brand')}
|
||||
</Typography>
|
||||
{withTagline && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center' }}>
|
||||
{t('brand_tagline')}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandMark;
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { APP_ROLES, type AppRole } from '@/constants';
|
||||
import { OTP_RESEND_FALLBACK_SECONDS } from '@/services/auth/constants';
|
||||
import AuthCard from './AuthCard';
|
||||
import PhoneStep from './PhoneStep';
|
||||
import OtpStep from './OtpStep';
|
||||
import RoleRouter from './RoleRouter';
|
||||
|
||||
type Step = 'phone' | 'otp' | 'routing';
|
||||
|
||||
/**
|
||||
* The single login stack for both actors (A1/A2 customer, B1/B2 nurse). One OTP mechanism,
|
||||
* parameterised by `intendedRole` (seeded from `?role=nurse`) — no forked login trees. After a
|
||||
* successful verify it hands off to the role router.
|
||||
* @component LoginFlow
|
||||
*/
|
||||
const LoginFlow: FunctionComponent = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const [intendedRole, setIntendedRole] = useState<AppRole>(
|
||||
searchParams.get('role') === APP_ROLES.NURSE ? APP_ROLES.NURSE : APP_ROLES.CUSTOMER,
|
||||
);
|
||||
const [step, setStep] = useState<Step>('phone');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [resendSeconds, setResendSeconds] = useState(OTP_RESEND_FALLBACK_SECONDS);
|
||||
|
||||
if (step === 'routing') {
|
||||
return <RoleRouter intendedRole={intendedRole} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard>
|
||||
{step === 'phone' ? (
|
||||
<PhoneStep
|
||||
intendedRole={intendedRole}
|
||||
onSwitchRole={() =>
|
||||
setIntendedRole((role) =>
|
||||
role === APP_ROLES.NURSE ? APP_ROLES.CUSTOMER : APP_ROLES.NURSE,
|
||||
)
|
||||
}
|
||||
onSent={(nextPhone, result) => {
|
||||
setPhone(nextPhone);
|
||||
setResendSeconds(result.resendAvailableInSeconds);
|
||||
setStep('otp');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<OtpStep
|
||||
phone={phone}
|
||||
intendedRole={intendedRole}
|
||||
resendSeconds={resendSeconds}
|
||||
onVerified={() => setStep('routing')}
|
||||
onChangeNumber={() => setStep('phone')}
|
||||
/>
|
||||
)}
|
||||
</AuthCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginFlow;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
const mockVerifyMutate = jest.fn();
|
||||
const mockVerifyReset = jest.fn();
|
||||
const mockRequestMutate = jest.fn();
|
||||
|
||||
jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
jest.mock('@/services/auth', () => ({
|
||||
useVerifyOtp: () => ({ isPending: false, isError: false, mutate: mockVerifyMutate, reset: mockVerifyReset }),
|
||||
useRequestOtp: () => ({ isPending: false, mutate: mockRequestMutate }),
|
||||
}));
|
||||
|
||||
import OtpStep from './OtpStep';
|
||||
|
||||
const PHONE = '09120001234';
|
||||
|
||||
function renderStep() {
|
||||
const onVerified = jest.fn();
|
||||
const onChangeNumber = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<OtpStep
|
||||
phone={PHONE}
|
||||
intendedRole="customer"
|
||||
resendSeconds={120}
|
||||
onVerified={onVerified}
|
||||
onChangeNumber={onChangeNumber}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onVerified, onChangeNumber };
|
||||
}
|
||||
|
||||
async function fillCode(code: string) {
|
||||
const user = userEvent.setup();
|
||||
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
for (let i = 0; i < code.length; i += 1) {
|
||||
await user.type(boxes[i], code[i]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('<OtpStep/> state machine', () => {
|
||||
beforeEach(() => {
|
||||
mockVerifyMutate.mockReset();
|
||||
mockVerifyReset.mockReset();
|
||||
mockRequestMutate.mockReset();
|
||||
});
|
||||
|
||||
it('renders one box per code digit (6)', () => {
|
||||
renderStep();
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('auto-verifies once the last box is filled', async () => {
|
||||
renderStep();
|
||||
await fillCode('123456');
|
||||
expect(mockVerifyMutate).toHaveBeenCalledWith(
|
||||
{ phone: PHONE, code: '123456' },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onVerified on a successful verify', async () => {
|
||||
mockVerifyMutate.mockImplementation((_vars, opts) => opts.onSuccess?.());
|
||||
const { onVerified } = renderStep();
|
||||
await fillCode('123456');
|
||||
expect(onVerified).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears the boxes on a wrong code so the user can retry', async () => {
|
||||
mockVerifyMutate.mockImplementation((_vars, opts) => opts.onError?.(new Error('bad code')));
|
||||
renderStep();
|
||||
await fillCode('000000');
|
||||
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
expect(boxes.every((box) => box.value === '')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
||||
import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import OtpInput from '@/components/OtpInput';
|
||||
import { maskIranMobile } from '@/components/PhoneNumberField';
|
||||
import { AppButton } from '@/components';
|
||||
import { APP_ROLES, type AppRole } from '@/constants';
|
||||
import { useRequestOtp, useVerifyOtp } from '@/services/auth';
|
||||
import { OTP_CODE_LENGTH, OTP_LOCKED_CODE } from '@/services/auth/constants';
|
||||
import type { ApiError } from '@/lib/api/errors';
|
||||
import { useCountdown } from './useCountdown';
|
||||
|
||||
interface OtpStepProps {
|
||||
phone: string;
|
||||
intendedRole: AppRole;
|
||||
/** Server's resend cooldown from the request step; drives the initial countdown. */
|
||||
resendSeconds: number;
|
||||
onVerified: () => void;
|
||||
onChangeNumber: () => void;
|
||||
}
|
||||
|
||||
function formatMmSs(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 (customer) / B2 (nurse) — the OTP code step. Auto-verifies once the last box is filled,
|
||||
* runs a single resend countdown (cleaned up on unmount), and renders explicit wrong-code,
|
||||
* expired-code and max-attempts-lockout states. Same mechanism for both actors; the CTA copy
|
||||
* differs.
|
||||
* @component OtpStep
|
||||
*/
|
||||
const OtpStep: FunctionComponent<OtpStepProps> = ({
|
||||
phone,
|
||||
intendedRole,
|
||||
resendSeconds,
|
||||
onVerified,
|
||||
onChangeNumber,
|
||||
}) => {
|
||||
const t = useTranslations('auth');
|
||||
const [code, setCode] = useState('');
|
||||
const [locked, setLocked] = useState(false);
|
||||
const countdown = useCountdown();
|
||||
const verifyOtp = useVerifyOtp();
|
||||
const resendOtp = useRequestOtp();
|
||||
|
||||
const { start } = countdown;
|
||||
useEffect(() => start(resendSeconds), [start, resendSeconds]);
|
||||
|
||||
const isNurse = intendedRole === APP_ROLES.NURSE;
|
||||
const hasError = verifyOtp.isError;
|
||||
|
||||
const verify = (value: string) => {
|
||||
if (value.length !== OTP_CODE_LENGTH || verifyOtp.isPending || locked) return;
|
||||
verifyOtp.mutate(
|
||||
{ phone, code: value },
|
||||
{
|
||||
onSuccess: onVerified,
|
||||
onError: (error) => {
|
||||
if ((error as ApiError).code === OTP_LOCKED_CODE) setLocked(true);
|
||||
setCode(''); // clear the boxes so the user can retry a fresh code
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
verify(code);
|
||||
};
|
||||
|
||||
const resend = () => {
|
||||
resendOtp.mutate(
|
||||
{ phone },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setLocked(false);
|
||||
setCode('');
|
||||
verifyOtp.reset();
|
||||
start(result.resendAvailableInSeconds);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// While locked, requesting a new code is the way out — allow resend regardless of the timer.
|
||||
const resendDisabled = resendOtp.isPending || (countdown.isActive && !locked);
|
||||
|
||||
return (
|
||||
<Stack component="form" onSubmit={submit} sx={{ gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('otp_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('otp_sent_to', { phone: maskIranMobile(phone) })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<OtpInput
|
||||
length={OTP_CODE_LENGTH}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={verify}
|
||||
disabled={locked || verifyOtp.isPending}
|
||||
error={hasError}
|
||||
autoFocus
|
||||
aria-label={t('otp_title')}
|
||||
/>
|
||||
|
||||
{locked ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', textAlign: 'center' }}>
|
||||
{t('otp_locked')}
|
||||
</Typography>
|
||||
) : hasError ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', textAlign: 'center' }}>
|
||||
{t('otp_invalid')}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<AppButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={code.length !== OTP_CODE_LENGTH || verifyOtp.isPending || locked}
|
||||
startIcon={verifyOtp.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{isNurse ? t('otp_verify_nurse') : t('otp_verify_customer')}
|
||||
</AppButton>
|
||||
|
||||
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||
{resendDisabled && countdown.isActive && !locked ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('resend_in', { time: formatMmSs(countdown.seconds) })}
|
||||
</Typography>
|
||||
) : (
|
||||
<MuiLink
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={resendDisabled}
|
||||
underline="hover"
|
||||
sx={{ color: 'primary.main' }}
|
||||
>
|
||||
{t('resend')}
|
||||
</MuiLink>
|
||||
)}
|
||||
<MuiLink
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onChangeNumber}
|
||||
underline="hover"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
{t('change_number')}
|
||||
</MuiLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtpStep;
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
import { FormEvent, FunctionComponent, useState } from 'react';
|
||||
import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { AppButton } from '@/components';
|
||||
import { APP_ROLES, type AppRole } from '@/constants';
|
||||
import { useRequestOtp } from '@/services/auth';
|
||||
import type { ApiError } from '@/lib/api/errors';
|
||||
import type { RequestOtpResult } from '@/services/auth/types';
|
||||
|
||||
interface PhoneStepProps {
|
||||
intendedRole: AppRole;
|
||||
onSwitchRole: () => void;
|
||||
onSent: (phone: string, result: RequestOtpResult) => void;
|
||||
}
|
||||
|
||||
const RATE_LIMIT_STATUS = 429;
|
||||
|
||||
/**
|
||||
* A1 (customer) / B1 (nurse) — the phone entry step. Same mechanism for both actors; only the
|
||||
* copy and the role-switch link differ. Requests an OTP and advances to the code step; domain
|
||||
* 4xx (invalid number, rate-limit) render inline.
|
||||
* @component PhoneStep
|
||||
*/
|
||||
const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRole, onSent }) => {
|
||||
const t = useTranslations('auth');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
const requestOtp = useRequestOtp();
|
||||
|
||||
const isNurse = intendedRole === APP_ROLES.NURSE;
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setRateLimited(false);
|
||||
if (!isIranianMobile(phone)) {
|
||||
setInvalid(true);
|
||||
return;
|
||||
}
|
||||
setInvalid(false);
|
||||
requestOtp.mutate(
|
||||
{ phone },
|
||||
{
|
||||
onSuccess: (result) => onSent(phone, result),
|
||||
onError: (error) => {
|
||||
if ((error as ApiError).status === RATE_LIMIT_STATUS) setRateLimited(true);
|
||||
else setInvalid(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack component="form" onSubmit={submit} sx={{ gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{isNurse ? t('nurse_title') : t('customer_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{isNurse ? t('nurse_subtitle') : t('customer_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<PhoneNumberField
|
||||
value={phone}
|
||||
onChange={(next) => {
|
||||
setPhone(next);
|
||||
if (invalid) setInvalid(false);
|
||||
}}
|
||||
label={t('phone_label')}
|
||||
placeholder="0912 000 0000"
|
||||
error={invalid}
|
||||
helperText={invalid ? t('phone_invalid') : rateLimited ? t('rate_limited') : ' '}
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<AppButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={requestOtp.isPending}
|
||||
startIcon={requestOtp.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('request_code')}
|
||||
</AppButton>
|
||||
|
||||
<MuiLink
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onSwitchRole}
|
||||
underline="hover"
|
||||
sx={{ color: 'text.secondary', textAlign: 'center' }}
|
||||
>
|
||||
{isNurse ? t('customer_switch') : t('nurse_switch')}
|
||||
</MuiLink>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhoneStep;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { Me } from '@/services/auth/types';
|
||||
|
||||
const mockReplace = jest.fn();
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
...jest.requireActual('next/navigation'),
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}));
|
||||
jest.mock('next-intl', () => ({ useLocale: () => 'fa', useTranslations: () => (key: string) => key }));
|
||||
|
||||
let meResult: { data?: Partial<Me>; isError: boolean };
|
||||
jest.mock('@/services/auth', () => ({ useMe: () => meResult }));
|
||||
|
||||
import RoleRouter from './RoleRouter';
|
||||
|
||||
function renderRouter(intendedRole?: 'customer' | 'nurse') {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<RoleRouter intendedRole={intendedRole} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<RoleRouter/>', () => {
|
||||
beforeEach(() => mockReplace.mockReset());
|
||||
|
||||
it('does not navigate while /me is loading', () => {
|
||||
meResult = { data: undefined, isError: false };
|
||||
renderRouter();
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes a customer to the family app', async () => {
|
||||
meResult = { data: { roles: ['customer'] }, isError: false };
|
||||
renderRouter();
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/'));
|
||||
});
|
||||
|
||||
it('routes a nurse to the nurse app', async () => {
|
||||
meResult = { data: { roles: ['nurse'] }, isError: false };
|
||||
renderRouter();
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/nurse'));
|
||||
});
|
||||
|
||||
it('routes a role-less user to select-role, carrying nurse intent', async () => {
|
||||
meResult = { data: { roles: [] }, isError: false };
|
||||
renderRouter('nurse');
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/select-role?role=nurse'));
|
||||
});
|
||||
|
||||
it('falls back to login when /me errors', async () => {
|
||||
meResult = { data: undefined, isError: true };
|
||||
renderRouter();
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/login'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { resolveRoleDestination } from '@/services/auth/routing';
|
||||
import AuthSplash from './AuthSplash';
|
||||
|
||||
interface RoleRouterProps {
|
||||
/** The role the user logged in as (A1 vs B1); disambiguates a multi-role user and pre-selects on SelectRole. */
|
||||
intendedRole?: AppRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs after authentication (never re-implements the auth gate — the middleware owns that) and
|
||||
* sends the user to the right app: customer → family, nurse → nurse app, no role → SelectRole,
|
||||
* admin → admin console. Shows the branded splash while `/me` is in flight so the wrong shell
|
||||
* never flashes.
|
||||
* @component RoleRouter
|
||||
*/
|
||||
const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole }) => {
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('auth');
|
||||
const { data: me, isError } = useMe();
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
router.replace(`/${locale}${ROUTES.LOGIN}`);
|
||||
return;
|
||||
}
|
||||
if (!me) return;
|
||||
|
||||
let destination = resolveRoleDestination(me, intendedRole);
|
||||
// Carry the login intent into role selection so it can pre-select the right role.
|
||||
if (destination === ROUTES.SELECT_ROLE && intendedRole === APP_ROLES.NURSE) {
|
||||
destination = `${destination}?role=${APP_ROLES.NURSE}`;
|
||||
}
|
||||
router.replace(`/${locale}${destination}`);
|
||||
}, [me, isError, intendedRole, router, locale]);
|
||||
|
||||
return <AuthSplash message={t('routing_title')} />;
|
||||
};
|
||||
|
||||
export default RoleRouter;
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { AppButton } from '@/components';
|
||||
import { APP_ROLES, type AppRole } from '@/constants';
|
||||
import { useSelectRole } from '@/services/auth';
|
||||
import { resolveRoleDestination } from '@/services/auth/routing';
|
||||
import type { PublicRole } from '@/services/auth/types';
|
||||
import { AUTH_CARD_MAX_WIDTH } from './constants';
|
||||
import BrandMark from './BrandMark';
|
||||
|
||||
interface SelectRoleProps {
|
||||
/** Login intent carried from A1/B1 — pre-selects the matching option. Admin is never offered. */
|
||||
initialRole?: AppRole;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: ReadonlyArray<{ role: PublicRole; icon: string }> = [
|
||||
{ role: 'customer', icon: 'account' },
|
||||
{ role: 'nurse', icon: 'home' },
|
||||
];
|
||||
|
||||
/**
|
||||
* First-use role picker for a brand-new user whose `/me` has no public role. Selecting one calls
|
||||
* `me/select_role` (which rotates the token so the new claim lands), then routes into that app.
|
||||
* Admin roles are internal-only and never selectable here.
|
||||
* @component SelectRole
|
||||
*/
|
||||
const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
|
||||
const t = useTranslations('auth');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const selectRole = useSelectRole();
|
||||
|
||||
const preset: PublicRole = initialRole === APP_ROLES.NURSE ? 'nurse' : 'customer';
|
||||
const [selected, setSelected] = useState<PublicRole>(preset);
|
||||
|
||||
const confirm = () => {
|
||||
selectRole.mutate(selected, {
|
||||
onSuccess: (me) => router.replace(`/${locale}${resolveRoleDestination(me, selected)}`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
|
||||
<Stack sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, gap: 3 }}>
|
||||
<BrandMark />
|
||||
<Stack sx={{ gap: 0.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('select_role_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('select_role_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{ROLE_OPTIONS.map(({ role, icon }) => {
|
||||
const isSelected = selected === role;
|
||||
return (
|
||||
<Paper
|
||||
key={role}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => setSelected(role)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') setSelected(role);
|
||||
}}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? 'primary.main' : 'divider',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{t(`role_${role}`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`role_${role}_desc`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={confirm}
|
||||
disabled={selectRole.isPending}
|
||||
startIcon={selectRole.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('continue')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectRole;
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Reading width for the branded auth screens (login, OTP, role selection). */
|
||||
export const AUTH_CARD_MAX_WIDTH = 420;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as LoginFlow } from './LoginFlow';
|
||||
export { default as RoleRouter } from './RoleRouter';
|
||||
export { default as SelectRole } from './SelectRole';
|
||||
export { default as AuthSplash } from './AuthSplash';
|
||||
@@ -0,0 +1,48 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useCountdown } from './useCountdown';
|
||||
|
||||
describe('useCountdown', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('starts at zero and inactive', () => {
|
||||
const { result } = renderHook(() => useCountdown());
|
||||
expect(result.current.seconds).toBe(0);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('counts down one second at a time and stops at zero', () => {
|
||||
const { result } = renderHook(() => useCountdown());
|
||||
act(() => result.current.start(3));
|
||||
expect(result.current.seconds).toBe(3);
|
||||
expect(result.current.isActive).toBe(true);
|
||||
|
||||
act(() => jest.advanceTimersByTime(1000));
|
||||
expect(result.current.seconds).toBe(2);
|
||||
|
||||
act(() => jest.advanceTimersByTime(2000));
|
||||
expect(result.current.seconds).toBe(0);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
|
||||
// No leaked interval keeps decrementing past zero.
|
||||
act(() => jest.advanceTimersByTime(3000));
|
||||
expect(result.current.seconds).toBe(0);
|
||||
});
|
||||
|
||||
it('restarting resets the remaining seconds', () => {
|
||||
const { result } = renderHook(() => useCountdown());
|
||||
act(() => result.current.start(2));
|
||||
act(() => jest.advanceTimersByTime(1000));
|
||||
expect(result.current.seconds).toBe(1);
|
||||
act(() => result.current.start(5));
|
||||
expect(result.current.seconds).toBe(5);
|
||||
});
|
||||
|
||||
it('stop() halts the countdown', () => {
|
||||
const { result } = renderHook(() => useCountdown());
|
||||
act(() => result.current.start(5));
|
||||
act(() => result.current.stop());
|
||||
act(() => jest.advanceTimersByTime(3000));
|
||||
expect(result.current.seconds).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* A one-shot seconds countdown for the OTP resend timer. Exactly one interval runs at a time —
|
||||
* `start(n)` restarts it, it stops itself at zero, and it is cleared on unmount, so no interval
|
||||
* leaks (which would cause re-render storms). State is kept local to the OTP component.
|
||||
*/
|
||||
export function useCountdown() {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
(from: number) => {
|
||||
stop();
|
||||
const initial = Math.max(0, Math.floor(from));
|
||||
setSeconds(initial);
|
||||
if (initial === 0) return;
|
||||
intervalRef.current = setInterval(() => {
|
||||
setSeconds((prev) => {
|
||||
if (prev <= 1) {
|
||||
stop();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
},
|
||||
[stop],
|
||||
);
|
||||
|
||||
useEffect(() => stop, [stop]);
|
||||
|
||||
return { seconds, isActive: seconds > 0, start, stop };
|
||||
}
|
||||
Reference in New Issue
Block a user