From 17a82832ab355b576dc8b3ef66cb628c6fd49d60 Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 2 Jul 2026 11:58:52 +0330 Subject: [PATCH] =?UTF-8?q?frontend=20phase=201:=20auth=20=E2=80=94=20phon?= =?UTF-8?q?e-OTP=20login,=20role=20routing=20&=20session=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- client/CLAUDE.md | 75 +++++--- client/jest.config.ts | 5 +- client/messages/en.json | 33 +++- client/messages/fa.json | 33 +++- .../app/[locale]/(private-routes)/layout.tsx | 5 +- .../(private-routes)/select-role/page.tsx | 20 +++ .../[locale]/(public-routes)/login/page.tsx | 15 ++ .../PhoneNumberField/PhoneNumberField.tsx | 9 + .../src/components/PhoneNumberField/index.tsx | 2 +- client/src/components/auth/AuthCard.tsx | 33 ++++ client/src/components/auth/AuthSplash.tsx | 29 +++ client/src/components/auth/BrandMark.tsx | 34 ++++ client/src/components/auth/LoginFlow.tsx | 62 +++++++ client/src/components/auth/OtpStep.test.tsx | 79 ++++++++ client/src/components/auth/OtpStep.tsx | 168 ++++++++++++++++++ client/src/components/auth/PhoneStep.tsx | 106 +++++++++++ .../src/components/auth/RoleRouter.test.tsx | 58 ++++++ client/src/components/auth/RoleRouter.tsx | 47 +++++ client/src/components/auth/SelectRole.tsx | 115 ++++++++++++ client/src/components/auth/constants.ts | 2 + client/src/components/auth/index.ts | 4 + .../src/components/auth/useCountdown.test.ts | 48 +++++ client/src/components/auth/useCountdown.ts | 42 +++++ client/src/constants/routes.ts | 2 + client/src/context/auth/index.ts | 2 +- client/src/context/auth/types.ts | 18 +- client/src/lib/api/client.ts | 14 +- client/src/lib/api/refresh.ts | 54 ++++++ client/src/lib/auth/session.ts | 25 +++ client/src/services/auth/apis/clientApi.ts | 67 +++++-- client/src/services/auth/apis/index.ts | 10 ++ client/src/services/auth/apis/mockApi.ts | 104 +++++++++++ client/src/services/auth/constants.ts | 29 +++ .../src/services/auth/hooks/useCurrentUser.ts | 11 -- client/src/services/auth/hooks/useLogin.ts | 28 --- client/src/services/auth/hooks/useLogout.ts | 19 +- client/src/services/auth/hooks/useMe.ts | 21 +++ client/src/services/auth/hooks/useRefresh.ts | 18 ++ .../src/services/auth/hooks/useRequestOtp.ts | 15 ++ .../src/services/auth/hooks/useSelectRole.ts | 39 ++++ .../services/auth/hooks/useSessionRoleSync.ts | 22 +++ .../src/services/auth/hooks/useVerifyOtp.ts | 34 ++++ client/src/services/auth/index.ts | 8 +- client/src/services/auth/keys.ts | 2 +- client/src/services/auth/routing.test.ts | 58 ++++++ client/src/services/auth/routing.ts | 47 +++++ client/src/services/auth/types.ts | 107 +++++++++-- dev/shared-working-context/frontend/STATUS.md | 19 ++ .../frontend/requests/for-backend.md | 31 ++++ .../reports/frontend-phase-1-report.md | 95 ++++++++++ 50 files changed, 1815 insertions(+), 108 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/select-role/page.tsx create mode 100644 client/src/app/[locale]/(public-routes)/login/page.tsx create mode 100644 client/src/components/auth/AuthCard.tsx create mode 100644 client/src/components/auth/AuthSplash.tsx create mode 100644 client/src/components/auth/BrandMark.tsx create mode 100644 client/src/components/auth/LoginFlow.tsx create mode 100644 client/src/components/auth/OtpStep.test.tsx create mode 100644 client/src/components/auth/OtpStep.tsx create mode 100644 client/src/components/auth/PhoneStep.tsx create mode 100644 client/src/components/auth/RoleRouter.test.tsx create mode 100644 client/src/components/auth/RoleRouter.tsx create mode 100644 client/src/components/auth/SelectRole.tsx create mode 100644 client/src/components/auth/constants.ts create mode 100644 client/src/components/auth/index.ts create mode 100644 client/src/components/auth/useCountdown.test.ts create mode 100644 client/src/components/auth/useCountdown.ts create mode 100644 client/src/lib/api/refresh.ts create mode 100644 client/src/lib/auth/session.ts create mode 100644 client/src/services/auth/apis/index.ts create mode 100644 client/src/services/auth/apis/mockApi.ts create mode 100644 client/src/services/auth/constants.ts delete mode 100644 client/src/services/auth/hooks/useCurrentUser.ts delete mode 100644 client/src/services/auth/hooks/useLogin.ts create mode 100644 client/src/services/auth/hooks/useMe.ts create mode 100644 client/src/services/auth/hooks/useRefresh.ts create mode 100644 client/src/services/auth/hooks/useRequestOtp.ts create mode 100644 client/src/services/auth/hooks/useSelectRole.ts create mode 100644 client/src/services/auth/hooks/useSessionRoleSync.ts create mode 100644 client/src/services/auth/hooks/useVerifyOtp.ts create mode 100644 client/src/services/auth/routing.test.ts create mode 100644 client/src/services/auth/routing.ts create mode 100644 dev/shared-working-context/reports/frontend-phase-1-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 2679bd7..64b7eff 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -114,7 +114,8 @@ client/ │ └── [locale]/ │ ├── layout.tsx # ROOT RSC: renders + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) │ ├── (private-routes)/ - │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout (auth shell) + │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me) + │ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment │ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout │ │ │ ├── page.tsx # / (home) @@ -133,13 +134,15 @@ client/ │ │ ├── users/page.tsx # /admin/users │ │ └── notifications/page.tsx # /admin/notifications │ └── (public-routes)/ - │ └── layout.tsx # 'use client' — wraps PublicLayout + │ ├── layout.tsx # 'use client' — wraps PublicLayout + │ └── login/page.tsx # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch) ├── components/ # Shared UI components (each with .test.tsx if imported >1 place) │ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens │ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe) - │ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL) + │ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile) │ ├── StepperHeader/ # Progress header for onboarding/verification flows - │ └── StatusChip/ # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens + │ ├── StatusChip/ # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens + │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown ├── i18n/ │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' │ └── request.ts # getRequestConfig — loads messages/${locale}.json @@ -161,12 +164,14 @@ client/ │ └── index.tsx ├── lib/ │ ├── api/ - │ │ ├── client.ts # clientFetch — throws ApiError on error; use in hooks/client components + │ │ ├── client.ts # clientFetch — throws ApiError on error; use in hooks/client components; silent-refreshes + retries once on 401 │ │ ├── server.ts # serverFetch — throws ApiError on error; use in RSCs/Server Actions │ │ ├── types.ts # ApiEnvelope + unwrap(), Paginated, PageParams — shared wire types + │ │ ├── refresh.ts # attemptTokenRefresh — single-flight silent refresh used by clientFetch's 401 branch │ │ └── errors.ts # ApiError class (status, message, code) │ ├── auth/ │ │ ├── token.ts # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers) + │ │ ├── session.ts # persistAuthTokens / clearAuthTokens — client token-cookie writers (shared by auth hooks + fetch refresh) │ │ └── server.ts # getServerAuthState — access-token cookie → AuthState for AuthProvider │ ├── query/ │ │ ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton @@ -177,7 +182,7 @@ client/ │ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie │ └── index.ts # Re-exports constants ONLY (never server/client) ├── services/ # Domain services — no top-level barrel; import directly from the file - │ ├── auth/ # Reference domain (login/logout/currentUser) + │ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync │ ├── patients/ # Reference domain for the mock-behind-a-seam pattern (§ services pattern) │ └── {domain}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) @@ -261,9 +266,10 @@ async function MyServerComponent() { - `'common'` — `DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …) - `'shell'` — actor-shell titles + the not-yet-built placeholder body - `'patients'` — the reference services/{domain} demo screen +- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale -files): `auth`, `onboarding`, `verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`, +files): `onboarding`, `verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`, `notifications`, `admin`. Keep top-level keys as namespaces and both files in sync. **Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files. @@ -513,42 +519,57 @@ Every domain follows the same shape: `types.ts` (wire types + the domain's `Api` | Cookie | Constant | TTL | Set by | |--------|----------|-----|--------| -| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `useLogin()` in `src/services/auth/hooks/useLogin.ts` | -| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | `useLogin()` in `src/services/auth/hooks/useLogin.ts` | +| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`src/lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh | +| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same as above | -**Session state lives in `AuthContext`** (`src/context/auth/`). The root layout resolves the session on -the server with `getServerAuthState()` (`src/lib/auth/server.ts`) — which reads the `access_token` cookie -and checks the JWT `exp` via the shared `isTokenAlive` (`src/lib/auth/token.ts`) — and passes it to -``. So the **first render already knows whether the user is -authenticated**: no logged-out flash, no post-mount cookie read. +**The credential is phone-OTP** — there is no username/password anywhere; email is never a login key. +The login flow lives in `src/components/auth/` (`LoginFlow` → `PhoneStep`/`OtpStep`) at `/login`, over the +`services/auth` domain (`requestOtp`/`verifyOtp`/`refresh`/`logout`/`getMe`/`selectRole`). + +**Role router:** after a successful verify, `RoleRouter` (`src/components/auth/`) reads `/me` and navigates — +customer→family app, nurse→nurse app, empty roles→`/select-role`, admin→admin console — showing the branded +splash while `/me` loads so the wrong shell never flashes. The routing decision is the **pure** +`resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware +still owns the auth gate; the router only decides *which app*. + +**Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone, +roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()` +(`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared +`isTokenAlive` (`src/lib/auth/token.ts`) — and passes it to ``, so the first +render already knows whether the user is authenticated. **Roles are not derivable from the opaque JWE token +server-side**, so the server seeds `isAuthenticated` only; `useSessionRoleSync()` (mounted in the +private-routes layout) hydrates `currentUser.roles` from `/me` — the single source the shells read via +`useActorRole()`. `invalidateQueries(authKeys.me())` runs on login; `removeQueries(authKeys.all)` on logout. **Lifecycle:** -- Written after a successful login via `setClientCookie` (`useLogin`), which also dispatches `LOG_IN` to - keep `AuthContext` in sync on the client without a reload. -- Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) — the single logout path: it calls - the API, clears both cookies, dispatches `LOG_OUT`, and redirects — and automatically by `clientFetch` - on 401. +- Written by `persistAuthTokens` after verify/refresh/select-role, which also dispatch `LOG_IN` to keep + `AuthContext` in sync without a reload. +- Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) — the single logout path: revoke the + server session, clear both cookies, `LOG_OUT`, drop the `/me` cache, redirect — and by `clientFetch` when a + 401 can't be recovered by a refresh. - Read on the server by `serverFetch` / `getServerAuthState` via `getServerCookie`. - Read on the client by `clientFetch` via `getClientCookie` (to attach `Authorization: Bearer`). -**`useIsAuthenticated()`** (`src/hooks/auth.ts`) reads the server-seeded `AuthContext`, so it is correct -on the first paint (it no longer reads the cookie after mount). +**Silent refresh:** `clientFetch` attempts one single-flight `attemptTokenRefresh` (`src/lib/api/refresh.ts`) +on a 401 and retries the request once; a failed refresh (unknown/expired/reused token → the server revokes +the session) clears tokens and redirects to `/login`. The refresh/OTP endpoints are excluded from this retry. **Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render. **Security posture — current limits and best-practice follow-ups.** The flow above is the intended -client design, but the auth model has known gaps that need *server* coordination to close. Don't -silently "fix" them client-only: +client design, but some hardening needs *server* coordination — don't silently "fix" it client-only: - **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header — this trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a same-origin proxy) spans the server. - **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but does **not** verify the signature. The API is the only authority; never gate real authorization on the middleware or `isTokenAlive`. -- **No refresh-token rotation** is implemented — the `refresh_token` cookie is set/cleared but never - exchanged; access expiry just forces re-login on the next 401. -- **No role/permission model** yet (`User` is `{ id, username }`); authorization is binary. Add roles to - `User`/`AuthState` and the server before gating UI by permission. +- **Role gating is coarse:** the shells pick chrome from `currentUser.roles`, but cross-actor route access + isn't hard-guarded client-side yet (the server authorizes each call). Add route guards when a phase needs + them. +- **Refresh-token rotation is wired** client-side (fetch-layer silent refresh + `useRefresh`), matching the + server's rotation + reuse-detection. The `refresh_token` cookie TTL (7d) is shorter than the server session + default (30d) — a follow-up can align the cookie `maxAge` to `refreshExpiresAt`. --- diff --git a/client/jest.config.ts b/client/jest.config.ts index 06f47a1..bb0de34 100644 --- a/client/jest.config.ts +++ b/client/jest.config.ts @@ -13,8 +13,9 @@ const customJestConfig: Config = { coverageProvider: 'v8', setupFilesAfterEnv: ['/jest.setup.ts'], moduleNameMapper: { - // Handle module aliases - '^@/(.*)$': '/$1', + // Handle the `@/*` alias — it maps to `src/*` (see tsconfig.json), so jest.mock('@/…') + // and imports resolve to the same files the app uses. + '^@/(.*)$': '/src/$1', }, // testEnvironment: 'jest-environment-jsdom', testEnvironment: 'jsdom', diff --git a/client/messages/en.json b/client/messages/en.json index 7a8e131..ae91233 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -25,7 +25,9 @@ "retry": "Retry", "add": "Add", "cancel": "Cancel", - "currency_toman": "Toman" + "currency_toman": "Toman", + "brand": "Balinyaar", + "brand_tagline": "Home care you can trust" }, "shell": { "customer_app": "Family app", @@ -43,5 +45,34 @@ "gender_male": "Male", "gender_female": "Female", "added": "Patient added" + }, + "auth": { + "customer_title": "Sign in to Balinyaar", + "customer_subtitle": "Sign in with your mobile number", + "nurse_title": "Nurse sign in", + "nurse_subtitle": "For nurses with a Nursing Council licence", + "phone_label": "Mobile number", + "phone_invalid": "Enter a valid Iranian mobile number", + "request_code": "Get verification code", + "nurse_switch": "Are you a nurse? Nurse sign in →", + "customer_switch": "Are you a family? Family sign in →", + "rate_limited": "Too many attempts — please try again shortly", + "otp_title": "Enter the verification code", + "otp_sent_to": "Code sent to {phone}", + "otp_verify_customer": "Verify and continue", + "otp_verify_nurse": "Verify and sign in", + "otp_invalid": "The code is incorrect or has expired", + "otp_locked": "Too many attempts. Request a new code to continue.", + "resend_in": "Resend code in {time}", + "resend": "Resend code", + "change_number": "Change number", + "routing_title": "Signing you in…", + "select_role_title": "Welcome to Balinyaar", + "select_role_subtitle": "Choose how you'll use Balinyaar to get started", + "role_customer": "Family", + "role_customer_desc": "Book nurses and home care", + "role_nurse": "Nurse", + "role_nurse_desc": "Offer nursing services", + "continue": "Continue" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 56a01be..cf9b540 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -25,7 +25,9 @@ "retry": "تلاش مجدد", "add": "افزودن", "cancel": "انصراف", - "currency_toman": "تومان" + "currency_toman": "تومان", + "brand": "بلینیار", + "brand_tagline": "مراقبت مطمئن در خانه" }, "shell": { "customer_app": "اپلیکیشن خانواده", @@ -43,5 +45,34 @@ "gender_male": "مرد", "gender_female": "زن", "added": "بیمار اضافه شد" + }, + "auth": { + "customer_title": "ورود به بلینیار", + "customer_subtitle": "با شماره موبایل خود وارد شوید", + "nurse_title": "ورود پرستاران", + "nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری", + "phone_label": "شماره موبایل", + "phone_invalid": "شماره موبایل معتبر وارد کنید", + "request_code": "دریافت کد تایید", + "nurse_switch": "پرستار هستید؟ ورود پرستاران ←", + "customer_switch": "خانواده هستید؟ ورود خانواده‌ها ←", + "rate_limited": "به‌دلیل تلاش زیاد، کمی بعد دوباره تلاش کنید", + "otp_title": "کد تایید را وارد کنید", + "otp_sent_to": "کد به شماره {phone} ارسال شد", + "otp_verify_customer": "تایید و ادامه", + "otp_verify_nurse": "تایید و ورود", + "otp_invalid": "کد وارد شده نادرست یا منقضی شده است", + "otp_locked": "به‌دلیل تلاش‌های زیاد، ورود موقتاً قفل شد. کد جدید دریافت کنید.", + "resend_in": "ارسال مجدد کد تا {time}", + "resend": "ارسال مجدد کد", + "change_number": "تغییر شماره", + "routing_title": "در حال ورود…", + "select_role_title": "به بلینیار خوش آمدید", + "select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید", + "role_customer": "خانواده", + "role_customer_desc": "برای رزرو پرستار و مراقبت در منزل", + "role_nurse": "پرستار", + "role_nurse_desc": "برای ارائه خدمات پرستاری", + "continue": "ادامه" } } diff --git a/client/src/app/[locale]/(private-routes)/layout.tsx b/client/src/app/[locale]/(private-routes)/layout.tsx index 8108e2b..94d2baf 100644 --- a/client/src/app/[locale]/(private-routes)/layout.tsx +++ b/client/src/app/[locale]/(private-routes)/layout.tsx @@ -1,8 +1,11 @@ 'use client'; import type { ReactNode } from 'react'; import { PrivateLayout } from '@/layout'; - +import { useSessionRoleSync } from '@/services/auth'; export default function PrivateRouteLayout({ children }: { children: ReactNode }) { + // Hydrate AuthContext roles from /me for a returning (server-seeded) session so the shells + // pick the right chrome without a second role store. + useSessionRoleSync(); return {children}; } diff --git a/client/src/app/[locale]/(private-routes)/select-role/page.tsx b/client/src/app/[locale]/(private-routes)/select-role/page.tsx new file mode 100644 index 0000000..5dc5673 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/select-role/page.tsx @@ -0,0 +1,20 @@ +'use client'; +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { APP_ROLES } from '@/constants'; +import { SelectRole } from '@/components/auth'; + +function SelectRoleContent() { + const searchParams = useSearchParams(); + const initialRole = searchParams.get('role') === APP_ROLES.NURSE ? APP_ROLES.NURSE : APP_ROLES.CUSTOMER; + return ; +} + +/** First-use role picker for an authenticated user with no public role yet. */ +export default function SelectRolePage() { + return ( + + + + ); +} diff --git a/client/src/app/[locale]/(public-routes)/login/page.tsx b/client/src/app/[locale]/(public-routes)/login/page.tsx new file mode 100644 index 0000000..84df22f --- /dev/null +++ b/client/src/app/[locale]/(public-routes)/login/page.tsx @@ -0,0 +1,15 @@ +'use client'; +import { Suspense } from 'react'; +import { LoginFlow } from '@/components/auth'; + +/** + * Phone-OTP login (A1/A2 customer, B1/B2 nurse). The Suspense boundary is required because + * LoginFlow reads `useSearchParams` (the `?role=nurse` switch). + */ +export default function LoginPage() { + return ( + + + + ); +} diff --git a/client/src/components/PhoneNumberField/PhoneNumberField.tsx b/client/src/components/PhoneNumberField/PhoneNumberField.tsx index 275055b..2cdb59c 100644 --- a/client/src/components/PhoneNumberField/PhoneNumberField.tsx +++ b/client/src/components/PhoneNumberField/PhoneNumberField.tsx @@ -11,6 +11,15 @@ export function isIranianMobile(value: string): boolean { return /^09\d{9}$/.test(value); } +/** + * Masks a mobile number for a "code sent to …" echo, keeping the first 4 and last 4 digits + * (e.g. `09120001234` → `0912•••1234`). Short/partial input is returned unchanged. + */ +export function maskIranMobile(value: string): string { + if (value.length < 8) return value; + return `${value.slice(0, 4)}•••${value.slice(-4)}`; +} + export interface PhoneNumberFieldProps extends Omit { /** Controlled value — normalized ASCII digits, no formatting. */ value: string; diff --git a/client/src/components/PhoneNumberField/index.tsx b/client/src/components/PhoneNumberField/index.tsx index 5e6bab9..2ef4620 100644 --- a/client/src/components/PhoneNumberField/index.tsx +++ b/client/src/components/PhoneNumberField/index.tsx @@ -1,5 +1,5 @@ import PhoneNumberField from './PhoneNumberField'; export type { PhoneNumberFieldProps } from './PhoneNumberField'; -export { IRAN_MOBILE_LENGTH, isIranianMobile } from './PhoneNumberField'; +export { IRAN_MOBILE_LENGTH, isIranianMobile, maskIranMobile } from './PhoneNumberField'; export { PhoneNumberField as default, PhoneNumberField }; diff --git a/client/src/components/auth/AuthCard.tsx b/client/src/components/auth/AuthCard.tsx new file mode 100644 index 0000000..6d50e50 --- /dev/null +++ b/client/src/components/auth/AuthCard.tsx @@ -0,0 +1,33 @@ +'use client'; +import { FunctionComponent, PropsWithChildren } from 'react'; +import { Paper, Stack } from '@mui/material'; +import { AUTH_CARD_MAX_WIDTH } from './constants'; +import BrandMark from './BrandMark'; + +/** + * Centered branded card that hosts each auth step (phone, OTP, role selection). Presentational + * shell only — the step content is passed as children. + * @component AuthCard + */ +const AuthCard: FunctionComponent = ({ children }) => ( + + + + + {children} + + + +); + +export default AuthCard; diff --git a/client/src/components/auth/AuthSplash.tsx b/client/src/components/auth/AuthSplash.tsx new file mode 100644 index 0000000..967160b --- /dev/null +++ b/client/src/components/auth/AuthSplash.tsx @@ -0,0 +1,29 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { Stack, Typography } from '@mui/material'; +import { AppLoading } from '@/components'; +import BrandMark from './BrandMark'; + +interface AuthSplashProps { + /** Already-translated status line (e.g. "Signing you in…"). */ + message?: string; +} + +/** + * Full-height branded loading splash shown while the role router resolves `/me` — prevents the + * wrong actor shell from flashing before the destination is known. + * @component AuthSplash + */ +const AuthSplash: FunctionComponent = ({ message }) => ( + + + + {message && ( + + {message} + + )} + +); + +export default AuthSplash; diff --git a/client/src/components/auth/BrandMark.tsx b/client/src/components/auth/BrandMark.tsx new file mode 100644 index 0000000..f96a48e --- /dev/null +++ b/client/src/components/auth/BrandMark.tsx @@ -0,0 +1,34 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { Stack, Typography } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import AppIcon from '../common/AppIcon'; + +interface BrandMarkProps { + /** Show the brand tagline under the wordmark. */ + withTagline?: boolean; +} + +/** + * The Balinyaar brand lockup for the auth screens: logo mark + wordmark (+ optional tagline). + * Colours come from the palette so it tracks the color scheme. + * @component BrandMark + */ +const BrandMark: FunctionComponent = ({ withTagline }) => { + const t = useTranslations('common'); + return ( + + + + {t('brand')} + + {withTagline && ( + + {t('brand_tagline')} + + )} + + ); +}; + +export default BrandMark; diff --git a/client/src/components/auth/LoginFlow.tsx b/client/src/components/auth/LoginFlow.tsx new file mode 100644 index 0000000..8d22e16 --- /dev/null +++ b/client/src/components/auth/LoginFlow.tsx @@ -0,0 +1,62 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; + +import { APP_ROLES, type AppRole } from '@/constants'; +import { OTP_RESEND_FALLBACK_SECONDS } from '@/services/auth/constants'; +import AuthCard from './AuthCard'; +import PhoneStep from './PhoneStep'; +import OtpStep from './OtpStep'; +import RoleRouter from './RoleRouter'; + +type Step = 'phone' | 'otp' | 'routing'; + +/** + * The single login stack for both actors (A1/A2 customer, B1/B2 nurse). One OTP mechanism, + * parameterised by `intendedRole` (seeded from `?role=nurse`) — no forked login trees. After a + * successful verify it hands off to the role router. + * @component LoginFlow + */ +const LoginFlow: FunctionComponent = () => { + const searchParams = useSearchParams(); + const [intendedRole, setIntendedRole] = useState( + searchParams.get('role') === APP_ROLES.NURSE ? APP_ROLES.NURSE : APP_ROLES.CUSTOMER, + ); + const [step, setStep] = useState('phone'); + const [phone, setPhone] = useState(''); + const [resendSeconds, setResendSeconds] = useState(OTP_RESEND_FALLBACK_SECONDS); + + if (step === 'routing') { + return ; + } + + return ( + + {step === 'phone' ? ( + + setIntendedRole((role) => + role === APP_ROLES.NURSE ? APP_ROLES.CUSTOMER : APP_ROLES.NURSE, + ) + } + onSent={(nextPhone, result) => { + setPhone(nextPhone); + setResendSeconds(result.resendAvailableInSeconds); + setStep('otp'); + }} + /> + ) : ( + setStep('routing')} + onChangeNumber={() => setStep('phone')} + /> + )} + + ); +}; + +export default LoginFlow; diff --git a/client/src/components/auth/OtpStep.test.tsx b/client/src/components/auth/OtpStep.test.tsx new file mode 100644 index 0000000..2969a3d --- /dev/null +++ b/client/src/components/auth/OtpStep.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; + +const mockVerifyMutate = jest.fn(); +const mockVerifyReset = jest.fn(); +const mockRequestMutate = jest.fn(); + +jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +jest.mock('@/services/auth', () => ({ + useVerifyOtp: () => ({ isPending: false, isError: false, mutate: mockVerifyMutate, reset: mockVerifyReset }), + useRequestOtp: () => ({ isPending: false, mutate: mockRequestMutate }), +})); + +import OtpStep from './OtpStep'; + +const PHONE = '09120001234'; + +function renderStep() { + const onVerified = jest.fn(); + const onChangeNumber = jest.fn(); + render( + + + , + ); + return { onVerified, onChangeNumber }; +} + +async function fillCode(code: string) { + const user = userEvent.setup(); + const boxes = screen.getAllByRole('textbox') as HTMLInputElement[]; + for (let i = 0; i < code.length; i += 1) { + await user.type(boxes[i], code[i]); + } +} + +describe(' state machine', () => { + beforeEach(() => { + mockVerifyMutate.mockReset(); + mockVerifyReset.mockReset(); + mockRequestMutate.mockReset(); + }); + + it('renders one box per code digit (6)', () => { + renderStep(); + expect(screen.getAllByRole('textbox')).toHaveLength(6); + }); + + it('auto-verifies once the last box is filled', async () => { + renderStep(); + await fillCode('123456'); + expect(mockVerifyMutate).toHaveBeenCalledWith( + { phone: PHONE, code: '123456' }, + expect.any(Object), + ); + }); + + it('calls onVerified on a successful verify', async () => { + mockVerifyMutate.mockImplementation((_vars, opts) => opts.onSuccess?.()); + const { onVerified } = renderStep(); + await fillCode('123456'); + expect(onVerified).toHaveBeenCalledTimes(1); + }); + + it('clears the boxes on a wrong code so the user can retry', async () => { + mockVerifyMutate.mockImplementation((_vars, opts) => opts.onError?.(new Error('bad code'))); + renderStep(); + await fillCode('000000'); + const boxes = screen.getAllByRole('textbox') as HTMLInputElement[]; + expect(boxes.every((box) => box.value === '')).toBe(true); + }); +}); diff --git a/client/src/components/auth/OtpStep.tsx b/client/src/components/auth/OtpStep.tsx new file mode 100644 index 0000000..f2ccc42 --- /dev/null +++ b/client/src/components/auth/OtpStep.tsx @@ -0,0 +1,168 @@ +'use client'; +import { FormEvent, FunctionComponent, useEffect, useState } from 'react'; +import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material'; +import { useTranslations } from 'next-intl'; + +import OtpInput from '@/components/OtpInput'; +import { maskIranMobile } from '@/components/PhoneNumberField'; +import { AppButton } from '@/components'; +import { APP_ROLES, type AppRole } from '@/constants'; +import { useRequestOtp, useVerifyOtp } from '@/services/auth'; +import { OTP_CODE_LENGTH, OTP_LOCKED_CODE } from '@/services/auth/constants'; +import type { ApiError } from '@/lib/api/errors'; +import { useCountdown } from './useCountdown'; + +interface OtpStepProps { + phone: string; + intendedRole: AppRole; + /** Server's resend cooldown from the request step; drives the initial countdown. */ + resendSeconds: number; + onVerified: () => void; + onChangeNumber: () => void; +} + +function formatMmSs(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +/** + * A2 (customer) / B2 (nurse) — the OTP code step. Auto-verifies once the last box is filled, + * runs a single resend countdown (cleaned up on unmount), and renders explicit wrong-code, + * expired-code and max-attempts-lockout states. Same mechanism for both actors; the CTA copy + * differs. + * @component OtpStep + */ +const OtpStep: FunctionComponent = ({ + phone, + intendedRole, + resendSeconds, + onVerified, + onChangeNumber, +}) => { + const t = useTranslations('auth'); + const [code, setCode] = useState(''); + const [locked, setLocked] = useState(false); + const countdown = useCountdown(); + const verifyOtp = useVerifyOtp(); + const resendOtp = useRequestOtp(); + + const { start } = countdown; + useEffect(() => start(resendSeconds), [start, resendSeconds]); + + const isNurse = intendedRole === APP_ROLES.NURSE; + const hasError = verifyOtp.isError; + + const verify = (value: string) => { + if (value.length !== OTP_CODE_LENGTH || verifyOtp.isPending || locked) return; + verifyOtp.mutate( + { phone, code: value }, + { + onSuccess: onVerified, + onError: (error) => { + if ((error as ApiError).code === OTP_LOCKED_CODE) setLocked(true); + setCode(''); // clear the boxes so the user can retry a fresh code + }, + }, + ); + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + verify(code); + }; + + const resend = () => { + resendOtp.mutate( + { phone }, + { + onSuccess: (result) => { + setLocked(false); + setCode(''); + verifyOtp.reset(); + start(result.resendAvailableInSeconds); + }, + }, + ); + }; + + // While locked, requesting a new code is the way out — allow resend regardless of the timer. + const resendDisabled = resendOtp.isPending || (countdown.isActive && !locked); + + return ( + + + + {t('otp_title')} + + + {t('otp_sent_to', { phone: maskIranMobile(phone) })} + + + + + + {locked ? ( + + {t('otp_locked')} + + ) : hasError ? ( + + {t('otp_invalid')} + + ) : null} + + : undefined} + sx={{ m: 0 }} + > + {isNurse ? t('otp_verify_nurse') : t('otp_verify_customer')} + + + + {resendDisabled && countdown.isActive && !locked ? ( + + {t('resend_in', { time: formatMmSs(countdown.seconds) })} + + ) : ( + + {t('resend')} + + )} + + {t('change_number')} + + + + ); +}; + +export default OtpStep; diff --git a/client/src/components/auth/PhoneStep.tsx b/client/src/components/auth/PhoneStep.tsx new file mode 100644 index 0000000..73768f3 --- /dev/null +++ b/client/src/components/auth/PhoneStep.tsx @@ -0,0 +1,106 @@ +'use client'; +import { FormEvent, FunctionComponent, useState } from 'react'; +import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material'; +import { useTranslations } from 'next-intl'; + +import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField'; +import { AppButton } from '@/components'; +import { APP_ROLES, type AppRole } from '@/constants'; +import { useRequestOtp } from '@/services/auth'; +import type { ApiError } from '@/lib/api/errors'; +import type { RequestOtpResult } from '@/services/auth/types'; + +interface PhoneStepProps { + intendedRole: AppRole; + onSwitchRole: () => void; + onSent: (phone: string, result: RequestOtpResult) => void; +} + +const RATE_LIMIT_STATUS = 429; + +/** + * A1 (customer) / B1 (nurse) — the phone entry step. Same mechanism for both actors; only the + * copy and the role-switch link differ. Requests an OTP and advances to the code step; domain + * 4xx (invalid number, rate-limit) render inline. + * @component PhoneStep + */ +const PhoneStep: FunctionComponent = ({ intendedRole, onSwitchRole, onSent }) => { + const t = useTranslations('auth'); + const [phone, setPhone] = useState(''); + const [invalid, setInvalid] = useState(false); + const [rateLimited, setRateLimited] = useState(false); + const requestOtp = useRequestOtp(); + + const isNurse = intendedRole === APP_ROLES.NURSE; + + const submit = (event: FormEvent) => { + event.preventDefault(); + setRateLimited(false); + if (!isIranianMobile(phone)) { + setInvalid(true); + return; + } + setInvalid(false); + requestOtp.mutate( + { phone }, + { + onSuccess: (result) => onSent(phone, result), + onError: (error) => { + if ((error as ApiError).status === RATE_LIMIT_STATUS) setRateLimited(true); + else setInvalid(true); + }, + }, + ); + }; + + return ( + + + + {isNurse ? t('nurse_title') : t('customer_title')} + + + {isNurse ? t('nurse_subtitle') : t('customer_subtitle')} + + + + { + setPhone(next); + if (invalid) setInvalid(false); + }} + label={t('phone_label')} + placeholder="0912 000 0000" + error={invalid} + helperText={invalid ? t('phone_invalid') : rateLimited ? t('rate_limited') : ' '} + fullWidth + autoFocus + /> + + : undefined} + sx={{ m: 0 }} + > + {t('request_code')} + + + + {isNurse ? t('customer_switch') : t('nurse_switch')} + + + ); +}; + +export default PhoneStep; diff --git a/client/src/components/auth/RoleRouter.test.tsx b/client/src/components/auth/RoleRouter.test.tsx new file mode 100644 index 0000000..f6b34e5 --- /dev/null +++ b/client/src/components/auth/RoleRouter.test.tsx @@ -0,0 +1,58 @@ +import { render, waitFor } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import type { Me } from '@/services/auth/types'; + +const mockReplace = jest.fn(); + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: () => ({ replace: mockReplace }), +})); +jest.mock('next-intl', () => ({ useLocale: () => 'fa', useTranslations: () => (key: string) => key })); + +let meResult: { data?: Partial; isError: boolean }; +jest.mock('@/services/auth', () => ({ useMe: () => meResult })); + +import RoleRouter from './RoleRouter'; + +function renderRouter(intendedRole?: 'customer' | 'nurse') { + render( + + + , + ); +} + +describe('', () => { + beforeEach(() => mockReplace.mockReset()); + + it('does not navigate while /me is loading', () => { + meResult = { data: undefined, isError: false }; + renderRouter(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('routes a customer to the family app', async () => { + meResult = { data: { roles: ['customer'] }, isError: false }; + renderRouter(); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/')); + }); + + it('routes a nurse to the nurse app', async () => { + meResult = { data: { roles: ['nurse'] }, isError: false }; + renderRouter(); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/nurse')); + }); + + it('routes a role-less user to select-role, carrying nurse intent', async () => { + meResult = { data: { roles: [] }, isError: false }; + renderRouter('nurse'); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/select-role?role=nurse')); + }); + + it('falls back to login when /me errors', async () => { + meResult = { data: undefined, isError: true }; + renderRouter(); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/login')); + }); +}); diff --git a/client/src/components/auth/RoleRouter.tsx b/client/src/components/auth/RoleRouter.tsx new file mode 100644 index 0000000..207c1de --- /dev/null +++ b/client/src/components/auth/RoleRouter.tsx @@ -0,0 +1,47 @@ +'use client'; +import { FunctionComponent, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; + +import { APP_ROLES, ROUTES, type AppRole } from '@/constants'; +import { useMe } from '@/services/auth'; +import { resolveRoleDestination } from '@/services/auth/routing'; +import AuthSplash from './AuthSplash'; + +interface RoleRouterProps { + /** The role the user logged in as (A1 vs B1); disambiguates a multi-role user and pre-selects on SelectRole. */ + intendedRole?: AppRole; +} + +/** + * Runs after authentication (never re-implements the auth gate — the middleware owns that) and + * sends the user to the right app: customer → family, nurse → nurse app, no role → SelectRole, + * admin → admin console. Shows the branded splash while `/me` is in flight so the wrong shell + * never flashes. + * @component RoleRouter + */ +const RoleRouter: FunctionComponent = ({ intendedRole }) => { + const router = useRouter(); + const locale = useLocale(); + const t = useTranslations('auth'); + const { data: me, isError } = useMe(); + + useEffect(() => { + if (isError) { + router.replace(`/${locale}${ROUTES.LOGIN}`); + return; + } + if (!me) return; + + let destination = resolveRoleDestination(me, intendedRole); + // Carry the login intent into role selection so it can pre-select the right role. + if (destination === ROUTES.SELECT_ROLE && intendedRole === APP_ROLES.NURSE) { + destination = `${destination}?role=${APP_ROLES.NURSE}`; + } + router.replace(`/${locale}${destination}`); + }, [me, isError, intendedRole, router, locale]); + + return ; +}; + +export default RoleRouter; diff --git a/client/src/components/auth/SelectRole.tsx b/client/src/components/auth/SelectRole.tsx new file mode 100644 index 0000000..2e99568 --- /dev/null +++ b/client/src/components/auth/SelectRole.tsx @@ -0,0 +1,115 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +import { CircularProgress, Paper, Stack, Typography } from '@mui/material'; +import { useRouter } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; + +import AppIcon from '@/components/common/AppIcon'; +import { AppButton } from '@/components'; +import { APP_ROLES, type AppRole } from '@/constants'; +import { useSelectRole } from '@/services/auth'; +import { resolveRoleDestination } from '@/services/auth/routing'; +import type { PublicRole } from '@/services/auth/types'; +import { AUTH_CARD_MAX_WIDTH } from './constants'; +import BrandMark from './BrandMark'; + +interface SelectRoleProps { + /** Login intent carried from A1/B1 — pre-selects the matching option. Admin is never offered. */ + initialRole?: AppRole; +} + +const ROLE_OPTIONS: ReadonlyArray<{ role: PublicRole; icon: string }> = [ + { role: 'customer', icon: 'account' }, + { role: 'nurse', icon: 'home' }, +]; + +/** + * First-use role picker for a brand-new user whose `/me` has no public role. Selecting one calls + * `me/select_role` (which rotates the token so the new claim lands), then routes into that app. + * Admin roles are internal-only and never selectable here. + * @component SelectRole + */ +const SelectRole: FunctionComponent = ({ initialRole }) => { + const t = useTranslations('auth'); + const router = useRouter(); + const locale = useLocale(); + const selectRole = useSelectRole(); + + const preset: PublicRole = initialRole === APP_ROLES.NURSE ? 'nurse' : 'customer'; + const [selected, setSelected] = useState(preset); + + const confirm = () => { + selectRole.mutate(selected, { + onSuccess: (me) => router.replace(`/${locale}${resolveRoleDestination(me, selected)}`), + }); + }; + + return ( + + + + + + {t('select_role_title')} + + + {t('select_role_subtitle')} + + + + + {ROLE_OPTIONS.map(({ role, icon }) => { + const isSelected = selected === role; + return ( + setSelected(role)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') setSelected(role); + }} + elevation={0} + sx={{ + p: 2, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 2, + border: '2px solid', + borderColor: isSelected ? 'primary.main' : 'divider', + borderRadius: 2, + }} + > + + + + {t(`role_${role}`)} + + + {t(`role_${role}_desc`)} + + + + ); + })} + + + : undefined} + sx={{ m: 0 }} + > + {t('continue')} + + + + ); +}; + +export default SelectRole; diff --git a/client/src/components/auth/constants.ts b/client/src/components/auth/constants.ts new file mode 100644 index 0000000..baf84e7 --- /dev/null +++ b/client/src/components/auth/constants.ts @@ -0,0 +1,2 @@ +/** Reading width for the branded auth screens (login, OTP, role selection). */ +export const AUTH_CARD_MAX_WIDTH = 420; diff --git a/client/src/components/auth/index.ts b/client/src/components/auth/index.ts new file mode 100644 index 0000000..4b84a60 --- /dev/null +++ b/client/src/components/auth/index.ts @@ -0,0 +1,4 @@ +export { default as LoginFlow } from './LoginFlow'; +export { default as RoleRouter } from './RoleRouter'; +export { default as SelectRole } from './SelectRole'; +export { default as AuthSplash } from './AuthSplash'; diff --git a/client/src/components/auth/useCountdown.test.ts b/client/src/components/auth/useCountdown.test.ts new file mode 100644 index 0000000..ad2f7c0 --- /dev/null +++ b/client/src/components/auth/useCountdown.test.ts @@ -0,0 +1,48 @@ +import { act, renderHook } from '@testing-library/react'; +import { useCountdown } from './useCountdown'; + +describe('useCountdown', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('starts at zero and inactive', () => { + const { result } = renderHook(() => useCountdown()); + expect(result.current.seconds).toBe(0); + expect(result.current.isActive).toBe(false); + }); + + it('counts down one second at a time and stops at zero', () => { + const { result } = renderHook(() => useCountdown()); + act(() => result.current.start(3)); + expect(result.current.seconds).toBe(3); + expect(result.current.isActive).toBe(true); + + act(() => jest.advanceTimersByTime(1000)); + expect(result.current.seconds).toBe(2); + + act(() => jest.advanceTimersByTime(2000)); + expect(result.current.seconds).toBe(0); + expect(result.current.isActive).toBe(false); + + // No leaked interval keeps decrementing past zero. + act(() => jest.advanceTimersByTime(3000)); + expect(result.current.seconds).toBe(0); + }); + + it('restarting resets the remaining seconds', () => { + const { result } = renderHook(() => useCountdown()); + act(() => result.current.start(2)); + act(() => jest.advanceTimersByTime(1000)); + expect(result.current.seconds).toBe(1); + act(() => result.current.start(5)); + expect(result.current.seconds).toBe(5); + }); + + it('stop() halts the countdown', () => { + const { result } = renderHook(() => useCountdown()); + act(() => result.current.start(5)); + act(() => result.current.stop()); + act(() => jest.advanceTimersByTime(3000)); + expect(result.current.seconds).toBe(5); + }); +}); diff --git a/client/src/components/auth/useCountdown.ts b/client/src/components/auth/useCountdown.ts new file mode 100644 index 0000000..0122421 --- /dev/null +++ b/client/src/components/auth/useCountdown.ts @@ -0,0 +1,42 @@ +'use client'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * A one-shot seconds countdown for the OTP resend timer. Exactly one interval runs at a time — + * `start(n)` restarts it, it stops itself at zero, and it is cleared on unmount, so no interval + * leaks (which would cause re-render storms). State is kept local to the OTP component. + */ +export function useCountdown() { + const [seconds, setSeconds] = useState(0); + const intervalRef = useRef | null>(null); + + const stop = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const start = useCallback( + (from: number) => { + stop(); + const initial = Math.max(0, Math.floor(from)); + setSeconds(initial); + if (initial === 0) return; + intervalRef.current = setInterval(() => { + setSeconds((prev) => { + if (prev <= 1) { + stop(); + return 0; + } + return prev - 1; + }); + }, 1000); + }, + [stop], + ); + + useEffect(() => stop, [stop]); + + return { seconds, isActive: seconds > 0, start, stop }; +} diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 722dc41..3527c05 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -1,5 +1,7 @@ export const ROUTES = { LOGIN: '/login', + // Post-login role picker for a brand-new user whose /me has no public role yet. + SELECT_ROLE: '/select-role', // Customer (family) app — mobile-first, bottom-tab nav HOME: '/', diff --git a/client/src/context/auth/index.ts b/client/src/context/auth/index.ts index 69b68fb..e6d1bb9 100644 --- a/client/src/context/auth/index.ts +++ b/client/src/context/auth/index.ts @@ -1,4 +1,4 @@ export { AuthProvider, useAuth } from './AuthContext'; export type { AuthContextValue } from './AuthContext'; export { INITIAL_AUTH_STATE } from './types'; -export type { AuthAction, AuthState } from './types'; +export type { AuthAction, AuthState, SessionUser } from './types'; diff --git a/client/src/context/auth/types.ts b/client/src/context/auth/types.ts index 96acfcf..68856d0 100644 --- a/client/src/context/auth/types.ts +++ b/client/src/context/auth/types.ts @@ -1,11 +1,23 @@ -import type { User } from '@/services/auth/types'; +import type { AppRole } from '@/constants'; + +/** + * The session identity the shells need for chrome. `roles` are the coarse actor roles + * (customer/nurse/admin) derived from the server's fine-grained role codes via + * `toAppRoles`; `id` is optional because verify_otp returns roles but not the id — `/me` + * (via useSessionRoleSync) fills it once loaded. + */ +export interface SessionUser { + id?: number; + phone: string; + roles: AppRole[]; +} export interface AuthState { isAuthenticated: boolean; - currentUser?: User; + currentUser?: SessionUser; } -export type AuthAction = { type: 'LOG_IN'; user?: User } | { type: 'LOG_OUT' }; +export type AuthAction = { type: 'LOG_IN'; user?: SessionUser } | { type: 'LOG_OUT' }; export const INITIAL_AUTH_STATE: AuthState = { isAuthenticated: false, diff --git a/client/src/lib/api/client.ts b/client/src/lib/api/client.ts index 0a4101f..5b31c6f 100644 --- a/client/src/lib/api/client.ts +++ b/client/src/lib/api/client.ts @@ -17,6 +17,11 @@ import { deleteClientCookie, getClientCookie } from '@/lib/cookies/client'; import { dispatchToast } from '@/lib/toast/dispatchToast'; import { ROUTES } from '@/constants'; import { ApiError } from './errors'; +import { attemptTokenRefresh } from './refresh'; + +// Routes that must never trigger the silent-refresh-on-401 retry: the refresh call itself +// (a 401 there is terminal reuse/expiry) and the unauthenticated OTP endpoints. +const NO_REFRESH_PATHS = ['/auth/refresh', '/auth/request_otp', '/auth/verify_otp']; async function parseBody(response: Response): Promise<{ message?: string; code?: string }> { try { @@ -26,7 +31,7 @@ async function parseBody(response: Response): Promise<{ message?: string; code?: } } -export async function clientFetch(path: string, options?: RequestInit): Promise { +export async function clientFetch(path: string, options?: RequestInit, isRetry = false): Promise { const token = getClientCookie(COOKIE_NAMES.ACCESS_TOKEN); const locale = window.location.pathname.split('/')[1] || 'fa'; @@ -59,6 +64,13 @@ export async function clientFetch(path: string, options?: RequestInit): Promi const { code } = body; if (response.status === 401) { + // Try one silent refresh + retry before giving up — an expired access token is rotated + // transparently so the user keeps their session. Skip for the refresh/OTP endpoints and + // never loop (single retry). + const refreshEligible = !isRetry && !NO_REFRESH_PATHS.some((p) => path.includes(p)); + if (refreshEligible && (await attemptTokenRefresh())) { + return clientFetch(path, options, true); + } deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN); deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN); dispatchToast('Session expired. Please log in again.', 'error'); diff --git a/client/src/lib/api/refresh.ts b/client/src/lib/api/refresh.ts new file mode 100644 index 0000000..156b547 --- /dev/null +++ b/client/src/lib/api/refresh.ts @@ -0,0 +1,54 @@ +/** + * Silent access-token refresh for the client fetch layer. On a 401, `clientFetch` calls this + * once to rotate the token pair (the server does rotation + reuse-detection) and, on success, + * retries the original request. A module-level single-flight promise coalesces concurrent 401s + * into one refresh so a burst of parallel requests rotates the session exactly once. + * + * This is deliberately a bare `fetch` (not `clientFetch`, not the `services/auth` client) to + * avoid an import cycle — it only shares the token-cookie writers in `@/lib/auth/session`. + */ +import { API_URL } from '@/config'; +import { COOKIE_NAMES } from '@/lib/cookies'; +import { getClientCookie } from '@/lib/cookies/client'; +import { clearAuthTokens, persistAuthTokens } from '@/lib/auth/session'; + +const REFRESH_PATH = '/api/v1/auth/refresh'; + +let inFlight: Promise | null = null; + +async function doRefresh(): Promise { + const refreshToken = getClientCookie(COOKIE_NAMES.REFRESH_TOKEN); + if (!refreshToken) return false; + + try { + const response = await fetch(`${API_URL}${REFRESH_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + if (!response.ok) { + // 401 here means an unknown/expired/reused (stolen) token — the session is gone. + clearAuthTokens(); + return false; + } + const envelope = await response.json(); + const tokens = envelope?.data; + if (!tokens?.accessToken || !tokens?.refreshToken) { + clearAuthTokens(); + return false; + } + persistAuthTokens(tokens); + return true; + } catch { + return false; + } +} + +export function attemptTokenRefresh(): Promise { + if (!inFlight) { + inFlight = doRefresh().finally(() => { + inFlight = null; + }); + } + return inFlight; +} diff --git a/client/src/lib/auth/session.ts b/client/src/lib/auth/session.ts new file mode 100644 index 0000000..ffd893b --- /dev/null +++ b/client/src/lib/auth/session.ts @@ -0,0 +1,25 @@ +/** + * Client-only session token persistence. The single place that writes/clears the auth + * cookies via the cookie manager — shared by the `services/auth` hooks and the fetch + * layer's silent-refresh path so token storage never diverges. + * + * Import from client components / hooks / the client fetch layer only (uses the client + * cookie manager, which needs `window`). Never from an RSC. + */ +import { AUTH_ACCESS_COOKIE_OPTIONS, AUTH_REFRESH_COOKIE_OPTIONS, COOKIE_NAMES } from '@/lib/cookies'; +import { deleteClientCookie, setClientCookie } from '@/lib/cookies/client'; + +interface TokenPair { + accessToken: string; + refreshToken: string; +} + +export function persistAuthTokens(tokens: TokenPair): void { + setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, tokens.accessToken, AUTH_ACCESS_COOKIE_OPTIONS); + setClientCookie(COOKIE_NAMES.REFRESH_TOKEN, tokens.refreshToken, AUTH_REFRESH_COOKIE_OPTIONS); +} + +export function clearAuthTokens(): void { + deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN); + deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN); +} diff --git a/client/src/services/auth/apis/clientApi.ts b/client/src/services/auth/apis/clientApi.ts index 2cc94c1..57d178e 100644 --- a/client/src/services/auth/apis/clientApi.ts +++ b/client/src/services/auth/apis/clientApi.ts @@ -1,16 +1,63 @@ import { clientFetch } from '@/lib/api/client'; -import type { AuthTokens, LoginDto, User } from '../types'; +import { unwrap, type ApiEnvelope } from '@/lib/api/types'; +import { AUTH_API_BASE } from '../constants'; +import type { + AuthApi, + AuthTokens, + LogoutRequest, + Me, + OtpRequest, + OtpVerify, + RefreshRequest, + RequestOtpResult, + SelectRoleDto, +} from '../types'; -export const AuthClientApi = { - login: (dto: LoginDto) => - clientFetch('/auth/login', { +/** + * Real HTTP implementation of the AuthApi seam. `clientFetch` returns the raw server + * envelope, so each call reads its payload via `unwrap`. Routes are the exact snake_case + * paths from the published swagger (`[controller]/[action]` transform). + */ +export const authClientApi: AuthApi = { + requestOtp: async (body: OtpRequest) => + unwrap( + await clientFetch>(`${AUTH_API_BASE}/auth/request_otp`, { + method: 'POST', + body: JSON.stringify(body), + }), + ), + + verifyOtp: async (body: OtpVerify) => + unwrap( + await clientFetch>(`${AUTH_API_BASE}/auth/verify_otp`, { + method: 'POST', + body: JSON.stringify(body), + }), + ), + + refresh: async (body: RefreshRequest) => + unwrap( + await clientFetch>(`${AUTH_API_BASE}/auth/refresh`, { + method: 'POST', + body: JSON.stringify(body), + }), + ), + + // Success is an empty envelope (no `data`) — don't unwrap, just await the revocation. + logout: async (body?: LogoutRequest) => { + await clientFetch>(`${AUTH_API_BASE}/auth/logout`, { method: 'POST', - body: JSON.stringify(dto), - }), + body: JSON.stringify(body ?? {}), + }); + }, - logout: () => - clientFetch('/auth/logout', { method: 'POST' }), + getMe: async () => unwrap(await clientFetch>(`${AUTH_API_BASE}/me`)), - getCurrentUser: () => - clientFetch('/auth/me'), + selectRole: async (body: SelectRoleDto) => + unwrap( + await clientFetch>(`${AUTH_API_BASE}/me/select_role`, { + method: 'POST', + body: JSON.stringify(body), + }), + ), }; diff --git a/client/src/services/auth/apis/index.ts b/client/src/services/auth/apis/index.ts new file mode 100644 index 0000000..fbe398c --- /dev/null +++ b/client/src/services/auth/apis/index.ts @@ -0,0 +1,10 @@ +import { USE_AUTH_MOCK } from '../constants'; +import type { AuthApi } from '../types'; +import { authClientApi } from './clientApi'; +import { authMockApi } from './mockApi'; + +/** + * The selected AuthApi implementation — the single seam every auth hook imports. Selection is + * by config (USE_AUTH_MOCK), never by scattered `if (mock)` checks. + */ +export const authApi: AuthApi = USE_AUTH_MOCK ? authMockApi : authClientApi; diff --git a/client/src/services/auth/apis/mockApi.ts b/client/src/services/auth/apis/mockApi.ts new file mode 100644 index 0000000..bb6c925 --- /dev/null +++ b/client/src/services/auth/apis/mockApi.ts @@ -0,0 +1,104 @@ +import { sleep } from '@/utils'; +import { ApiError } from '@/lib/api/errors'; +import { OTP_LOCKED_CODE } from '../constants'; +import type { AuthApi, AuthTokens, Me, RoleCode } from '../types'; + +const MOCK_LATENCY_MS = 400; +const DEV_OTP_CODE = '123456'; +const MAX_WRONG_ATTEMPTS = 3; + +/** + * Which `Me` the mock returns, so a developer can exercise all three role-router branches + * without a backend. Flip this to `nurse_unverified` or `no_role` while USE_AUTH_MOCK is true. + */ +type MockScenario = 'customer' | 'nurse_unverified' | 'no_role'; +const MOCK_SCENARIO: MockScenario = 'customer'; + +const SCENARIO_ROLES: Record = { + customer: ['customer'], + nurse_unverified: ['nurse'], + no_role: [], +}; + +// Mutable so `selectRole` can grant a role to the fresh (`no_role`) user mid-session. +let grantedRoles: RoleCode[] = [...SCENARIO_ROLES[MOCK_SCENARIO]]; +let wrongAttempts = 0; + +function fakeTokens(): AuthTokens { + return { + // A structurally-valid unsigned JWT with a far-future `exp` so `isTokenAlive` + // (middleware / server-seed) accepts it. Payload: { exp: 4102444800 }. + accessToken: + 'eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9.', + refreshToken: 'mock-refresh-token', + accessExpiresAt: '2100-01-01T00:00:00Z', + refreshExpiresAt: '2100-01-01T00:00:00Z', + isNewUser: grantedRoles.length === 0, + roles: grantedRoles, + }; +} + +function currentMe(): Me { + return { + id: 1, + phone: '0912*****00', + firstName: null, + lastName: null, + gender: null, + isActive: true, + roles: grantedRoles, + hasCustomerProfile: false, + hasNurseProfile: false, + nurseVerificationStatus: 'not_started', + }; +} + +/** + * In-memory mock behind the AuthApi seam — the offline fallback for the login flow. + * `verifyOtp` accepts the fixed dev code `123456` and locks after MAX_WRONG_ATTEMPTS wrong + * tries (reset by requesting a new code), mirroring the real domain 4xx states. + */ +export const authMockApi: AuthApi = { + requestOtp: async () => { + await sleep(MOCK_LATENCY_MS); + wrongAttempts = 0; + return { otpSent: true, resendAvailableInSeconds: 120 }; + }, + + verifyOtp: async ({ code }) => { + await sleep(MOCK_LATENCY_MS); + if (wrongAttempts >= MAX_WRONG_ATTEMPTS) { + throw new ApiError(400, 'Too many attempts. Request a new code.', OTP_LOCKED_CODE); + } + if (code !== DEV_OTP_CODE) { + wrongAttempts += 1; + if (wrongAttempts >= MAX_WRONG_ATTEMPTS) { + throw new ApiError(400, 'Too many attempts. Request a new code.', OTP_LOCKED_CODE); + } + throw new ApiError(400, 'The code is incorrect or has expired.'); + } + wrongAttempts = 0; + return fakeTokens(); + }, + + refresh: async () => { + await sleep(MOCK_LATENCY_MS); + return fakeTokens(); + }, + + logout: async () => { + await sleep(MOCK_LATENCY_MS); + grantedRoles = [...SCENARIO_ROLES[MOCK_SCENARIO]]; + }, + + getMe: async () => { + await sleep(MOCK_LATENCY_MS); + return currentMe(); + }, + + selectRole: async ({ role }) => { + await sleep(MOCK_LATENCY_MS); + if (!grantedRoles.includes(role)) grantedRoles = [...grantedRoles, role]; + return currentMe(); + }, +}; diff --git a/client/src/services/auth/constants.ts b/client/src/services/auth/constants.ts new file mode 100644 index 0000000..ef1356d --- /dev/null +++ b/client/src/services/auth/constants.ts @@ -0,0 +1,29 @@ +/** + * Auth domain configuration. The b2 endpoints are live (swagger snapshot published), so + * the real client is selected by default; flip USE_AUTH_MOCK to develop the login flow + * offline behind the AuthApi seam (see dev/shared-working-context/reports/mocks-registry.md). + */ +export const USE_AUTH_MOCK = false; + +/** Versioned API base — every auth route is `api/v1/...` per the published swagger. */ +export const AUTH_API_BASE = '/api/v1'; + +/** `/me` is stable within a session; revisiting a screen shouldn't refetch it. */ +export const AUTH_ME_STALE_TIME = 60_000; + +/** + * OTP length. The contract's RequestOtpResult exposes no `code_length`, and the live verify + * example uses a 6-digit code, so we default to 6 (a request to surface the length is filed in + * frontend/requests/for-backend.md). The wireframe's 4 boxes predate the real 6-digit code. + */ +export const OTP_CODE_LENGTH = 6; + +/** Fallback resend cooldown (seconds) when a request hasn't yet returned the server value. */ +export const OTP_RESEND_FALLBACK_SECONDS = 120; + +/** + * `ApiError.code` the mock returns after too many wrong codes so the OTP screen can show the + * lockout state. The live server sends a 400 with a safe message but no machine-readable code + * yet (also filed in for-backend.md) — the screen degrades to the generic invalid-code state. + */ +export const OTP_LOCKED_CODE = 'otp_locked'; diff --git a/client/src/services/auth/hooks/useCurrentUser.ts b/client/src/services/auth/hooks/useCurrentUser.ts deleted file mode 100644 index e28b0c8..0000000 --- a/client/src/services/auth/hooks/useCurrentUser.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { AuthClientApi } from '../apis/clientApi'; -import { authKeys } from '../keys'; - -export function useCurrentUser() { - return useQuery({ - queryKey: authKeys.currentUser(), - queryFn: () => AuthClientApi.getCurrentUser(), - }); -} diff --git a/client/src/services/auth/hooks/useLogin.ts b/client/src/services/auth/hooks/useLogin.ts deleted file mode 100644 index 1234e82..0000000 --- a/client/src/services/auth/hooks/useLogin.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -import { AUTH_ACCESS_COOKIE_OPTIONS, AUTH_REFRESH_COOKIE_OPTIONS, COOKIE_NAMES } from '@/lib/cookies'; -import { setClientCookie } from '@/lib/cookies/client'; -import { dispatchToast } from '@/lib/toast/dispatchToast'; -import type { ApiError } from '@/lib/api/errors'; -import { useAuth } from '@/context/auth'; - -import { AuthClientApi } from '../apis/clientApi'; -import type { LoginDto } from '../types'; - -export function useLogin() { - const [, dispatch] = useAuth(); - - return useMutation({ - mutationFn: (dto: LoginDto) => AuthClientApi.login(dto), - onSuccess: (data) => { - setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, data.accessToken, AUTH_ACCESS_COOKIE_OPTIONS); - setClientCookie(COOKIE_NAMES.REFRESH_TOKEN, data.refreshToken, AUTH_REFRESH_COOKIE_OPTIONS); - // Sync AuthContext after a client-side login so the UI reflects the new - // session immediately, without waiting for the next full server render. - dispatch({ type: 'LOG_IN' }); - }, - onError: (error: ApiError) => { - dispatchToast(error.message, 'error'); - }, - }); -} diff --git a/client/src/services/auth/hooks/useLogout.ts b/client/src/services/auth/hooks/useLogout.ts index 0cf68a0..ac2fd78 100644 --- a/client/src/services/auth/hooks/useLogout.ts +++ b/client/src/services/auth/hooks/useLogout.ts @@ -1,25 +1,30 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { useLocale } from 'next-intl'; -import { COOKIE_NAMES } from '@/lib/cookies'; -import { deleteClientCookie } from '@/lib/cookies/client'; +import { clearAuthTokens } from '@/lib/auth/session'; import { ROUTES } from '@/constants'; import { useAuth } from '@/context/auth'; -import { AuthClientApi } from '../apis/clientApi'; +import { authApi } from '../apis'; +import { authKeys } from '../keys'; +/** + * The single logout path: revoke the server session, then (regardless of the call's outcome) clear + * both token cookies, reset AuthContext, drop the cached `/me`, and return to login. + */ export function useLogout() { const [, dispatch] = useAuth(); const router = useRouter(); const locale = useLocale(); + const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => AuthClientApi.logout(), + mutationFn: () => authApi.logout({}), onSettled: () => { - deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN); - deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN); + clearAuthTokens(); dispatch({ type: 'LOG_OUT' }); + queryClient.removeQueries({ queryKey: authKeys.all }); router.replace(`/${locale}${ROUTES.LOGIN}`); }, }); diff --git a/client/src/services/auth/hooks/useMe.ts b/client/src/services/auth/hooks/useMe.ts new file mode 100644 index 0000000..a6ff6ae --- /dev/null +++ b/client/src/services/auth/hooks/useMe.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useIsAuthenticated } from '@/hooks'; + +import { authApi } from '../apis'; +import { AUTH_ME_STALE_TIME } from '../constants'; +import { authKeys } from '../keys'; + +/** + * The signed-in user's identity, roles, and onboarding state — the role router's data source and + * the shells' role source. Only runs once authenticated (no `/me` call for a logged-out visitor). + */ +export function useMe() { + const isAuthenticated = useIsAuthenticated(); + return useQuery({ + queryKey: authKeys.me(), + queryFn: () => authApi.getMe(), + enabled: isAuthenticated, + staleTime: AUTH_ME_STALE_TIME, + }); +} diff --git a/client/src/services/auth/hooks/useRefresh.ts b/client/src/services/auth/hooks/useRefresh.ts new file mode 100644 index 0000000..1b4f31a --- /dev/null +++ b/client/src/services/auth/hooks/useRefresh.ts @@ -0,0 +1,18 @@ +import { useMutation } from '@tanstack/react-query'; + +import { persistAuthTokens } from '@/lib/auth/session'; + +import { authApi } from '../apis'; + +/** + * Explicitly rotate the token pair (e.g. after selecting a role, whose claim lives inside the + * access token). The **automatic** silent refresh on an expired access token is owned by the + * fetch layer (`@/lib/api/refresh`); this hook is the on-demand path and shares the same + * `persistAuthTokens` writer so token storage never diverges. + */ +export function useRefresh() { + return useMutation({ + mutationFn: (refreshToken: string) => authApi.refresh({ refreshToken }), + onSuccess: (tokens) => persistAuthTokens(tokens), + }); +} diff --git a/client/src/services/auth/hooks/useRequestOtp.ts b/client/src/services/auth/hooks/useRequestOtp.ts new file mode 100644 index 0000000..627db4a --- /dev/null +++ b/client/src/services/auth/hooks/useRequestOtp.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; + +import { authApi } from '../apis'; +import type { OtpRequest } from '../types'; + +/** + * Request an OTP for a phone (A1/B1 → A2/B2). Domain 4xx (400 invalid phone, 429 rate-limit) + * surface via `mutation.error` for the caller to render inline — 401/403/5xx are already + * toasted by the fetch layer, so this hook does not toast. + */ +export function useRequestOtp() { + return useMutation({ + mutationFn: (body: OtpRequest) => authApi.requestOtp(body), + }); +} diff --git a/client/src/services/auth/hooks/useSelectRole.ts b/client/src/services/auth/hooks/useSelectRole.ts new file mode 100644 index 0000000..c37d139 --- /dev/null +++ b/client/src/services/auth/hooks/useSelectRole.ts @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { COOKIE_NAMES } from '@/lib/cookies'; +import { getClientCookie } from '@/lib/cookies/client'; +import { persistAuthTokens } from '@/lib/auth/session'; +import { useAuth } from '@/context/auth'; + +import { authApi } from '../apis'; +import { authKeys } from '../keys'; +import { toAppRoles } from '../routing'; +import type { PublicRole } from '../types'; + +/** + * Self-assign a public actor role for a brand-new user. Because role claims live inside the access + * token, we rotate the token pair after selecting so subsequent role-gated calls carry the new + * claim (contract note). AuthContext and the cached `/me` are then updated so the router re-runs + * against the new role. + */ +export function useSelectRole() { + const queryClient = useQueryClient(); + const [, dispatch] = useAuth(); + + return useMutation({ + mutationFn: (role: PublicRole) => authApi.selectRole({ role }), + onSuccess: async (me) => { + const refreshToken = getClientCookie(COOKIE_NAMES.REFRESH_TOKEN); + if (refreshToken) { + try { + persistAuthTokens(await authApi.refresh({ refreshToken })); + } catch { + // A failed rotation surfaces on the next `/me`/gated call via the fetch layer; the + // role is already persisted server-side, so don't block the flow on it. + } + } + dispatch({ type: 'LOG_IN', user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles) } }); + queryClient.setQueryData(authKeys.me(), me); + }, + }); +} diff --git a/client/src/services/auth/hooks/useSessionRoleSync.ts b/client/src/services/auth/hooks/useSessionRoleSync.ts new file mode 100644 index 0000000..c18bd84 --- /dev/null +++ b/client/src/services/auth/hooks/useSessionRoleSync.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +import { useAuth } from '@/context/auth'; + +import { useMe } from './useMe'; +import { toAppRoles } from '../routing'; + +/** + * Hydrate AuthContext with the real roles from `/me` for an already-authenticated session (a + * returning user whose server-seeded state only knows `isAuthenticated`). Runs once per `/me` + * result — the shells read `currentUser.roles` to pick chrome, so this keeps that single source + * of truth fresh without a second role store. Mount it once, high in the authenticated tree. + */ +export function useSessionRoleSync(): void { + const [, dispatch] = useAuth(); + const { data: me } = useMe(); + + useEffect(() => { + if (!me) return; + dispatch({ type: 'LOG_IN', user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles) } }); + }, [me, dispatch]); +} diff --git a/client/src/services/auth/hooks/useVerifyOtp.ts b/client/src/services/auth/hooks/useVerifyOtp.ts new file mode 100644 index 0000000..0061a88 --- /dev/null +++ b/client/src/services/auth/hooks/useVerifyOtp.ts @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { persistAuthTokens } from '@/lib/auth/session'; +import { useAuth } from '@/context/auth'; + +import { authApi } from '../apis'; +import { authKeys } from '../keys'; +import { toAppRoles } from '../routing'; +import type { OtpVerify } from '../types'; + +/** + * Verify the OTP, then establish the session: persist the token pair, sync AuthContext with the + * roles the server returned (so the shells pick chrome immediately), and invalidate `/me` — the + * role router's input. The router (rendered on success) then routes to the right app. + * + * Wrong/expired/lockout codes come back as domain 400s via `mutation.error`; the OTP screen maps + * them to inline states. The fetch layer owns 401/403/5xx toasts. + */ +export function useVerifyOtp() { + const [, dispatch] = useAuth(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: OtpVerify) => authApi.verifyOtp(body), + onSuccess: (tokens, variables) => { + persistAuthTokens(tokens); + dispatch({ + type: 'LOG_IN', + user: { phone: variables.phone, roles: toAppRoles(tokens.roles) }, + }); + queryClient.invalidateQueries({ queryKey: authKeys.me() }); + }, + }); +} diff --git a/client/src/services/auth/index.ts b/client/src/services/auth/index.ts index 0c39419..faba734 100644 --- a/client/src/services/auth/index.ts +++ b/client/src/services/auth/index.ts @@ -1,3 +1,7 @@ -export { useLogin } from './hooks/useLogin'; +export { useRequestOtp } from './hooks/useRequestOtp'; +export { useVerifyOtp } from './hooks/useVerifyOtp'; +export { useMe } from './hooks/useMe'; +export { useRefresh } from './hooks/useRefresh'; export { useLogout } from './hooks/useLogout'; -export { useCurrentUser } from './hooks/useCurrentUser'; +export { useSelectRole } from './hooks/useSelectRole'; +export { useSessionRoleSync } from './hooks/useSessionRoleSync'; diff --git a/client/src/services/auth/keys.ts b/client/src/services/auth/keys.ts index 19c7641..1a280dd 100644 --- a/client/src/services/auth/keys.ts +++ b/client/src/services/auth/keys.ts @@ -1,4 +1,4 @@ export const authKeys = { all: ['auth'] as const, - currentUser: () => [...authKeys.all, 'me'] as const, + me: () => [...authKeys.all, 'me'] as const, }; diff --git a/client/src/services/auth/routing.test.ts b/client/src/services/auth/routing.test.ts new file mode 100644 index 0000000..ac926e1 --- /dev/null +++ b/client/src/services/auth/routing.test.ts @@ -0,0 +1,58 @@ +import { ROUTES } from '@/constants'; +import { isAdminRole, resolveRoleDestination, toAppRoles } from './routing'; +import type { RoleCode } from './types'; + +describe('toAppRoles', () => { + it('collapses all admin sub-roles to the single admin actor', () => { + expect(toAppRoles(['support'])).toEqual(['admin']); + expect(toAppRoles(['super_admin', 'finance'])).toEqual(['admin']); + }); + + it('keeps customer and nurse and de-duplicates', () => { + expect(toAppRoles(['customer', 'nurse'])).toEqual(['customer', 'nurse']); + expect(toAppRoles(['nurse', 'nurse'])).toEqual(['nurse']); + }); + + it('drops unknown/empty codes', () => { + expect(toAppRoles([])).toEqual([]); + expect(toAppRoles(['unknown' as RoleCode])).toEqual([]); + }); +}); + +describe('isAdminRole', () => { + it('flags every internal admin sub-role', () => { + (['admin', 'support', 'finance', 'moderation', 'super_admin'] as RoleCode[]).forEach((role) => + expect(isAdminRole(role)).toBe(true), + ); + }); + it('does not flag public roles', () => { + expect(isAdminRole('customer')).toBe(false); + expect(isAdminRole('nurse')).toBe(false); + }); +}); + +describe('resolveRoleDestination (role-router branches)', () => { + it('sends a customer to the family app home', () => { + expect(resolveRoleDestination({ roles: ['customer'] })).toBe(ROUTES.HOME); + }); + + it('sends a nurse to the nurse app', () => { + expect(resolveRoleDestination({ roles: ['nurse'] })).toBe(ROUTES.NURSE); + }); + + it('sends a role-less (brand-new) user to role selection', () => { + expect(resolveRoleDestination({ roles: [] })).toBe(ROUTES.SELECT_ROLE); + }); + + it('sends any admin-family role to the admin console', () => { + expect(resolveRoleDestination({ roles: ['support'] })).toBe(ROUTES.ADMIN); + expect(resolveRoleDestination({ roles: ['customer', 'super_admin'] })).toBe(ROUTES.ADMIN); + }); + + it('uses the login intent to disambiguate a dual customer+nurse user', () => { + expect(resolveRoleDestination({ roles: ['customer', 'nurse'] }, 'nurse')).toBe(ROUTES.NURSE); + expect(resolveRoleDestination({ roles: ['customer', 'nurse'] }, 'customer')).toBe(ROUTES.HOME); + // Defaults to the family app when no intent is carried. + expect(resolveRoleDestination({ roles: ['customer', 'nurse'] })).toBe(ROUTES.HOME); + }); +}); diff --git a/client/src/services/auth/routing.ts b/client/src/services/auth/routing.ts new file mode 100644 index 0000000..5055336 --- /dev/null +++ b/client/src/services/auth/routing.ts @@ -0,0 +1,47 @@ +import { APP_ROLES, ROUTES, type AppRole } from '@/constants'; +import type { Me, RoleCode } from './types'; + +const ADMIN_ROLES: RoleCode[] = ['admin', 'support', 'finance', 'moderation', 'super_admin']; + +export function isAdminRole(role: RoleCode): boolean { + return ADMIN_ROLES.includes(role); +} + +/** + * Collapse the server's fine-grained role codes to the three actor experiences the shells + * render. Admin sub-roles all map to the single ADMIN shell; unknown/non-public codes drop out. + */ +export function toAppRoles(roles: RoleCode[]): AppRole[] { + const actors = new Set(); + for (const role of roles) { + if (role === 'customer') actors.add(APP_ROLES.CUSTOMER); + else if (role === 'nurse') actors.add(APP_ROLES.NURSE); + else if (isAdminRole(role)) actors.add(APP_ROLES.ADMIN); + } + return [...actors]; +} + +/** + * The locale-less path a signed-in user should land on after auth — the pure core of the role + * router (the component just navigates to it). `intendedRole` is the role carried from the login + * switch (A1 vs B1); it only disambiguates a user who holds **both** customer and nurse. + * + * A nurse always routes to the nurse app here; the B3 "verification in progress" landing for an + * unverified nurse is f5's job (this only routes correctly, it doesn't render the banner). + */ +export function resolveRoleDestination(me: Pick, intendedRole?: AppRole): string { + if (me.roles.length === 0) return ROUTES.SELECT_ROLE; + if (me.roles.some(isAdminRole)) return ROUTES.ADMIN; + + const hasNurse = me.roles.includes('nurse'); + const hasCustomer = me.roles.includes('customer'); + + let target: AppRole; + if (hasNurse && hasCustomer) { + target = intendedRole === APP_ROLES.NURSE ? APP_ROLES.NURSE : APP_ROLES.CUSTOMER; + } else { + target = hasNurse ? APP_ROLES.NURSE : APP_ROLES.CUSTOMER; + } + + return target === APP_ROLES.NURSE ? ROUTES.NURSE : ROUTES.HOME; +} diff --git a/client/src/services/auth/types.ts b/client/src/services/auth/types.ts index 8d726f4..bb61dcb 100644 --- a/client/src/services/auth/types.ts +++ b/client/src/services/auth/types.ts @@ -1,19 +1,104 @@ -export interface LoginDto { - username: string; - password: string; +/** + * Auth domain — phone-OTP login, revocable refresh-token sessions, `/me`, and public + * role selection. Shapes mirror the b2 contract + * (`dev/contracts/domains/identity-auth.md` + `dev/contracts/openapi/swagger.v1.json`) + * exactly, including the **camelCase** wire casing confirmed against the live envelope. + * + * Enums cross the wire as stable string codes (money-and-types.md) and are mirrored here + * as string-literal unions — never hardcode a display label off a code (labels are i18n keys). + */ + +/** Publicly self-selectable roles (customer and nurse may coexist). */ +export type PublicRole = 'customer' | 'nurse'; + +/** Internal-only admin sub-roles — never self-assignable (`me/select_role` → 403). */ +export type AdminRole = 'admin' | 'support' | 'finance' | 'moderation' | 'super_admin'; + +/** Any role code the server may report on a session. */ +export type RoleCode = PublicRole | AdminRole; + +/** Load-bearing for same-gender matching; null until the profile flow (b3) sets it. */ +export type Gender = 'male' | 'female'; + +/** Nurse verification stage. `not_started` until the b6 pipeline exists. */ +export type NurseVerificationStatus = + | 'not_started' + | 'in_progress' + | 'pending_review' + | 'verified' + | 'rejected'; + +/** `POST auth/request_otp` body. The server accepts +98/0098/Persian digits and normalizes. */ +export interface OtpRequest { + phone: string; } +/** `POST auth/request_otp` payload. Inside the resend window `otpSent` is false with the remaining seconds. */ +export interface RequestOtpResult { + otpSent: boolean; + resendAvailableInSeconds: number; +} + +/** `POST auth/verify_otp` body. */ +export interface OtpVerify { + phone: string; + code: string; + deviceInfo?: string; +} + +/** `POST auth/refresh` body — the refresh token is itself the credential. */ +export interface RefreshRequest { + refreshToken: string; + deviceInfo?: string; +} + +/** `POST auth/logout` body. Omitting `refreshToken` (or `everywhere: true`) revokes every session. */ +export interface LogoutRequest { + refreshToken?: string; + everywhere?: boolean; +} + +/** `AuthTokensResult` — returned by verify_otp and refresh. */ export interface AuthTokens { accessToken: string; refreshToken: string; + accessExpiresAt: string; + refreshExpiresAt: string; + /** `true` only on the first successful verify for a phone ⇒ send to role selection. */ + isNewUser: boolean; + /** Active roles; empty ⇒ route the user to role selection. */ + roles: RoleCode[]; } -import type { AppRole } from '@/constants'; - -export interface User { - id: string; - username: string; - // Populated by the server in f1-b2. Optional until then; the shell defaults to - // the customer experience when roles are absent (see useActorRole). - roles?: AppRole[]; +/** `POST me/select_role` body. Only public roles are accepted. */ +export interface SelectRoleDto { + role: PublicRole; +} + +/** `MeResult` — the role router's input and the shell's role source. */ +export interface Me { + id: number; + /** Always masked by the server (`0912*****33`); the full number is never returned. */ + phone: string; + firstName: string | null; + lastName: string | null; + gender: Gender | null; + isActive: boolean; + roles: RoleCode[]; + hasCustomerProfile: boolean; + hasNurseProfile: boolean; + nurseVerificationStatus: NurseVerificationStatus; +} + +/** + * The auth domain's API seam. The real HTTP client and the in-memory mock both implement + * this interface; selection is by config (`USE_AUTH_MOCK`), never scattered `if (mock)` checks. + */ +export interface AuthApi { + requestOtp(body: OtpRequest): Promise; + verifyOtp(body: OtpVerify): Promise; + refresh(body: RefreshRequest): Promise; + logout(body?: LogoutRequest): Promise; + getMe(): Promise; + selectRole(body: SelectRoleDto): Promise; } diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 3c01f87..9692f8f 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -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 `/$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 diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 1ab542a..79a13c3 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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 diff --git a/dev/shared-working-context/reports/frontend-phase-1-report.md b/dev/shared-working-context/reports/frontend-phase-1-report.md new file mode 100644 index 0000000..2ac7ac5 --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-1-report.md @@ -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 ``). `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`).