Files
baya-monorepo/dev/shared-working-context/reports/frontend-phase-1-report.md
T
hamid 17a82832ab 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>
2026-07-02 11:58:52 +03:30

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 the AuthApi seam interface.
  • keys.tsauthKeys.me() (+ authKeys.all).
  • constants.tsUSE_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.tspure resolveRoleDestination(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, over clientFetch + unwrap, exact snake_case routes), apis/mockApi.ts (in-memory, dev code 123456, 3-try lockout, MOCK_SCENARIO toggle), apis/index.ts (seam selector).
  • hooks (one per file): useRequestOtp, useVerifyOtp, useMe, useRefresh, useLogout, useSelectRole, useSessionRoleSync; barrel index.ts re-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 by intendedRole; inline invalid-number and rate-limit states.
  • A2/B2 OtpStepOtpInput (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.
  • LoginFlow orchestrates phone→otp→routing (one stack, ?role=nurse seeds nurse intent); AuthCard, BrandMark, AuthSplash are the shared branded shells.
  • Role router RoleRouter — consumes useMe, shows AuthSplash while /me is in flight (never flashes the wrong shell), then router.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: currentUser is now SessionUser { id?, phone, roles: AppRole[] }. The server still seeds isAuthenticated only (the access token is an opaque JWE — roles aren't derivable edge/server-side); useSessionRoleSync (mounted in the private-routes layout) hydrates roles from /me so 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 in clientFetch): 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 in lib/auth/session.ts (persistAuthTokens/clearAuthTokens), shared by the hooks and the fetch layer. useRefresh is the explicit/on-demand path (used after select_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; /me in the Query cache.
  • Nurse switch: A1 → «پرستار هستید؟ ورود پرستاران ←» → B1 nurse copy → verify («تایید و ورود») → routed to the nurse app (B3 status is f5; routed to /nurse for now).
  • No-role user: verify a user whose /me.roles is 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, /me dropped, back to /login; an expired access token is silently refreshed on the next call (or a clean redirect to /login if the session is gone).
  • i18n/RTL: /en translates and stays LTR-correct; /fa is RTL.
  • Gate: npm run check green · npm run test:ci green (95 tests) · npm run build green (with NEXT_PUBLIC_API_URL set — 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) so jest.mock('@/…') resolves.

What is mocked / waiting on a real service

  • services/auth client-side mock (authMockApi) behind USE_AUTH_MOCK (default false; the real client is wired to the live b2 routes). Recorded in mocks-registry.md. OTP/SMS delivery itself is the backend's ISmsSender seam — 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_role is not sent to the server (client-only routing state); Me has no activeRole/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 + lockout retryAfterSeconds), REQ-004 (multi-role activeRole?; verify returns no id).

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 onboarding namespace); 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 maxAge to refreshExpiresAt is a small follow-up (kept the f0 constants for now).
  • npm run build needs NEXT_PUBLIC_API_URL (pre-existing f0 note — @/config throws at prerender without it; next build doesn't read .env.development).