frontend phase 1: auth — phone-OTP login, role routing & session refresh

Replace the username/password stub with Balinyaar's real credential (phone-OTP)
and add the role router every authenticated screen sits behind.

- services/auth rewritten for OTP over the b2 contract: types/keys/constants,
  clientApi+mockApi behind a config'd seam, hooks (useRequestOtp/useVerifyOtp/
  useMe/useRefresh/useLogout/useSelectRole/useSessionRoleSync). Stub removed.
- A1/A2 customer login + B1/B2 nurse switch as one OTP flow at /login
  (PhoneStep/OtpStep: auto-verify, resend countdown, wrong/expired/lockout states).
- Role router: pure resolveRoleDestination + RoleRouter -> family / nurse /
  admin / select-role, with a splash while /me loads (no wrong-shell flash).
- SelectRole first-use screen at /select-role.
- Widened AuthState (roles via SessionUser), hydrated from /me by useSessionRoleSync.
- Fetch-layer silent token refresh (single-flight + one retry) + shared
  persistAuthTokens/clearAuthTokens; useRefresh as the on-demand path.
- auth i18n namespace in both locales; tests for routing branches, countdown,
  OtpStep state machine, RoleRouter branches; fixed jest @/ -> src alias.
- Docs: client/CLAUDE.md, frontend STATUS/report, for-backend REQ-002..004.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 11:58:52 +03:30
parent 3a51305343
commit 17a82832ab
50 changed files with 1815 additions and 108 deletions
+48 -27
View File
@@ -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`.
---