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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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 `<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`).
|
||||
Reference in New Issue
Block a user