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
/meafter 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, theservices/{domain}+ Query pattern, the cookie manager,AuthContext) · backend contractdev/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); theservices/{domain}+ TanStack Query reference pattern (akeys.tsfactory,apis/clientApi.tsoverclientFetch, one-hook-per-file, invalidate-on-mutation); the types-from-contract convention; the money/format util; theauth,common,navi18n namespaces seeded in bothen.json/fa.json. Read the f0 report (reports/frontend-phase-0-report.md) for exactly what shipped and how the mock-clientApiseam 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/client—setClientCookie/deleteClientCookie, withAUTH_ACCESS_COOKIE_OPTIONS≈15 min andAUTH_REFRESH_COOKIE_OPTIONS≈7 d, both non-httpOnly by the current design);AuthContext(useReducer[state, dispatch], actionsLOG_IN{user?}/LOG_OUT, server-seededinitialState);makeQueryClient(staleTime 30 s); the middleware auth gate (isTokenAliveonaccess_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 dispatchesLOG_IN;useLogoutdeletes both, dispatchesLOG_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/loginpath — replaced by the OTP request/verify pair. Remove the deadusername/passwordtypes and theloginapi/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.mdand../_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 theMe/roles enum. Do not guess shapes. Read it together with../../contracts/conventions/api-conventions.md(envelope,snake_caseroutes —[controller]/[action]soRequestOtp→.../request_otp; 400/401/403/409 meanings; the locale header) and../../contracts/conventions/money-and-types.md(enums cross the wire as stable string codes —male/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 exactMerole codes, the resend-cooldown / max-attempts fields), follow operating-rules §6: append a request todev/shared-working-context/frontend/requests/for-backend.mdand mock behind theservices/authseam meanwhile (§4 below). client/CLAUDE.md— RSC/client boundary, the cookie rules (auth state only through the cookie manager — neverdocument.cookie/localStorage), the services pattern, anti-patterns.- Invoke the
frontend-designerskill before any visual work — A1/A2/B1 are full branded screens (brand mark, teal#1d4a40primary CTA, RTL Persian, Vazirmatn/Mikhak). It is the palette/token/typography andApp*-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— replaceLoginDtowith: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 fromAuthTokens; 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 whatGET /mereturns 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— whateverrequest_otpreturns (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 hardcoded45/4.
keys.ts—authKeys.me()(replacescurrentUser()); keepauthKeys.all.apis/clientApi.ts—AuthClientApioverclientFetch:requestOtp(body: OtpRequest): Promise<OtpRequestResult>→POST .../auth/request_otpverifyOtp(body: OtpVerify): Promise<Tokens>→POST .../auth/verify_otprefresh(refreshToken: string): Promise<Tokens>→POST .../auth/refreshlogout(): Promise<void>→POST .../auth/logoutgetMe(): Promise<Me>→GET .../me(Use the exact snake_case routes from the contract; the examples follow the[controller]/[action]transform.) Remove the oldlogincall.
hooks/(one per file):useRequestOtp.ts—useMutationoverrequestOtp.onSuccesshands 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.ts—useMutationoververifyOtp.onSuccess: persist tokens via the cookie manager (setClientCookiewithAUTH_ACCESS_COOKIE_OPTIONS/AUTH_REFRESH_COOKIE_OPTIONS), dispatchLOG_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: calllogout,deleteClientCookieboth tokens, dispatchLOG_OUT,invalidateQueries(authKeys.me())(orqueryClient.clear()for auth keys), redirect to/${locale}/login.useMe.ts—useQuery(authKeys.me(), getMe)(replacesuseCurrentUser);enabledonly when authenticated; sensiblestaleTime. 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) — exposerefreshas 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; dropuseLogin/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 byresend_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 (
/mein flight) → the f0AppLoadingsplash; never flash the wrong shell. - roles includes
nurse→ the nurse app home; ifnurse_verification_statusis notverified, 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, oractive_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 theintended_rolecarried 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— extendAuthState.currentUserto carry{ id; phone; roles: RoleCode[]; active_role?: RoleCode }(mirror theMeessentials the shell needs for chrome) and keepisAuthenticated. Keep the reducer'sLOG_IN{user?}/LOG_OUTactions; just widen theuserpayload.- 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, seedisAuthenticatedonly and letuseMe()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 mockAuthClientApibehind the sameservices/authseam (the f0 mock-clientApipattern):requestOtpreturns a fixed{ resend_after_seconds: 45, code_length: 4, expires_in_seconds: 120 };verifyOtpaccepts a fixed dev code (e.g.0000) and returns fake tokens- a
Meyou can toggle (customer / nurse-unverified / no-role) to exercise all three router branches;getMereturns the matchingMe. Selection is by config (env/flag), never anif (mock)in a hook or screen.
- a
- Record it: append a row to
reports/mocks-registry.md(seam =AuthClientApimock + file, what's faked, the config key, exact swap steps = point the service at the realclientApionce the contract is live) and a follow-up in your phase report. - Any shape the contract doesn't cover (resend/lockout fields,
Merole codes,active_role) → append a request tofor-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
loginpath 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. Neverdocument.cookie/localStoragefor auth. Logout must actually clear both cookies and the server session (calllogout). - Sessions are revocable; refresh rotates. Wire
refreshso 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./meis the role router's input — a stale/meroutes 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
AppLoadingwhile/meis 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'); nonext/headers/@/lib/cookies/serverin 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.jsonandfa.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;
useMesubscribers useselectif they only needroles.
6. Definition of Done
The shared definition-of-done.md, plus:
services/authis OTP-based:OtpRequest/OtpVerify/Tokens/Me(+roles) types,authKeys.me(),requestOtp/verifyOtp/refresh/logout/getMeapis, and theuseRequestOtp/useVerifyOtp/useMe/useLogouthooks — 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
AppLoadingduring/meand never flashes the wrong shell. - Tokens are stored only via the cookie manager;
LOG_IN/LOG_OUTdispatched;authKeys.me()invalidated on login and logout; refresh wired (or fetch-layer ownership documented). AuthStatecarriesroles/active_role; the f0 shells pick chrome from the real role.- All strings in
auth/common/navin bothen.jsonandfa.json, in sync. npm run checkgreen;npm run test:cigreen (you touched/added shared composites and the router — add tests for the OTP state machine and the router branches).client/CLAUDE.mdupdated: the OTP login flow, the role-router seam, and the widenedAuthStatenoted 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./meis 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
/mehas emptyroles→ 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,
/meinvalidated, redirected to/login; let the access token expire and act → silent refresh rotates it (or you're sent to/logincleanly). - i18n/RTL: switch to
en→ all auth strings translate, layout stays correct;fais RTL. npm run checkandnpm run test:cipass (OTP state-machine + router-branch tests included).
8. Hand off & document (close the phase)
- Docs: update
client/CLAUDE.md— the phone-OTP flow, theuseRoleRouterseam, the widenedAuthState(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. Noproduct/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, exactMerole codes,active_role, role-selection route/body) todev/shared-working-context/frontend/requests/for-backend.md. Frontend produces no contract. - Handoff & report: append to
dev/shared-working-context/frontend/STATUS.md; writereports/frontend-phase-1-report.md(what was built, what is now testable and exactly how, what's mocked behind theAuthClientApiseam- 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.mdfor theAuthClientApimock if you used it.
- 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
- Memory: save a
projectmemory note for the phone-OTP decision, the role-router seam + branch logic, and the widenedAuthState, with aMEMORY.mdpointer — what a future agent can't cheaply re-derive.