17a82832ab
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>
7.4 KiB
7.4 KiB
Frontend Phase 1 (b2) — Auth: phone-OTP login & role routing — Report (2026-07-02)
What was built
services/auth — rewritten for phone-OTP (the username/password stub is removed, not left dangling)
types.ts— contract-mirrored (camelCase) shapes:OtpRequest,RequestOtpResult,OtpVerify,RefreshRequest,LogoutRequest,AuthTokens(=AuthTokensResult),SelectRoleDto,Me(=MeResult),RoleCode/PublicRole/AdminRole,Gender,NurseVerificationStatus, and theAuthApiseam interface.keys.ts—authKeys.me()(+authKeys.all).constants.ts—USE_AUTH_MOCK(default false),AUTH_API_BASE = '/api/v1',AUTH_ME_STALE_TIME,OTP_CODE_LENGTH = 6,OTP_RESEND_FALLBACK_SECONDS,OTP_LOCKED_CODE.routing.ts— pureresolveRoleDestination(me, intendedRole)(the role router's core),toAppRoles(fine-grained role codes → the 3 actor roles; all admin sub-roles →admin),isAdminRole.apis/clientApi.ts(real, overclientFetch+unwrap, exact snake_case routes),apis/mockApi.ts(in-memory, dev code123456, 3-try lockout,MOCK_SCENARIOtoggle),apis/index.ts(seam selector).- hooks (one per file):
useRequestOtp,useVerifyOtp,useMe,useRefresh,useLogout,useSelectRole,useSessionRoleSync; barrelindex.tsre-exports hooks only.
Screens (branded, RTL-first, both locales, via the f0 OTP/phone composites + the App* library)
- A1/B1
PhoneStep— one phone step for both actors, copy + role-switch link driven byintendedRole; inline invalid-number and rate-limit states. - A2/B2
OtpStep—OtpInput(6 boxes), masked-phone echo, single resend countdown (useCountdown, cleaned up on unmount), auto-verify on last digit, and explicit wrong-code / expired / max-attempts lockout states; CTA copy differs by actor. LoginFloworchestrates phone→otp→routing (one stack,?role=nurseseeds nurse intent);AuthCard,BrandMark,AuthSplashare the shared branded shells.- Role router
RoleRouter— consumesuseMe, showsAuthSplashwhile/meis in flight (never flashes the wrong shell), thenrouter.replaces to the resolved app; carries nurse intent into/select-role. - SelectRole — first-use picker (خانواده / پرستار only; admin never selectable), pre-selects the login
intent; on select it calls
me/select_role, rotates the token (role claim lives in the access token), and routes into the chosen app. - Pages:
(public-routes)/login/page.tsx,(private-routes)/select-role/page.tsx(both wrap the search-param reader in<Suspense>).ROUTES.SELECT_ROLE = '/select-role'(authenticated, not public).
Session / auth plumbing
- Widened
AuthState:currentUseris nowSessionUser { id?, phone, roles: AppRole[] }. The server still seedsisAuthenticatedonly (the access token is an opaque JWE — roles aren't derivable edge/server-side);useSessionRoleSync(mounted in the private-routes layout) hydrates roles from/meso the f0 shells pick chrome from the real role — one source of truth, no second role store. - Silent refresh in the fetch layer (
lib/api/refresh.ts+ a 401 branch inclientFetch): on a 401 it attempts one single-flight refresh and retries the original request once; on refresh failure it clears tokens and redirects to login (matches the server's rotation + reuse-detection → sign-in-again). Token cookie writes are centralised inlib/auth/session.ts(persistAuthTokens/clearAuthTokens), shared by the hooks and the fetch layer.useRefreshis the explicit/on-demand path (used afterselect_role). invalidateQueries(authKeys.me())on login;removeQueries(authKeys.all)on logout.
What is now testable (and exactly how)
cd client && npm run dev (b2 backend running, or USE_AUTH_MOCK = true for offline with dev code 123456):
- Customer happy path:
/fa/login→ valid mobile → «دریافت کد تایید» → A2 shows masked phone + counting resend → enter code (auto-verifies) → tokens in cookies (DevTools → Application → Cookies; nothing in localStorage) → redirected to the customer home;/mein the Query cache. - Nurse switch: A1 → «پرستار هستید؟ ورود پرستاران ←» → B1 nurse copy → verify («تایید و ورود») → routed
to the nurse app (B3 status is f5; routed to
/nursefor now). - No-role user: verify a user whose
/me.rolesis empty →/select-role→ pick a role → routed in. - OTP edges: wrong code → inline error + boxes clear; resend re-enabled when the countdown hits 0; 3 wrong tries (mock) → input locks with the lockout message.
- Session: logout → both cookies cleared,
/medropped, back to/login; an expired access token is silently refreshed on the next call (or a clean redirect to/loginif the session is gone). - i18n/RTL:
/entranslates and stays LTR-correct;/fais RTL. - Gate:
npm run checkgreen ·npm run test:cigreen (95 tests) ·npm run buildgreen (withNEXT_PUBLIC_API_URLset — see follow-ups).
Tests added
services/auth/routing.test.ts— all role-router branches +toAppRoles/isAdminRole.components/auth/useCountdown.test.ts— countdown lifecycle (fake timers; no leaked interval).components/auth/OtpStep.test.tsx— box count, auto-verify, success→onVerified, wrong-code clears boxes.components/auth/RoleRouter.test.tsx— loading (no nav), customer/nurse/no-role/error navigation targets.- Fixed the jest
@/alias (<rootDir>/$1→<rootDir>/src/$1) sojest.mock('@/…')resolves.
What is mocked / waiting on a real service
services/authclient-side mock (authMockApi) behindUSE_AUTH_MOCK(default false; the real client is wired to the live b2 routes). Recorded inmocks-registry.md. OTP/SMS delivery itself is the backend'sISmsSenderseam — the frontend never sends SMS.
Contracts
- Produced: none (frontend consumes).
- Consumed:
dev/contracts/domains/identity-auth.md+openapi/swagger.v1.json(b2). Reconciliations vs the phase's sketch: wire is camelCase (not snake_case body);intended_roleis not sent to the server (client-only routing state);Mehas noactiveRole/profileCompleted(router uses intended role; profile state =hasCustomerProfile/hasNurseProfile); OTP length isn't in the contract (defaulted to 6). - Requests filed: REQ-002 (OTP
codeLength/expiresInSeconds), REQ-003 (verify error codes + lockoutretryAfterSeconds), REQ-004 (multi-roleactiveRole?; verify returns noid).
Follow-ups for later phases
- B3 verification landing (unverified nurse) is f5 — the router sends nurses to
/nurse; the persistent "verification in progress" banner is f5's job. - A3/A4 onboarding & profiles are f2-b3 (their own
onboardingnamespace); SelectRole only picks the role. - Admin screens are f15 — the router routes admins correctly but builds no admin UI here.
- Refresh-cookie TTL: the client cookie is 7d (f0 design) while the contract's session default is 30d;
aligning the cookie
maxAgetorefreshExpiresAtis a small follow-up (kept the f0 constants for now). npm run buildneedsNEXT_PUBLIC_API_URL(pre-existing f0 note —@/configthrows at prerender without it;next builddoesn't read.env.development).