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:
hamid
2026-07-02 11:58:52 +03:30
parent 3a51305343
commit 17a82832ab
50 changed files with 1815 additions and 108 deletions
@@ -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 };
+33
View File
@@ -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;
+29
View File
@@ -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;
+34
View File
@@ -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;
+62
View File
@@ -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);
});
});
+168
View File
@@ -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;
+106
View File
@@ -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'));
});
});
+47
View File
@@ -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;
+115
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
/** Reading width for the branded auth screens (login, OTP, role selection). */
export const AUTH_CARD_MAX_WIDTH = 420;
+4
View File
@@ -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 };
}