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
+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;