# 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.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` — **pure** `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** `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. - `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.replace`s 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 ``). `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 (`/$1` → `/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`).