Files
2026-06-28 21:59:59 +03:30

26 KiB

Frontend Phase 1 — Auth: phone-OTP login & role routing

Mission: make people able to actually sign in. Replace the inherited username/password stub with Balinyaar's real credential — phone-OTP — and wire the gate that every other screen sits behind. Build the customer login (A1), the OTP screen (A2), the family↔nurse login switch (B1/B2 entry), and the role router that reads /me after sign-in and sends a customer to the family app, a nurse to the nurse app, and a brand-new user with no role to role selection. After this phase the app has a real front door; f2-b3 (onboarding & profiles) and every authenticated screen build on it.

Track: frontend · Depends on: frontend-phase-0 (shells, the OTP/phone composites, the services/{domain} + Query pattern, the cookie manager, AuthContext) · backend contract dev/contracts/domains/identity-auth.md (from backend-phase-2) · Unlocks: every authenticated screen; f2-b3 onboarding & profiles Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

The client (client/, Next.js 16 App Router + React 19 + MUI v9 + next-intl, fa default & RTL) has a full app shell, the three actor app shells from f0, and a cookie/fetch/Query/auth stack — but no way to log in. Today's auth domain is a username/password stub (useLogin({ username, password })/auth/login) with no UI; per the product docs, Balinyaar's only real credential is phone-OTP (product/business/01-actors-and-onboarding.md: "Phone number is the primary login credential … Authentication is phone-OTP"). This phase swaps the stub for the real thing and adds the role router that turns "authenticated" into "in the right app".

What already exists (do not rebuild) — confirmed in the codebase:

  • From frontend-phase-0: the three actor app shells + role-scoped route groups under (private-routes); the shared OTP code input and Iranian phone-number field composites (src/components/… with co-located tests); the services/{domain} + TanStack Query reference pattern (a keys.ts factory, apis/clientApi.ts over clientFetch, one-hook-per-file, invalidate-on-mutation); the types-from-contract convention; the money/format util; the auth, common, nav i18n namespaces seeded in both en.json/fa.json. Read the f0 report (reports/frontend-phase-0-report.md) for exactly what shipped and how the mock-clientApi seam works.
  • The stack (built before f0): root layout src/app/[locale]/layout.tsx (providers NextIntl → Auth → Theme → Query → Notistack); clientFetch/serverFetch + ApiError (@/lib/api) — 401 already clears cookies + toasts + redirects without throwing; 403/5xx toast+throw; other 4xx throw-no-toast; the cookie manager (@/lib/cookies/clientsetClientCookie/deleteClientCookie, with AUTH_ACCESS_COOKIE_OPTIONS ≈15 min and AUTH_REFRESH_COOKIE_OPTIONS ≈7 d, both non-httpOnly by the current design); AuthContext (useReducer [state, dispatch], actions LOG_IN{user?}/LOG_OUT, server-seeded initialState); makeQueryClient (staleTime 30 s); the middleware auth gate (isTokenAlive on access_token, redirects non-public paths to /${locale}/login).
  • The auth domain (the stub you are replacing): src/services/auth/types.ts (LoginDto {username,password}, AuthTokens {accessToken,refreshToken}, User {id,username}), keys.ts (authKeys.currentUser()), apis/clientApi.ts (AuthClientApi.login/logout/getCurrentUser), hooks/useLogin|useLogout|useCurrentUser, index.ts. The login hook writes both tokens as non-httpOnly cookies and dispatches LOG_IN; useLogout deletes both, dispatches LOG_OUT, redirects to /${locale}/login. There is no login page yet — only the hooks. You extend this domain in place.

What you are replacing / removing in this phase:

  • The username/password LoginDto + useLogin({username,password}) + POST /auth/login path — replaced by the OTP request/verify pair. Remove the dead username/password types and the login api/hook once the OTP flow is wired; don't leave both (no-unused-vars is a lint error).

