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:
@@ -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;
|
||||
Reference in New Issue
Block a user