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