Wireframe reality check: A1/A2 (customer) and B1/B2 (nurse) are the same OTP mechanism with different copy and a different post-login destination. Build one OTP flow and parameterise the intended-role/copy — do not fork two parallel login stacks.

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/frontend-conventions-checklist.md — how you work, the gate, the §6 contract-gap handoff, the caching/re-render rules.
  • The contract you consume: dev/contracts/domains/identity-auth.md (published by backend-phase-2) — the source of truth for the OTP/refresh/logout/me shapes, routes, status codes, and the Me/roles enum. Do not guess shapes. Read it together with ../../contracts/conventions/api-conventions.md (envelope, snake_case routes — [controller]/[action] so RequestOtp.../request_otp; 400/401/403/409 meanings; the locale header) and ../../contracts/conventions/money-and-types.md (enums cross the wire as stable string codesmale/female, roles as codes — and the frontend mirrors them as string-literal unions; never hardcode a display label off a code, labels are i18n keys). If the contract isn't published yet or a needed shape is missing (e.g. the exact Me role codes, the resend-cooldown / max-attempts fields), follow operating-rules §6: append a request to dev/shared-working-context/frontend/requests/for-backend.md and mock behind the services/auth seam meanwhile (§4 below).
  • client/CLAUDE.md — RSC/client boundary, the cookie rules (auth state only through the cookie manager — never document.cookie/localStorage), the services pattern, anti-patterns.
  • Invoke the frontend-designer skill before any visual work — A1/A2/B1 are full branded screens (brand mark, teal #1d4a40 primary CTA, RTL Persian, Vazirmatn/Mikhak). It is the palette/token/typography and App*-library contract; all the login UI goes through it.
  • product/wireframes/index.html — screens A1, A2, B1, B2 (and the role-router branch implied by A5 home vs B3 nurse status). The exact Persian strings are there: A1 CTA "دریافت کد تایید", nurse-switch link "پرستار هستید؟ ورود پرستاران ←"; A2 resend "ارسال مجدد کد تا ۰۰:۴۵" + CTA "تایید و ادامه"; B1 subtitle "ویژه پرستاران دارای پروانه نظام پرستاری", CTA "دریافت کد تایید"; B2 CTA "تایید و ورود".
  • product/business/01-actors-and-onboarding.md — the auth/role rules: phone is the primary credential (email never a login key), customer browses with only a verified phone, sessions are revocable with refresh-token rotation + stolen-token detection, admin roles are internal-only / never self-assigned.
  • The existing src/services/auth/*, src/context/auth/*, src/lib/cookies/*, src/lib/api/*, src/middleware.ts — the exact code you extend.

3. Scope — build this

A vertical slice: service → hooks → screens → role router, all over the published contract.

3.1 Rewrite services/auth for OTP (replace the username/password stub)

Keep the f0 service shape (types.ts / keys.ts / apis/clientApi.ts / hooks/use*.ts / index.ts); swap its contents to OTP. Mirror the published contract for every shape — the names below are the intended surface; reconcile field names/casing against swagger.json and file a for-backend.md request for any gap.

  • types.ts — replace LoginDto with:
    • OtpRequest{ phone: string; intended_role?: RoleCode } (the role the user is logging in as, from the A1/B1 switch — used only for routing/copy; the server decides actual roles).
    • OtpVerify{ phone: string; code: string }.
    • Tokens{ accessToken: string; refreshToken: string } (rename/keep from AuthTokens; match wire casing).
    • Me{ id: number; phone: string; roles: RoleCode[]; active_role?: RoleCode; profile_completed: boolean; nurse_verification_status?: NurseVerificationStatus } — the role-router input. Trim/extend to exactly what GET /me returns in the contract.
    • RoleCode — string-literal union 'customer' | 'nurse' | 'admin' (codes, not labels).
    • NurseVerificationStatus — string-literal union mirroring the contract's enum (e.g. 'not_started' | 'in_progress' | 'pending_review' | 'verified' | 'rejected'); used by the router to decide whether a nurse lands on B3 status vs the nurse home.
    • OtpRequestResult — whatever request_otp returns (e.g. { resend_after_seconds: number; code_length: number; expires_in_seconds: number }); drive the resend countdown and OTP box count from this, not from a hardcoded 45/4.
  • keys.tsauthKeys.me() (replaces currentUser()); keep authKeys.all.
  • apis/clientApi.tsAuthClientApi over clientFetch:
    • requestOtp(body: OtpRequest): Promise<OtpRequestResult>POST .../auth/request_otp
    • verifyOtp(body: OtpVerify): Promise<Tokens>POST .../auth/verify_otp
    • refresh(refreshToken: string): Promise<Tokens>POST .../auth/refresh
    • logout(): Promise<void>POST .../auth/logout
    • getMe(): Promise<Me>GET .../me (Use the exact snake_case routes from the contract; the examples follow the [controller]/[action] transform.) Remove the old login call.
  • hooks/ (one per file):
    • useRequestOtp.tsuseMutation over requestOtp. onSuccess hands the cooldown/length back to A2. Do not toast 401/403/5xx (the fetch layer does) — only surface domain 4xx (e.g. 429 rate-limit "try again in N s", invalid-phone 400) as inline field/screen errors.
    • useVerifyOtp.tsuseMutation over verifyOtp. onSuccess: persist tokens via the cookie manager (setClientCookie with AUTH_ACCESS_COOKIE_OPTIONS/AUTH_REFRESH_COOKIE_OPTIONS), dispatch LOG_IN, invalidateQueries(authKeys.me()), then trigger the role router (3.4). Map the contract's wrong-code / expired-code / max-attempts (lockout) failures to inline states.
    • useLogout.ts — keep behaviour: call logout, deleteClientCookie both tokens, dispatch LOG_OUT, invalidateQueries(authKeys.me()) (or queryClient.clear() for auth keys), redirect to /${locale}/login.
    • useMe.tsuseQuery(authKeys.me(), getMe) (replaces useCurrentUser); enabled only when authenticated; sensible staleTime. This is the role router's data source and the shell's role source.
    • useRefresh.ts (DEFERRED token-rotation UI is out of scope, but) — expose refresh as a callable so the silent refresh path can rotate the access token when it expires (see 3.5). If the existing fetch layer is to own refresh, document that instead and don't duplicate it; flag the decision in your report.
  • index.ts — re-export the new hooks; drop useLogin/useCurrentUser.

3.2 A1 — customer phone login screen

The public login page (the route the middleware redirects to: /${locale}/login). Per A1 / the frontend-designer skill:

  • Brand mark + tagline; the Iranian phone-number field composite from f0 (placeholder ۰۹۱۲ ۰۰۰ ۰۰۰۰, RTL-safe, validates an Iranian mobile).
  • Primary CTA "دریافت کد تایید"useRequestOtp({ phone, intended_role: 'customer' }); on success, advance to A2 (carry the masked phone + cooldown + code length).
  • Secondary link "پرستار هستید؟ ورود پرستاران ←" → switches to the nurse login variant (B1).
  • States: sending (CTA spinner/disabled), invalid/non-Iranian number (inline), rate-limit hit ("تا N ثانیه دیگر دوباره تلاش کنید"), network error (the fetch layer toasts; keep the field intact).

3.3 B1/B2 — nurse login switch (same flow, nurse intent)

Not a separate stack — the same login screen with intended_role: 'nurse', nurse copy (B1 subtitle "ویژه پرستاران دارای پروانه نظام پرستاری", OTP CTA "تایید و ورود"), and a link back to family login. Carry intended_role through A1→A2 (B1→B2) so the OTP screen shows the right CTA and the router knows the entry intent. Implement the variant via a query param or a small client switch on the login page — no second route tree.

3.4 A2 — OTP screen + auto-advance

Per A2/B2, using the shared OTP code input from f0:

  • N-digit boxes (N from OtpRequestResult.code_length, default 4 per the wireframe), masked phone echo ("کد به شماره ۰۹۱۲•••۰۰۰۰ ارسال شد"), resend countdown "ارسال مجدد کد تا ۰۰:۴۵" driven by resend_after_seconds (a single timer, cleaned up on unmount — no leaked intervals).
  • CTA "تایید و ادامه" (customer) / "تایید و ورود" (nurse) → useVerifyOtp.
  • States: auto-advance (verify fires automatically when the last box is filled), wrong code (inline, clear boxes, keep focus), expired code (offer resend), max-attempts lockout (disable input + show the locked message + when it lifts), resend (re-calls useRequestOtp, restarts the countdown, disabled until the countdown hits 0).
  • On verify success the hook stores tokens + dispatches LOG_IN + invalidates /me; then the role router takes over (next).

3.5 The role router (after /me)

A small client component/hook (useRoleRouter or a RoleRouter boundary placed where the shell decides chrome) that consumes useMe() and routes:

  • loading (/me in flight) → the f0 AppLoading splash; never flash the wrong shell.
  • roles includes nurse → the nurse app home; if nurse_verification_status is not verified, land on B3 verification status (built in f5) — for now route to the nurse shell's home/placeholder and leave a clear pointer (f5 fills B3). A persistent "verification in progress" banner is f5's job; here just route correctly.
  • roles includes customer (and not nurse, or active_role === 'customer') → the customer/family app home (A5, built in f4 — route to the customer shell's home/placeholder now).
  • roles empty / no usable role → the SelectRole screen (3.6).
  • active_role (if the contract returns one) wins when a user has multiple roles; otherwise prefer the intended_role carried from login, else default customer.
  • admin → the admin shell (f15) — route correctly but don't build admin screens here.

Honour the middleware gate: it already redirects unauthenticated users to /login; the role router runs after auth and only decides which app. Don't duplicate the auth check in the router.

3.6 SelectRole screen (first use)

A minimal post-login screen for a brand-new user with no role: choose خانواده (customer) or پرستار (nurse) → call the role-selection mutation (POST /me/role, SelectRoleCommand per the digest; consume the contract's exact route/body) → invalidateQueries(authKeys.me()) → re-run the router. Admin is never selectable here (admin roles are internal-only). If intended_role from login is set, pre-select it. Keep this screen shared/simple; the full onboarding (A3 "who is care for", A4 add-patient, nurse profile bootstrap) is f2-b3 — link it, don't build it.

3.7 Seed AuthState with roles

Extend the auth context beyond { id, username }:

  • src/context/auth/types.ts — extend AuthState.currentUser to carry { id; phone; roles: RoleCode[]; active_role?: RoleCode } (mirror the Me essentials the shell needs for chrome) and keep isAuthenticated. Keep the reducer's LOG_IN{user?}/LOG_OUT actions; just widen the user payload.
  • The server-seeded initialState (root layout) should seed roles when it can derive them (it reads cookies/getServerAuthState); if roles aren't available server-side yet, seed isAuthenticated only and let useMe() hydrate roles client-side — don't put a second source of truth for roles. Document which it is in your report.
  • The f0 shells already read "a role" to pick chrome — now feed them the real roles/active_role.

3.8 i18n

All A1/A2/B1/B2/SelectRole/role-router strings as keys in the auth namespace (and common/nav for shared bits) in both messages/en.json and messages/fa.json, in sync, RTL-first. No label is ever derived from a role/status code in code — codes map to i18n keys. (DEFERRED) onboarding copy (A3/A4) belongs to f2's onboarding namespace.

4. Mocks & seams in this phase

This phase introduces no new external-service seam — OTP/SMS delivery is the backend's seam (ISmsSender, introduced in backend-phase-2; the frontend never sends SMS). The only frontend "mock" is the standard contract-gap fallback from operating-rules §6 and the f0 pattern:

  • If identity-auth.md / the b2 swagger snapshot isn't merged when you start, build a mock AuthClientApi behind the same services/auth seam (the f0 mock-clientApi pattern): requestOtp returns a fixed { resend_after_seconds: 45, code_length: 4, expires_in_seconds: 120 }; verifyOtp accepts a fixed dev code (e.g. 0000) and returns fake tokens
    • a Me you can toggle (customer / nurse-unverified / no-role) to exercise all three router branches; getMe returns the matching Me. Selection is by config (env/flag), never an if (mock) in a hook or screen.
  • Record it: append a row to reports/mocks-registry.md (seam = AuthClientApi mock + file, what's faked, the config key, exact swap steps = point the service at the real clientApi once the contract is live) and a follow-up in your phase report.
  • Any shape the contract doesn't cover (resend/lockout fields, Me role codes, active_role) → append a request to for-backend.md; do not edit backend files.

(Reuse the f0 mock-seam mechanism — don't invent a new one.)

5. Critical rules you must not get wrong

  • Phone is the only credential. No username/password anywhere after this phase; email is never a login key. Remove the stub login path completely (no dead code — it's a lint error).
  • No token in localStorage, ever. Tokens live only in cookies via the cookie manager (@/lib/cookies/client), non-httpOnly per the current design. Never document.cookie/localStorage for auth. Logout must actually clear both cookies and the server session (call logout).
  • Sessions are revocable; refresh rotates. Wire refresh so an expired access token is silently rotated (or confirm the fetch layer owns this and don't duplicate it). The server does reuse-detection; the client must send the current refresh token and replace both tokens on a successful refresh.
  • invalidateQueries(authKeys.me()) on login and logout. /me is the role router's input — a stale /me routes the wrong person to the wrong app. Never read roles from two sources.
  • Don't toast 401/403/5xx in hooks — the fetch layer already does. Hooks surface only domain 4xx (invalid phone, rate-limit/429, wrong/expired code, lockout) as inline UI states.
  • OTP states are explicit, not edges: sending, resend-cooldown, wrong code, expired code, max-attempts lockout, auto-advance. A single timer, cleaned up on unmount (no leaked intervals → no re-render storms).
  • The router never flashes the wrong shell. Show AppLoading while /me is in flight; only render a shell once the role is known. admin role is never self-assignable — SelectRole offers customer/nurse only.
  • RSC/client boundary: login screens, OTP, the router, and the cookie writes are client ('use client'); no next/headers/@/lib/cookies/server in them. The middleware already gates routes — don't re-implement the gate in a component.
  • Both locales, RTL-first, tokens for colour, MUI v9, MUI primitives stay MUI. Every string in en.json and fa.json; no label derived from a code.
  • Minimise re-renders: the countdown timer and per-box OTP state stay low (in the OTP component), not in a high context; useMe subscribers use select if they only need roles.

6. Definition of Done

The shared definition-of-done.md, plus:

  • services/auth is OTP-based: OtpRequest/OtpVerify/Tokens/Me(+roles) types, authKeys.me(), requestOtp/verifyOtp/refresh/logout/getMe apis, and the useRequestOtp/useVerifyOtp/ useMe/useLogout hooks — the username/password stub is removed, not left dangling.
  • A1 (customer login), A2 (OTP, auto-advance, resend countdown, lockout), and the B1/B2 nurse switch render per the wireframe via the f0 phone/OTP composites, RTL, both locales, branded.
  • The role router sends customer→family app, nurse→nurse app (B3 status if unverified), no-role→SelectRole, admin→admin shell; it shows AppLoading during /me and never flashes the wrong shell.
  • Tokens are stored only via the cookie manager; LOG_IN/LOG_OUT dispatched; authKeys.me() invalidated on login and logout; refresh wired (or fetch-layer ownership documented).
  • AuthState carries roles/active_role; the f0 shells pick chrome from the real role.
  • All strings in auth/common/nav in both en.json and fa.json, in sync.
  • npm run check green; npm run test:ci green (you touched/added shared composites and the router — add tests for the OTP state machine and the router branches).
  • client/CLAUDE.md updated: the OTP login flow, the role-router seam, and the widened AuthState noted in Project Structure / the auth notes; the now-stale username/password references corrected.

7. How to test (what a human can verify after this phase)

With the b2 backend running (or the mock seam configured), npm run dev:

  • Customer happy path: open /fa/login → enter a valid mobile → "دریافت کد تایید" → A2 shows the masked phone + a counting-down resend → enter the code (auto-advance fires verify) → tokens appear in cookies (DevTools → Application → Cookies; nothing in localStorage) → redirected to the customer app home. /me is in the Query cache (React Query Devtools).
  • Nurse switch: from A1 tap "پرستار هستید؟ ورود پرستاران ←" → B1 nurse copy/subtitle → request → verify ("تایید و ورود") → routed to the nurse app (B3 status placeholder if unverified).
  • No-role user: verify as a user whose /me has empty roles → lands on SelectRole → choose a role → re-routed into that app.
  • OTP edge states: wrong code → inline error + boxes clear; let the code expire → resend re-enabled by the countdown; trigger max attempts → input locks with a clear message.
  • Session: log out → both cookies cleared, /me invalidated, redirected to /login; let the access token expire and act → silent refresh rotates it (or you're sent to /login cleanly).
  • i18n/RTL: switch to en → all auth strings translate, layout stays correct; fa is RTL.
  • npm run check and npm run test:ci pass (OTP state-machine + router-branch tests included).

8. Hand off & document (close the phase)

  • Docs: update client/CLAUDE.md — the phone-OTP flow, the useRoleRouter seam, the widened AuthState (roles/active_role), and the new auth screens/route; fix the stale username/password references. If you add a route group/folder, update Project Structure in the same change. No product/ rule changes expected (you're implementing decided rules); if you discover drift, record it there.
  • Contract: consume dev/contracts/domains/identity-auth.md — types come from it, not guesses. Append every gap (resend/lockout fields, exact Me role codes, active_role, role-selection route/body) to dev/shared-working-context/frontend/requests/for-backend.md. Frontend produces no contract.
  • Handoff & report: append to dev/shared-working-context/frontend/STATUS.md; write reports/frontend-phase-1-report.md (what was built, what is now testable and exactly how, what's mocked behind the AuthClientApi seam
    • how f-next swaps it, the contract consumed + gaps filed, follow-ups: B3 banner is f5, A3/A4 onboarding is f2, admin screens are f15). Update reports/mocks-registry.md for the AuthClientApi mock if you used it.
  • Memory: save a project memory note for the phone-OTP decision, the role-router seam + branch logic, and the widened AuthState, with a MEMORY.md pointer — what a future agent can't cheaply re-derive.