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
@@ -12,6 +12,25 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
## frontend-phase-1-b2 — Auth: phone-OTP login & role routing — 2026-07-02
- **Shipped:** `services/auth` rewritten for phone-OTP (types/keys/apis[client+mock+seam]/hooks:
`useRequestOtp`/`useVerifyOtp`/`useMe`/`useRefresh`/`useLogout`/`useSelectRole`/`useSessionRoleSync`) —
the username/password stub is gone; A1/A2 customer login + B1/B2 nurse switch (one OTP flow parameterised
by intended role) at `/login`; the **role router** (`RoleRouter` + pure `resolveRoleDestination`) →
customer→family, nurse→nurse app, no-role→`/select-role`, admin→admin console (splash while `/me` loads,
no wrong-shell flash); `SelectRole` screen at `/select-role`; **silent token refresh** in the fetch layer
(single-flight `attemptTokenRefresh`, one retry on 401); widened `AuthState` (roles via `SessionUser`,
hydrated from `/me` by `useSessionRoleSync` in the private layout); `auth` i18n namespace + `common.brand*`
in both locales.
- **Consumes:** dev/contracts/domains/identity-auth.md + openapi/swagger.v1.json (backend-phase-2). Wire is
**camelCase**; routes `api/v1/auth/{request_otp,verify_otp,refresh,logout}`, `api/v1/me`, `api/v1/me/select_role`.
- **Mocked client-side:** `services/auth` via `authMockApi` behind `USE_AUTH_MOCK` (**default false** — b2 is
live; flip true for offline dev, dev code `123456`). See mocks-registry.
- **Gate:** npm run check green · npm run test:ci green (95 tests, +23) · npm run build green with
NEXT_PUBLIC_API_URL set. Fixed the jest `@/``src` alias (was `<rootDir>/$1`).
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-002 OTP length/expiry, REQ-003 verify
error codes + lockout retry-after, REQ-004 multi-role `activeRole?`).
## frontend-phase-0 — Foundations: app shells, design system & data/contract patterns — 2026-07-02
- **Shipped:** 3 actor shells (customer bottom-nav / nurse / admin sidebar) + role-aware routing under
`(private-routes)`; `useActorRole`; the `services/{domain}` reference (`patients`, mocked behind a
@@ -30,3 +30,34 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Proposed shape:** `{ isSuccess: boolean, statusCode: number, message?: string, requestId?: string, data?: T }`
and `data: { items: T[], total: number, page: number, pageSize: number }` for lists.
- **Status:** open
## REQ-002 — OTP length + expiry in RequestOtpResult — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** Add `codeLength` (int) and `expiresInSeconds` (int) to `RequestOtpResult`.
- **Why:** The A2/B2 OTP screen renders one box per digit and (later) a code-expiry hint. `RequestOtpResult`
currently exposes only `otpSent` + `resendAvailableInSeconds`, so the frontend hardcodes the box count
(`OTP_CODE_LENGTH = 6`, inferred from the live 6-digit verify example, not the 4-box wireframe). Surfacing
the length makes the box count contract-driven; the expiry lets us show "code expires in …".
- **Proposed shape:** `{ otpSent: boolean, resendAvailableInSeconds: number, codeLength: number, expiresInSeconds: number }`
- **Status:** open
## REQ-003 — Machine-readable error codes for verify_otp failures — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** A stable `code` on the 400 envelope for verify_otp that distinguishes wrong code vs expired code
vs max-attempts lockout (e.g. `otp_invalid` | `otp_expired` | `otp_locked`), and — for lockout — a
`retryAfterSeconds` (or `lockedUntil`) field.
- **Why:** The OTP screen has explicit **wrong-code**, **expired-code**, and **max-attempts-lockout** states
(per the phase spec), but the contract returns the same safe 400 message for all of them, so the frontend
can't reliably tell them apart. Today it maps a lockout only when it sees the mock's `otp_locked` code and
otherwise degrades to a generic "incorrect or expired" message. A stable machine code (kept generic enough
to avoid account enumeration) would let the UI render the precise state + the unlock countdown.
- **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }`
- **Status:** open
## REQ-004 — Confirm multi-role disambiguation (activeRole?) — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** Confirm whether `MeResult` will gain an `activeRole` (the user's currently-selected actor) for a
user who holds **both** `customer` and `nurse`, or whether the client should keep owning that choice.
- **Why:** The role router must pick one app for a dual-role user. Absent an `activeRole` in the contract,
it currently uses the **intended role** carried from the login switch (A1 vs B1), defaulting to the family
app. If the backend intends to persist a "current role", the router should prefer it. Also note: verify_otp
returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
flagging in case that changes.
- **Status:** open