287 lines
21 KiB
Markdown
287 lines
21 KiB
Markdown
# UI Phase 3 — Auth & first-run
|
||
|
||
> **Mission:** the login screen is the product's only front door — the middleware redirects every
|
||
> unauthenticated hit to `/login` — and today it is a bare starter card fronted by a cartoon Twemoji
|
||
> pencil, with no consent line, no trust content, and an OTP flow that never offers SMS autofill in an
|
||
> OTP-first market. This phase rebuilds login → OTP → select-role → onboarding as a branded,
|
||
> trust-forward, Persian-native entry: a login hero that states what Balinyaar verifies and escrows,
|
||
> OTP ergonomics at Snapp/Digikala parity (WebOTP + `one-time-code`), terms/privacy consent, an
|
||
> illustrated role fork, a chrome-free focused onboarding journey, and a `returnUrl` so deep links
|
||
> survive login.
|
||
>
|
||
> **Track:** frontend · **Depends on:** [Phases 0–2](ui-phase-2-shells-and-navigation.md) ·
|
||
> **Unlocks:** the first impression finally sells trust
|
||
> **Before you start, read [../../phases/_shared/agent-operating-rules.md](../../phases/_shared/agent-operating-rules.md)
|
||
> and invoke the frontend-designer skill — both are mandatory.**
|
||
|
||
## 1. Context — where this sits
|
||
|
||
Balinyaar is a trust-first home-nursing marketplace; there is no public landing page (that is
|
||
[Phase 13](ui-phase-13-public-front-door.md)), so **login IS the product's first impression** — and in a
|
||
phone-OTP market, login is also signup. Mechanically the flow is strong (see the audit's keep-list); the
|
||
problems are presentation, ergonomics, and a handful of genuine defects, all verified in code:
|
||
|
||
1. **The login card carries zero trust evidence.** `client/src/components/auth/AuthCard.tsx:12-31` is an
|
||
outlined `Paper` with a `BrandMark` and nothing else — no illustration, no mention of nurse
|
||
verification or escrowed payment, on a product whose entire pitch is trust.
|
||
2. **No OTP autofill.** A repo-wide grep for `autoComplete`, `one-time-code`, and `OTPCredential` over
|
||
`client/src` returns **zero hits** — `OtpInput.tsx:121-128` sets `inputMode`/`maxLength`/`aria-label`
|
||
but never `autoComplete`, and there is no WebOTP wiring. Every login feels worse than the apps users
|
||
compare against.
|
||
3. **No consent, no terms, no privacy.** `PhoneStep.tsx:56-102` renders title/field/CTA/role-switch only;
|
||
`PUBLIC_PATHS = [ROUTES.LOGIN]` (`client/src/constants/routes.ts:169`) — no `/terms` or `/privacy`
|
||
route exists anywhere. Entering a phone number creates an account with no disclosed terms.
|
||
4. **The 429 state is the least visible one.** `PhoneStep.tsx:76` renders `rate_limited` as grey
|
||
`helperText` while `error={invalid}` stays false — the one state where the user is blocked gets no
|
||
error styling.
|
||
5. **Bidi + digit defects on the OTP screen.** `OtpStep.tsx:100` interpolates the masked phone
|
||
(`0912•••1234`) into an RTL sentence with no bidi isolation (the classic digits-around-neutrals
|
||
reversal); `formatMmSs` (`OtpStep.tsx:24-28`) renders Latin `01:23` in a Persian sentence; backspace on
|
||
an empty box (`OtpInput.tsx:89-93`) moves focus but never clears the previous digit.
|
||
6. **Select-role is semantically confusing.** `SelectRole.tsx:21-24` gives the nurse option a **house**
|
||
icon (`{ role: 'nurse', icon: 'home' }`); selection feedback is border-color only
|
||
(`SelectRole.tsx:74-83`) — color is the sole signal.
|
||
7. **Onboarding renders inside the full app shell.** `(customer)/onboarding/page.tsx` sits in the
|
||
`(customer)` route group, so the A3→A4 wizard shows the 5-tab BottomBar, support icon, and bell — a
|
||
user mid-setup can tab away. All four relation options share the same `'account'` icon
|
||
(`onboarding/page.tsx:31`), and there is no welcome moment.
|
||
8. **Deep links die at login.** `middleware.ts:29` redirects to `/{locale}/login` discarding the attempted
|
||
path — an SMS booking link or shared nurse profile dumps the user on their role home after login.
|
||
|
||
**What already exists (do not rebuild):**
|
||
|
||
- **The real brand mark and de-startered theme** — [Phase 0](ui-phase-0-design-language.md) owns
|
||
`theme/`, the logotype replacing the Twemoji pencil, the icon registry, and `AppButton` (including
|
||
killing its `margin: 1` starter default). Consume the mark via `BrandMark`/`ICONS.logo`; do not design
|
||
a second one.
|
||
- **Shared primitives** — [Phase 1](ui-phase-1-primitives-and-states.md) owns `formatNumber` (the
|
||
locale-digit helper), `StepperHeader`, and the state views. Consume them.
|
||
- **The de-startered public shell** — [Phase 2](ui-phase-2-shells-and-navigation.md) owns `layout/`,
|
||
including retiring `PublicLayout`'s "Unauthorized - Balinyaar" chrome. This phase styles the **content**
|
||
of the auth routes, not the shell.
|
||
- **The auth machinery** — `LoginFlow`/`PhoneStep`/`OtpStep`/`RoleRouter`/`AuthSplash`/`RoleGuard`/
|
||
`SelectRole`/`useCountdown`, `services/auth` (real backend, `USE_AUTH_MOCK = false`), the middleware
|
||
auth gate, and the resolved-vs-pending role hydration from refinement phase 2. This phase redesigns
|
||
surfaces and adds ergonomics; the session/cookie/refresh/hydration logic is untouched.
|
||
|
||
## 2. Required reading (do this first)
|
||
|
||
- [audit/auth-first-run.md](audit/auth-first-run.md) — the full evidence, opportunities, and the
|
||
**keep-list** this phase must not regress.
|
||
- The auth surface: `client/src/components/auth/` (`LoginFlow.tsx`, `AuthCard.tsx`, `BrandMark.tsx`,
|
||
`PhoneStep.tsx`, `OtpStep.tsx`, `SelectRole.tsx`, `RoleRouter.tsx`, `constants.ts`),
|
||
`client/src/components/OtpInput/OtpInput.tsx`, `client/src/components/PhoneNumberField/`,
|
||
`client/src/services/auth/routing.ts` (`resolveRoleDestination` — pure, unit-tested).
|
||
- First-run: `client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx`,
|
||
`client/src/components/RelationSelect/`, `client/src/components/StepperHeader/`, and the home redirect
|
||
gate at `(customer)/page.tsx:60-68`.
|
||
- Routing: `client/middleware.ts`, `client/src/constants/routes.ts` (`ROUTES`, `PUBLIC_PATHS`).
|
||
- The design contract: [.claude/skills/frontend-designer/SKILL.md](../../../.claude/skills/frontend-designer/SKILL.md)
|
||
(invoke the skill) + `client/CLAUDE.md` Golden rules and the auth-cookies section (what you must not touch).
|
||
- Product truth for the trust copy: [product/overview/platform-summary.md](../../../product/overview/platform-summary.md)
|
||
(the four ground truths), [product/business/02-nurse-verification.md](../../../product/business/02-nurse-verification.md)
|
||
and [product/business/08-payments-and-escrow.md](../../../product/business/08-payments-and-escrow.md) —
|
||
the trust bullets must state facts the platform actually implements, nothing aspirational.
|
||
|
||
## 3. Scope — build this
|
||
|
||
### 3.1 Login hero — a branded, trust-forward front door
|
||
|
||
Redesign `LoginFlow`/`AuthCard` as a branded moment on the cream backdrop (`background.default` /
|
||
`--bal-bg-default`): the Phase-0 logotype, the tagline «مراقبت مطمئن در خانه», and **2–3 trust bullets**
|
||
beneath the card stating what the product actually does — «پرستاران تاییدشده» (license + identity
|
||
verification), «پرداخت امن امانی» (escrow until confirmed check-out), «پشتیبانی» — each with a registry
|
||
icon. Add a calm illustration treatment: **composed CSS/SVG shapes in brand tokens are fine; no stock
|
||
photos, no raster illustrations.** Keep the card readable at 320px and let the hero breathe on desktop.
|
||
Keep the single login stack parameterized by `intendedRole` (`LoginFlow.tsx:22-24`, seeded from
|
||
`?role=nurse`) and the A1/B1 switch link — the nurse variant may re-tint copy, never fork the tree.
|
||
Keep the `' '` helperText placeholder that prevents layout jump.
|
||
|
||
### 3.2 OTP ergonomics — autofill, correction, and Persian digits
|
||
|
||
- **`autoComplete="one-time-code"`** on the `OtpInput` digit inputs (`slotProps.htmlInput`), so
|
||
iOS/Android offer the SMS code as a keyboard suggestion.
|
||
- **WebOTP:** feature-detect `'OTPCredential' in window`, call
|
||
`navigator.credentials.get({ otp: { transport: ['sms'] }, signal })` with an `AbortController`
|
||
(abort on unmount and on manual completion), distribute the received code through the existing
|
||
`onChange`/`onComplete` path so the existing auto-verify fires. Unsupported browsers silently fall back
|
||
to manual entry — no errors, no UI difference.
|
||
- **File REQ-039** (see §4): WebOTP and native suggestion only work when the SMS **ends with the
|
||
origin-bound line `@<domain> #<code>`** — the SMS template is server/Kavenegar-side.
|
||
- **Backspace-on-empty clears the previous box:** in `OtpInput.tsx:89-93`, when backspace is pressed on
|
||
an empty box, clear `chars[index-1]` *and* move focus — one keypress per digit to erase.
|
||
- **Persian-digit countdown:** replace `formatMmSs`'s raw `String`/`padStart` (`OtpStep.tsx:24-28`) with
|
||
the Phase-1 `formatNumber` helper so `/fa` renders ۰۱:۲۳ and `/en` renders 01:23. Keep the clock an
|
||
LTR run inside the sentence.
|
||
- **429 as a real error:** in `PhoneStep.tsx:76`, the rate-limited state must set `error` on the field
|
||
(e.g. `error={invalid || rateLimited}`) so the message renders in error styling, not grey helperText.
|
||
- **Bidi-isolate the masked phone echo:** wrap `maskIranMobile(phone)` in `OtpStep.tsx:100` in a
|
||
`<bdi>`/`dir="ltr"` inline element (via `t.rich` or splitting the sentence) so `0912•••1234` never
|
||
reorders inside the RTL sentence. Keep the masking itself.
|
||
|
||
### 3.3 Consent + legal pages
|
||
|
||
- Add the standard implicit-consent line under the login CTA in `PhoneStep`:
|
||
«با ورود، [شرایط استفاده] و [حریم خصوصی] را میپذیرید» — the bracketed terms are `AppLink`s.
|
||
- Create `/terms` and `/privacy` under `(public-routes)` (`terms/page.tsx`, `privacy/page.tsx`):
|
||
readable, Typography-composed legal skeletons in **both locales**, clearly flagged in the phase report
|
||
and a code comment as **draft copy requiring human/legal review before launch**. Add
|
||
`ROUTES.TERMS`/`ROUTES.PRIVACY` and append both to `PUBLIC_PATHS` (`routes.ts:169`) so the middleware
|
||
lets them through logged-out.
|
||
|
||
### 3.4 Select-role — an illustrated two-card fork
|
||
|
||
Replace the generic icons (`SelectRole.tsx:21-24` — `account` for customer, a **house** for nurse) with
|
||
an illustrated two-card fork: *family receiving care* vs *nurse professional*, using the same CSS/SVG
|
||
illustration treatment as 3.1. Selected state = `--bal-primary-soft` fill **plus a check glyph** —
|
||
color must not be the only signal. Add a reassurance line that the other role can be added later
|
||
(dual-role sessions are already supported). **Keep the radio a11y semantics exactly**: `role="radio"`,
|
||
`aria-checked`, `tabIndex`, Enter/Space handlers (`SelectRole.tsx:64-72`). Admin is never offered here.
|
||
If a check/role icon is missing from the registry, extend `AppIcon/config.ts` minimally and note it in
|
||
the report (Phase 0 owns the file; later phases extend, never fork).
|
||
|
||
### 3.5 Onboarding as a focused journey
|
||
|
||
- **Move the A3→A4 wizard out of the customer shell chrome.** Today `onboarding/page.tsx` renders inside
|
||
`CustomerLayout` (5-tab BottomBar + bell). Relocate it to a sibling route group, e.g.
|
||
`(private-routes)/(customer-focused)/onboarding/`, whose layout keeps **`RoleGuard(expected=customer)`**
|
||
(non-negotiable) but renders a chrome-free focused shell in the spirit of `AuthCard` — logo, progress,
|
||
content, nothing to tab away to. Route groups don't change the URL, so `/onboarding`, the
|
||
`ROUTES.ONBOARDING` constant, and the home redirect gate (`(customer)/page.tsx:63`) keep working
|
||
untouched.
|
||
- **A one-screen welcome moment** before the relation step: «خوش آمدید — بگویید مراقبت برای چه کسی
|
||
است؟»-style framing with the brand mark, one CTA into the wizard.
|
||
- **Distinct relation iconography:** the four options (`onboarding/page.tsx:31` — parent/spouse/child/
|
||
self, currently all `'account'`) each get a distinct registry icon so the first product interaction
|
||
isn't four identical cards.
|
||
- **Progress via `StepperHeader`** (welcome doesn't count as a step; relation → patient does).
|
||
- **Keep** the relation pre-shaping the patient form (relation field hidden on A4) and the settled-list
|
||
redirect gate exactly as they are.
|
||
- (DEFERRED → [Phase 4](ui-phase-4-customer-storefront.md) + product decision) a «بعداً تکمیل میکنم»
|
||
skip path with a browse-capable home: it changes the zero-patient home gate, which is storefront
|
||
territory. The forced redirect stays as-is in this phase.
|
||
- (DEFERRED → [Phase 12](ui-phase-12-copy-motion-and-polish.md)) OTP delivery fallback escalation
|
||
(voice-call OTP / support link after failed resend cycles) — needs a second delivery channel that
|
||
doesn't exist server-side.
|
||
|
||
### 3.6 returnUrl — deep links survive login
|
||
|
||
- `middleware.ts:29`: when redirecting an unauthenticated hit to login, append the attempted
|
||
locale-stripped path + query as `?next=<encoded>` — e.g.
|
||
`/fa/bookings/42` → `/fa/login?next=%2Fbookings%2F42`.
|
||
- Login honors it: `LoginFlow` reads `next` and passes it to `RoleRouter`; add a **pure, unit-tested**
|
||
helper beside `resolveRoleDestination` in `services/auth/routing.ts` (e.g.
|
||
`resolvePostLoginDestination(me, intendedRole, next)`) that returns `next` only when it is (a) a
|
||
same-origin **relative** path — starts with `/`, not `//`, no scheme — and (b) permitted by the
|
||
resolved roles (a customer's `next=/nurse/...` falls through). Anything else falls back to
|
||
`resolveRoleDestination` — which stays the single "which app" source of truth.
|
||
- Touch `middleware.ts` **surgically**: the token check, locale detection, header propagation, and
|
||
next-intl handling are load-bearing; the auth gate semantics must not change.
|
||
|
||
### 3.7 PhoneNumberField autofill
|
||
|
||
Add `autoComplete="tel"` to the `PhoneNumberField` input so browsers/keyboards offer the user's own
|
||
number. Keep the LTR forcing and digit normalization untouched.
|
||
|
||
## 4. Mocks & seams in this phase
|
||
|
||
**None introduced.** Auth is real (`USE_AUTH_MOCK = false` since refinement phase 4); every deliverable
|
||
here is presentation, ergonomics, or client routing. Do not flip any mock flag.
|
||
|
||
**REQ posture:** backend gaps become REQ entries appended to
|
||
[../../shared-working-context/frontend/requests/for-backend.md](../../shared-working-context/frontend/requests/for-backend.md)
|
||
— REQ-001…038 are taken; number onward. This phase files exactly one:
|
||
|
||
- **REQ-039 — WebOTP-conformant OTP SMS template.** The OTP SMS must end with the origin-bound last line
|
||
`@<domain> #<code>` (the [WebOTP](https://web.dev/articles/web-otp) / origin-bound one-time-code
|
||
convention) so Chrome's WebOTP API and the iOS/Android keyboard suggestion can auto-read the code. This
|
||
is a server/SMS-template change (the Kavenegar adapter from refinement phase 8); zero API-shape impact.
|
||
The client work in 3.2 ships regardless and degrades to manual entry until this lands.
|
||
|
||
## 5. Critical rules you must not get wrong
|
||
|
||
- **Do not touch the auth/session machinery.** `services/auth` session logic, `persistAuthTokens`/
|
||
`clearAuthTokens`, the fetch-layer silent refresh, `useLogout`, cookie handling, and the
|
||
`RoleGuard`/`useRoleHydration` resolved-vs-pending behavior were hardened in refinement phases 2 and 5.
|
||
This phase restyles screens and adds a routing helper — nothing else.
|
||
- **The no-wrong-shell-flash behavior stays.** `RoleRouter` + branded `AuthSplash` while `/me` is in
|
||
flight, `AuthAccountError` on failure, `RoleGuard` redirect-with-toast on mismatch. The relocated
|
||
onboarding layout must keep `RoleGuard(expected=customer)`.
|
||
- **OTP mechanics that already work stay:** paste distribution across boxes, Persian/Arabic→ASCII digit
|
||
normalization, `dir="ltr"` on the OTP group and phone field, auto-verify on the 5th digit, the
|
||
server-driven resend cooldown (`resendAvailableInSeconds` seeds `useCountdown`), resend staying
|
||
available during lockout as the escape hatch, and the distinct wrong/expired/locked/429 states.
|
||
- **`next` must never become an open redirect.** Only same-origin relative paths, validated in the pure
|
||
helper (with unit tests covering `//evil.com`, `https:` schemes, and role-forbidden paths).
|
||
- **One login stack.** `intendedRole` parameterizes; it never forks customer/nurse login trees.
|
||
- **Admin roles are never offered** in SelectRole; the middleware stays a UX gate, not a security boundary.
|
||
- **Design-contract non-negotiables:** every new string in **both** `messages/en.json` and
|
||
`messages/fa.json`; tokens/palette keys, never hexes; RTL-safe logical props (verify `/fa` first);
|
||
both color schemes; MUI v9 API only; illustrations are CSS/SVG in tokens — no stock photos; shared
|
||
components (`OtpInput`, `PhoneNumberField`, `RelationSelect`, anything new in `components/`) keep/get
|
||
co-located `.test.tsx`; `clientFetch`/cookie rules untouched.
|
||
|
||
## 6. Definition of Done
|
||
|
||
On top of the shared [definition-of-done.md](../../phases/_shared/definition-of-done.md):
|
||
|
||
- [ ] `npm run check` green; `npm run test:ci` green for every touched shared component
|
||
(`OtpInput`, `PhoneNumberField`, `RelationSelect`, the new routing helper's unit tests, and any
|
||
new shared component); `en.json`/`fa.json` in sync.
|
||
- [ ] Visual verification on all four axes — `/fa` + `/en` × light + dark — and mobile (360px) +
|
||
desktop for login, OTP, select-role, terms/privacy, and onboarding.
|
||
- [ ] The login screen shows the brand mark, tagline, trust bullets, and consent line; no starter
|
||
chrome, no pencil, no hard-coded English anywhere in the flow.
|
||
- [ ] OTP inputs carry `autoComplete="one-time-code"`; WebOTP auto-fills on a supporting
|
||
Android/Chrome device (or is verified degrading silently elsewhere); backspace clears the
|
||
previous digit in one keypress.
|
||
- [ ] The resend countdown renders ۰۱:۲۳ on `/fa` and 01:23 on `/en`; the masked phone echo renders
|
||
un-scrambled inside the Persian sentence.
|
||
- [ ] A 429 on request-OTP renders with error styling on the field, not grey helper text.
|
||
- [ ] `/terms` and `/privacy` load logged-out in both locales; the consent line links to them.
|
||
- [ ] Select-role shows the illustrated fork; the selected card has a soft fill + check glyph; keyboard
|
||
selection (Tab, Enter/Space) still works.
|
||
- [ ] `/onboarding` renders chrome-free (no BottomBar/bell), opens with the welcome screen, shows four
|
||
visually distinct relation cards, and still creates the patient and lands on Home.
|
||
- [ ] Visiting `/fa/bookings` logged-out → login → after OTP, landing back on `/fa/bookings`;
|
||
`?next=//evil.com` and `?next=https://evil.com` are ignored (role-home fallback).
|
||
- [ ] REQ-039 appended to the tracker.
|
||
|
||
## 7. How to test (what a human can verify after this phase)
|
||
|
||
1. Open `/fa` logged-out → redirected to `/fa/login`. The screen is a branded hero: logotype, tagline,
|
||
trust bullets, consent line with working «شرایط استفاده»/«حریم خصوصی» links. Toggle dark mode and
|
||
`/en` — everything tracks.
|
||
2. Enter a phone (demo seeds `0912000000x`), request the code. On the OTP screen the masked number reads
|
||
`0912•••…` correctly inside the Persian sentence; the countdown ticks in Persian digits on `/fa`.
|
||
3. Type a wrong digit mid-code, press backspace twice — each press erases one digit. Paste a 5-digit
|
||
code — boxes fill and verify fires automatically (unchanged).
|
||
4. Spam request-OTP until a 429 → the message renders in error styling on the field.
|
||
5. On an Android/Chrome device with a WebOTP-formatted SMS (post REQ-039): the code fills without
|
||
typing. On desktop Firefox/Safari: no errors in console; manual entry works.
|
||
6. Log in with a fresh phone → `/select-role` shows two illustrated cards; select the nurse card —
|
||
soft fill + check glyph appear; the reassurance line about adding the other role later is present;
|
||
arrow/Enter keyboard selection works.
|
||
7. As a fresh customer with zero patients → land on `/onboarding`: welcome screen → relation step with
|
||
four distinct icons → patient form (relation pre-filled and hidden) → save → Home. During the wizard
|
||
there is no bottom nav or bell to tab away to.
|
||
8. Logged-out, open `/fa/bookings` → login carries `?next=%2Fbookings` → after OTP you land on
|
||
`/fa/bookings`. Repeat with `?next=https://evil.com` manually — you land on your role home.
|
||
9. Sanity: a seeded nurse still lands on `/nurse`, an admin on `/admin` (RoleRouter untouched); `?role=nurse`
|
||
still pre-selects the nurse copy and carries into select-role.
|
||
|
||
## 8. Hand off & document (close the phase)
|
||
|
||
- Update `client/CLAUDE.md` **Project Structure**: the new `(public-routes)` `terms`/`privacy` pages, the
|
||
relocated onboarding route group and its focused layout, any new/renamed auth components, and the new
|
||
routing helper — in the same change.
|
||
- Append **REQ-039** to
|
||
[for-backend.md](../../shared-working-context/frontend/requests/for-backend.md) as specified in §4.
|
||
- Write the frontend report at `dev/shared-working-context/reports/ui-phase-3-report.md`: what shipped
|
||
per scope item, the REQ filed, the **draft-legal-copy flag** for `/terms`/`/privacy` (human/legal
|
||
review required before launch), any icon-registry additions made under the Phase-0 ownership rule, and
|
||
the four-axes verification evidence.
|
||
- Save a memory note per operating-rules §8: auth/first-run redesigned (login hero + consent, WebOTP +
|
||
OTP ergonomics, illustrated select-role, focused onboarding, validated `returnUrl`), REQ-039 pending
|
||
server-side SMS template, skip-onboarding deliberately deferred to Phase 4.
|