frontend phase 1: auth — phone-OTP login, role routing & session refresh
Replace the username/password stub with Balinyaar's real credential (phone-OTP) and add the role router every authenticated screen sits behind. - services/auth rewritten for OTP over the b2 contract: types/keys/constants, clientApi+mockApi behind a config'd seam, hooks (useRequestOtp/useVerifyOtp/ useMe/useRefresh/useLogout/useSelectRole/useSessionRoleSync). Stub removed. - A1/A2 customer login + B1/B2 nurse switch as one OTP flow at /login (PhoneStep/OtpStep: auto-verify, resend countdown, wrong/expired/lockout states). - Role router: pure resolveRoleDestination + RoleRouter -> family / nurse / admin / select-role, with a splash while /me loads (no wrong-shell flash). - SelectRole first-use screen at /select-role. - Widened AuthState (roles via SessionUser), hydrated from /me by useSessionRoleSync. - Fetch-layer silent token refresh (single-flight + one retry) + shared persistAuthTokens/clearAuthTokens; useRefresh as the on-demand path. - auth i18n namespace in both locales; tests for routing branches, countdown, OtpStep state machine, RoleRouter branches; fixed jest @/ -> src alias. - Docs: client/CLAUDE.md, frontend STATUS/report, for-backend REQ-002..004. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+48
-27
@@ -114,7 +114,8 @@ client/
|
||||
│ └── [locale]/
|
||||
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + 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<T> — throws ApiError on error; use in hooks/client components
|
||||
│ │ ├── client.ts # clientFetch<T> — throws ApiError on error; use in hooks/client components; silent-refreshes + retries once on 401
|
||||
│ │ ├── server.ts # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
|
||||
│ │ ├── types.ts # ApiEnvelope<T> + unwrap(), Paginated<T>, 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
|
||||
`<AuthProvider initialState={…}>`. 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 `<AuthProvider initialState={…}>`, 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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ const customJestConfig: Config = {
|
||||
coverageProvider: 'v8',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
moduleNameMapper: {
|
||||
// Handle module aliases
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
// Handle the `@/*` alias — it maps to `src/*` (see tsconfig.json), so jest.mock('@/…')
|
||||
// and imports resolve to the same files the app uses.
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
// testEnvironment: 'jest-environment-jsdom',
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
+32
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -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": "ادامه"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <PrivateLayout>{children}</PrivateLayout>;
|
||||
}
|
||||
|
||||
@@ -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 <SelectRole initialRole={initialRole} />;
|
||||
}
|
||||
|
||||
/** First-use role picker for an authenticated user with no public role yet. */
|
||||
export default function SelectRolePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SelectRoleContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense>
|
||||
<LoginFlow />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<TextFieldProps, 'onChange' | 'value' | 'type'> {
|
||||
/** Controlled value — normalized ASCII digits, no formatting. */
|
||||
value: string;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<PropsWithChildren> = ({ children }) => (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: AUTH_CARD_MAX_WIDTH,
|
||||
p: { xs: 3, sm: 4 },
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<BrandMark withTagline />
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default AuthCard;
|
||||
@@ -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<AuthSplashProps> = ({ message }) => (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 2 }}>
|
||||
<BrandMark />
|
||||
<AppLoading />
|
||||
{message && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default AuthSplash;
|
||||
@@ -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<BrandMarkProps> = ({ withTagline }) => {
|
||||
const t = useTranslations('common');
|
||||
return (
|
||||
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="logo" size={56} color="var(--bal-primary)" />
|
||||
<Typography variant="h5" component="p" sx={{ fontWeight: 700, color: 'primary.main' }}>
|
||||
{t('brand')}
|
||||
</Typography>
|
||||
{withTagline && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center' }}>
|
||||
{t('brand_tagline')}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandMark;
|
||||
@@ -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<AppRole>(
|
||||
searchParams.get('role') === APP_ROLES.NURSE ? APP_ROLES.NURSE : APP_ROLES.CUSTOMER,
|
||||
);
|
||||
const [step, setStep] = useState<Step>('phone');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [resendSeconds, setResendSeconds] = useState(OTP_RESEND_FALLBACK_SECONDS);
|
||||
|
||||
if (step === 'routing') {
|
||||
return <RoleRouter intendedRole={intendedRole} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard>
|
||||
{step === 'phone' ? (
|
||||
<PhoneStep
|
||||
intendedRole={intendedRole}
|
||||
onSwitchRole={() =>
|
||||
setIntendedRole((role) =>
|
||||
role === APP_ROLES.NURSE ? APP_ROLES.CUSTOMER : APP_ROLES.NURSE,
|
||||
)
|
||||
}
|
||||
onSent={(nextPhone, result) => {
|
||||
setPhone(nextPhone);
|
||||
setResendSeconds(result.resendAvailableInSeconds);
|
||||
setStep('otp');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<OtpStep
|
||||
phone={phone}
|
||||
intendedRole={intendedRole}
|
||||
resendSeconds={resendSeconds}
|
||||
onVerified={() => setStep('routing')}
|
||||
onChangeNumber={() => setStep('phone')}
|
||||
/>
|
||||
)}
|
||||
</AuthCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginFlow;
|
||||
@@ -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(
|
||||
<ThemeProvider>
|
||||
<OtpStep
|
||||
phone={PHONE}
|
||||
intendedRole="customer"
|
||||
resendSeconds={120}
|
||||
onVerified={onVerified}
|
||||
onChangeNumber={onChangeNumber}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
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('<OtpStep/> 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);
|
||||
});
|
||||
});
|
||||
@@ -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<OtpStepProps> = ({
|
||||
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 (
|
||||
<Stack component="form" onSubmit={submit} sx={{ gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('otp_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('otp_sent_to', { phone: maskIranMobile(phone) })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<OtpInput
|
||||
length={OTP_CODE_LENGTH}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={verify}
|
||||
disabled={locked || verifyOtp.isPending}
|
||||
error={hasError}
|
||||
autoFocus
|
||||
aria-label={t('otp_title')}
|
||||
/>
|
||||
|
||||
{locked ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', textAlign: 'center' }}>
|
||||
{t('otp_locked')}
|
||||
</Typography>
|
||||
) : hasError ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', textAlign: 'center' }}>
|
||||
{t('otp_invalid')}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<AppButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={code.length !== OTP_CODE_LENGTH || verifyOtp.isPending || locked}
|
||||
startIcon={verifyOtp.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{isNurse ? t('otp_verify_nurse') : t('otp_verify_customer')}
|
||||
</AppButton>
|
||||
|
||||
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||
{resendDisabled && countdown.isActive && !locked ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('resend_in', { time: formatMmSs(countdown.seconds) })}
|
||||
</Typography>
|
||||
) : (
|
||||
<MuiLink
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={resendDisabled}
|
||||
underline="hover"
|
||||
sx={{ color: 'primary.main' }}
|
||||
>
|
||||
{t('resend')}
|
||||
</MuiLink>
|
||||
)}
|
||||
<MuiLink
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onChangeNumber}
|
||||
underline="hover"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
{t('change_number')}
|
||||
</MuiLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtpStep;
|
||||
@@ -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<PhoneStepProps> = ({ 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 (
|
||||
<Stack component="form" onSubmit={submit} sx={{ gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{isNurse ? t('nurse_title') : t('customer_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{isNurse ? t('nurse_subtitle') : t('customer_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<PhoneNumberField
|
||||
value={phone}
|
||||
onChange={(next) => {
|
||||
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
|
||||
/>
|
||||
|
||||
<AppButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={requestOtp.isPending}
|
||||
startIcon={requestOtp.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('request_code')}
|
||||
</AppButton>
|
||||
|
||||
<MuiLink
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onSwitchRole}
|
||||
underline="hover"
|
||||
sx={{ color: 'text.secondary', textAlign: 'center' }}
|
||||
>
|
||||
{isNurse ? t('customer_switch') : t('nurse_switch')}
|
||||
</MuiLink>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhoneStep;
|
||||
@@ -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<Me>; isError: boolean };
|
||||
jest.mock('@/services/auth', () => ({ useMe: () => meResult }));
|
||||
|
||||
import RoleRouter from './RoleRouter';
|
||||
|
||||
function renderRouter(intendedRole?: 'customer' | 'nurse') {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<RoleRouter intendedRole={intendedRole} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<RoleRouter/>', () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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<RoleRouterProps> = ({ 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 <AuthSplash message={t('routing_title')} />;
|
||||
};
|
||||
|
||||
export default RoleRouter;
|
||||
@@ -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<SelectRoleProps> = ({ 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<PublicRole>(preset);
|
||||
|
||||
const confirm = () => {
|
||||
selectRole.mutate(selected, {
|
||||
onSuccess: (me) => router.replace(`/${locale}${resolveRoleDestination(me, selected)}`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
|
||||
<Stack sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, gap: 3 }}>
|
||||
<BrandMark />
|
||||
<Stack sx={{ gap: 0.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('select_role_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('select_role_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{ROLE_OPTIONS.map(({ role, icon }) => {
|
||||
const isSelected = selected === role;
|
||||
return (
|
||||
<Paper
|
||||
key={role}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{t(`role_${role}`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`role_${role}_desc`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={confirm}
|
||||
disabled={selectRole.isPending}
|
||||
startIcon={selectRole.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('continue')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectRole;
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Reading width for the branded auth screens (login, OTP, role selection). */
|
||||
export const AUTH_CARD_MAX_WIDTH = 420;
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof setInterval> | 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 };
|
||||
}
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
export async function clientFetch<T>(path: string, options?: RequestInit, isRetry = false): Promise<T> {
|
||||
const token = getClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
const locale = window.location.pathname.split('/')[1] || 'fa';
|
||||
|
||||
@@ -59,6 +64,13 @@ export async function clientFetch<T>(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<T>(path, options, true);
|
||||
}
|
||||
deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
dispatchToast('Session expired. Please log in again.', 'error');
|
||||
|
||||
@@ -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<boolean> | null = null;
|
||||
|
||||
async function doRefresh(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
if (!inFlight) {
|
||||
inFlight = doRefresh().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<AuthTokens>('/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<ApiEnvelope<RequestOtpResult>>(`${AUTH_API_BASE}/auth/request_otp`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
|
||||
verifyOtp: async (body: OtpVerify) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<AuthTokens>>(`${AUTH_API_BASE}/auth/verify_otp`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
|
||||
refresh: async (body: RefreshRequest) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<AuthTokens>>(`${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<ApiEnvelope<void>>(`${AUTH_API_BASE}/auth/logout`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
}),
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
},
|
||||
|
||||
logout: () =>
|
||||
clientFetch<void>('/auth/logout', { method: 'POST' }),
|
||||
getMe: async () => unwrap(await clientFetch<ApiEnvelope<Me>>(`${AUTH_API_BASE}/me`)),
|
||||
|
||||
getCurrentUser: () =>
|
||||
clientFetch<User>('/auth/me'),
|
||||
selectRole: async (body: SelectRoleDto) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<Me>>(`${AUTH_API_BASE}/me/select_role`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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<MockScenario, RoleCode[]> = {
|
||||
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();
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const authKeys = {
|
||||
all: ['auth'] as const,
|
||||
currentUser: () => [...authKeys.all, 'me'] as const,
|
||||
me: () => [...authKeys.all, 'me'] as const,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AppRole>();
|
||||
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<Me, 'roles'>, 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;
|
||||
}
|
||||
@@ -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<RequestOtpResult>;
|
||||
verifyOtp(body: OtpVerify): Promise<AuthTokens>;
|
||||
refresh(body: RefreshRequest): Promise<AuthTokens>;
|
||||
logout(body?: LogoutRequest): Promise<void>;
|
||||
getMe(): Promise<Me>;
|
||||
selectRole(body: SelectRoleDto): Promise<Me>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,25 @@ for awareness.
|
||||
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
|
||||
-->
|
||||
|
||||
## frontend-phase-1-b2 — Auth: phone-OTP login & role routing — 2026-07-02
|
||||
- **Shipped:** `services/auth` rewritten for phone-OTP (types/keys/apis[client+mock+seam]/hooks:
|
||||
`useRequestOtp`/`useVerifyOtp`/`useMe`/`useRefresh`/`useLogout`/`useSelectRole`/`useSessionRoleSync`) —
|
||||
the username/password stub is gone; A1/A2 customer login + B1/B2 nurse switch (one OTP flow parameterised
|
||||
by intended role) at `/login`; the **role router** (`RoleRouter` + pure `resolveRoleDestination`) →
|
||||
customer→family, nurse→nurse app, no-role→`/select-role`, admin→admin console (splash while `/me` loads,
|
||||
no wrong-shell flash); `SelectRole` screen at `/select-role`; **silent token refresh** in the fetch layer
|
||||
(single-flight `attemptTokenRefresh`, one retry on 401); widened `AuthState` (roles via `SessionUser`,
|
||||
hydrated from `/me` by `useSessionRoleSync` in the private layout); `auth` i18n namespace + `common.brand*`
|
||||
in both locales.
|
||||
- **Consumes:** dev/contracts/domains/identity-auth.md + openapi/swagger.v1.json (backend-phase-2). Wire is
|
||||
**camelCase**; routes `api/v1/auth/{request_otp,verify_otp,refresh,logout}`, `api/v1/me`, `api/v1/me/select_role`.
|
||||
- **Mocked client-side:** `services/auth` via `authMockApi` behind `USE_AUTH_MOCK` (**default false** — b2 is
|
||||
live; flip true for offline dev, dev code `123456`). See mocks-registry.
|
||||
- **Gate:** npm run check green · npm run test:ci green (95 tests, +23) · npm run build green with
|
||||
NEXT_PUBLIC_API_URL set. Fixed the jest `@/`→`src` alias (was `<rootDir>/$1`).
|
||||
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-002 OTP length/expiry, REQ-003 verify
|
||||
error codes + lockout retry-after, REQ-004 multi-role `activeRole?`).
|
||||
|
||||
## frontend-phase-0 — Foundations: app shells, design system & data/contract patterns — 2026-07-02
|
||||
- **Shipped:** 3 actor shells (customer bottom-nav / nurse / admin sidebar) + role-aware routing under
|
||||
`(private-routes)`; `useActorRole`; the `services/{domain}` reference (`patients`, mocked behind a
|
||||
|
||||
@@ -30,3 +30,34 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
||||
- **Proposed shape:** `{ isSuccess: boolean, statusCode: number, message?: string, requestId?: string, data?: T }`
|
||||
and `data: { items: T[], total: number, page: number, pageSize: number }` for lists.
|
||||
- **Status:** open
|
||||
|
||||
## REQ-002 — OTP length + expiry in RequestOtpResult — filed by frontend-phase-1-b2 — 2026-07-02
|
||||
- **Need:** Add `codeLength` (int) and `expiresInSeconds` (int) to `RequestOtpResult`.
|
||||
- **Why:** The A2/B2 OTP screen renders one box per digit and (later) a code-expiry hint. `RequestOtpResult`
|
||||
currently exposes only `otpSent` + `resendAvailableInSeconds`, so the frontend hardcodes the box count
|
||||
(`OTP_CODE_LENGTH = 6`, inferred from the live 6-digit verify example, not the 4-box wireframe). Surfacing
|
||||
the length makes the box count contract-driven; the expiry lets us show "code expires in …".
|
||||
- **Proposed shape:** `{ otpSent: boolean, resendAvailableInSeconds: number, codeLength: number, expiresInSeconds: number }`
|
||||
- **Status:** open
|
||||
|
||||
## REQ-003 — Machine-readable error codes for verify_otp failures — filed by frontend-phase-1-b2 — 2026-07-02
|
||||
- **Need:** A stable `code` on the 400 envelope for verify_otp that distinguishes wrong code vs expired code
|
||||
vs max-attempts lockout (e.g. `otp_invalid` | `otp_expired` | `otp_locked`), and — for lockout — a
|
||||
`retryAfterSeconds` (or `lockedUntil`) field.
|
||||
- **Why:** The OTP screen has explicit **wrong-code**, **expired-code**, and **max-attempts-lockout** states
|
||||
(per the phase spec), but the contract returns the same safe 400 message for all of them, so the frontend
|
||||
can't reliably tell them apart. Today it maps a lockout only when it sees the mock's `otp_locked` code and
|
||||
otherwise degrades to a generic "incorrect or expired" message. A stable machine code (kept generic enough
|
||||
to avoid account enumeration) would let the UI render the precise state + the unlock countdown.
|
||||
- **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }`
|
||||
- **Status:** open
|
||||
|
||||
## REQ-004 — Confirm multi-role disambiguation (activeRole?) — filed by frontend-phase-1-b2 — 2026-07-02
|
||||
- **Need:** Confirm whether `MeResult` will gain an `activeRole` (the user's currently-selected actor) for a
|
||||
user who holds **both** `customer` and `nurse`, or whether the client should keep owning that choice.
|
||||
- **Why:** The role router must pick one app for a dual-role user. Absent an `activeRole` in the contract,
|
||||
it currently uses the **intended role** carried from the login switch (A1 vs B1), defaulting to the family
|
||||
app. If the backend intends to persist a "current role", the router should prefer it. Also note: verify_otp
|
||||
returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
|
||||
flagging in case that changes.
|
||||
- **Status:** open
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Frontend Phase 1 (b2) — Auth: phone-OTP login & role routing — Report (2026-07-02)
|
||||
|
||||
## What was built
|
||||
|
||||
**`services/auth` — rewritten for phone-OTP (the username/password stub is removed, not left dangling)**
|
||||
- `types.ts` — contract-mirrored (camelCase) shapes: `OtpRequest`, `RequestOtpResult`, `OtpVerify`,
|
||||
`RefreshRequest`, `LogoutRequest`, `AuthTokens` (= `AuthTokensResult`), `SelectRoleDto`, `Me` (= `MeResult`),
|
||||
`RoleCode`/`PublicRole`/`AdminRole`, `Gender`, `NurseVerificationStatus`, and the `AuthApi` seam interface.
|
||||
- `keys.ts` — `authKeys.me()` (+ `authKeys.all`).
|
||||
- `constants.ts` — `USE_AUTH_MOCK` (default **false**), `AUTH_API_BASE = '/api/v1'`, `AUTH_ME_STALE_TIME`,
|
||||
`OTP_CODE_LENGTH = 6`, `OTP_RESEND_FALLBACK_SECONDS`, `OTP_LOCKED_CODE`.
|
||||
- `routing.ts` — **pure** `resolveRoleDestination(me, intendedRole)` (the role router's core), `toAppRoles`
|
||||
(fine-grained role codes → the 3 actor roles; all admin sub-roles → `admin`), `isAdminRole`.
|
||||
- `apis/clientApi.ts` (real, over `clientFetch` + `unwrap`, exact snake_case routes), `apis/mockApi.ts`
|
||||
(in-memory, dev code `123456`, 3-try lockout, `MOCK_SCENARIO` toggle), `apis/index.ts` (seam selector).
|
||||
- hooks (one per file): `useRequestOtp`, `useVerifyOtp`, `useMe`, `useRefresh`, `useLogout`, `useSelectRole`,
|
||||
`useSessionRoleSync`; barrel `index.ts` re-exports **hooks only**.
|
||||
|
||||
**Screens (branded, RTL-first, both locales, via the f0 OTP/phone composites + the `App*` library)**
|
||||
- **A1/B1** `PhoneStep` — one phone step for both actors, copy + role-switch link driven by `intendedRole`;
|
||||
inline invalid-number and rate-limit states.
|
||||
- **A2/B2** `OtpStep` — `OtpInput` (6 boxes), masked-phone echo, single resend countdown (`useCountdown`,
|
||||
cleaned up on unmount), auto-verify on last digit, and explicit **wrong-code / expired / max-attempts
|
||||
lockout** states; CTA copy differs by actor.
|
||||
- `LoginFlow` orchestrates phone→otp→routing (one stack, `?role=nurse` seeds nurse intent); `AuthCard`,
|
||||
`BrandMark`, `AuthSplash` are the shared branded shells.
|
||||
- **Role router** `RoleRouter` — consumes `useMe`, shows `AuthSplash` while `/me` is in flight (never flashes
|
||||
the wrong shell), then `router.replace`s to the resolved app; carries nurse intent into `/select-role`.
|
||||
- **SelectRole** — first-use picker (خانواده / پرستار only; admin never selectable), pre-selects the login
|
||||
intent; on select it calls `me/select_role`, rotates the token (role claim lives in the access token), and
|
||||
routes into the chosen app.
|
||||
- Pages: `(public-routes)/login/page.tsx`, `(private-routes)/select-role/page.tsx` (both wrap the
|
||||
search-param reader in `<Suspense>`). `ROUTES.SELECT_ROLE = '/select-role'` (authenticated, not public).
|
||||
|
||||
**Session / auth plumbing**
|
||||
- Widened `AuthState`: `currentUser` is now `SessionUser { id?, phone, roles: AppRole[] }`. The server still
|
||||
seeds **`isAuthenticated` only** (the access token is an opaque JWE — roles aren't derivable edge/server-side);
|
||||
`useSessionRoleSync` (mounted in the private-routes layout) hydrates roles from `/me` so the f0 shells pick
|
||||
chrome from the real role — one source of truth, no second role store.
|
||||
- **Silent refresh in the fetch layer** (`lib/api/refresh.ts` + a 401 branch in `clientFetch`): on a 401 it
|
||||
attempts one single-flight refresh and retries the original request once; on refresh failure it clears
|
||||
tokens and redirects to login (matches the server's rotation + reuse-detection → sign-in-again). Token
|
||||
cookie writes are centralised in `lib/auth/session.ts` (`persistAuthTokens`/`clearAuthTokens`), shared by
|
||||
the hooks and the fetch layer. `useRefresh` is the explicit/on-demand path (used after `select_role`).
|
||||
- `invalidateQueries(authKeys.me())` on login; `removeQueries(authKeys.all)` on logout.
|
||||
|
||||
## What is now testable (and exactly how)
|
||||
|
||||
`cd client && npm run dev` (b2 backend running, or `USE_AUTH_MOCK = true` for offline with dev code `123456`):
|
||||
- **Customer happy path:** `/fa/login` → valid mobile → «دریافت کد تایید» → A2 shows masked phone + counting
|
||||
resend → enter code (auto-verifies) → tokens in cookies (DevTools → Application → Cookies; **nothing** in
|
||||
localStorage) → redirected to the **customer** home; `/me` in the Query cache.
|
||||
- **Nurse switch:** A1 → «پرستار هستید؟ ورود پرستاران ←» → B1 nurse copy → verify («تایید و ورود») → routed
|
||||
to the **nurse** app (B3 status is f5; routed to `/nurse` for now).
|
||||
- **No-role user:** verify a user whose `/me.roles` is empty → **`/select-role`** → pick a role → routed in.
|
||||
- **OTP edges:** wrong code → inline error + boxes clear; resend re-enabled when the countdown hits 0; 3 wrong
|
||||
tries (mock) → input locks with the lockout message.
|
||||
- **Session:** logout → both cookies cleared, `/me` dropped, back to `/login`; an expired access token is
|
||||
silently refreshed on the next call (or a clean redirect to `/login` if the session is gone).
|
||||
- **i18n/RTL:** `/en` translates and stays LTR-correct; `/fa` is RTL.
|
||||
- Gate: `npm run check` green · `npm run test:ci` green (95 tests) · `npm run build` green (with
|
||||
`NEXT_PUBLIC_API_URL` set — see follow-ups).
|
||||
|
||||
## Tests added
|
||||
- `services/auth/routing.test.ts` — all role-router branches + `toAppRoles`/`isAdminRole`.
|
||||
- `components/auth/useCountdown.test.ts` — countdown lifecycle (fake timers; no leaked interval).
|
||||
- `components/auth/OtpStep.test.tsx` — box count, auto-verify, success→onVerified, wrong-code clears boxes.
|
||||
- `components/auth/RoleRouter.test.tsx` — loading (no nav), customer/nurse/no-role/error navigation targets.
|
||||
- Fixed the jest `@/` alias (`<rootDir>/$1` → `<rootDir>/src/$1`) so `jest.mock('@/…')` resolves.
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
- **`services/auth` client-side mock** (`authMockApi`) behind `USE_AUTH_MOCK` (default **false**; the real
|
||||
client is wired to the live b2 routes). Recorded in `mocks-registry.md`. OTP/SMS delivery itself is the
|
||||
**backend's** `ISmsSender` seam — the frontend never sends SMS.
|
||||
|
||||
## Contracts
|
||||
- **Produced:** none (frontend consumes).
|
||||
- **Consumed:** `dev/contracts/domains/identity-auth.md` + `openapi/swagger.v1.json` (b2). Reconciliations
|
||||
vs the phase's sketch: wire is **camelCase** (not snake_case body); `intended_role` is **not** sent to the
|
||||
server (client-only routing state); `Me` has **no** `activeRole`/`profileCompleted` (router uses intended
|
||||
role; profile state = `hasCustomerProfile`/`hasNurseProfile`); OTP length isn't in the contract (defaulted
|
||||
to 6).
|
||||
- **Requests filed:** REQ-002 (OTP `codeLength`/`expiresInSeconds`), REQ-003 (verify error codes + lockout
|
||||
`retryAfterSeconds`), REQ-004 (multi-role `activeRole?`; verify returns no `id`).
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **B3 verification landing** (unverified nurse) is **f5** — the router sends nurses to `/nurse`; the
|
||||
persistent "verification in progress" banner is f5's job.
|
||||
- **A3/A4 onboarding & profiles** are **f2-b3** (their own `onboarding` namespace); SelectRole only picks the
|
||||
role.
|
||||
- **Admin screens** are **f15** — the router routes admins correctly but builds no admin UI here.
|
||||
- **Refresh-cookie TTL:** the client cookie is 7d (f0 design) while the contract's session default is 30d;
|
||||
aligning the cookie `maxAge` to `refreshExpiresAt` is a small follow-up (kept the f0 constants for now).
|
||||
- **`npm run build` needs `NEXT_PUBLIC_API_URL`** (pre-existing f0 note — `@/config` throws at prerender
|
||||
without it; `next build` doesn't read `.env.development`).
|
||||
Reference in New Issue
Block a user