# Auth & first-run experience (login → OTP → role routing → select-role → customer onboarding)

## Current state

Login lives at client/src/app/[locale]/(public-routes)/login/page.tsx, which renders LoginFlow (client/src/components/auth/LoginFlow.tsx): a two-step phone-OTP machine (PhoneStep → OtpStep) inside AuthCard — a 420px outlined Paper with a BrandMark lockup (icon + wordmark + tagline "مراقبت مطمئن در خانه"). One login stack serves both actors, parameterized by ?role=nurse; after verify, RoleRouter resolves /me behind a branded AuthSplash and routes to the family app, nurse app, admin console, or /select-role for role-less users. The middleware (client/middleware.ts) redirects every unauthenticated hit to /{locale}/login — the login screen is literally the product's front door; there is no public landing page. The whole thing sits inside PublicLayout → TopBarAndSideBarLayout, the untouched starter dashboard shell: a fixed AppBar titled "Unauthorized - Balinyaar", a logo icon-button that opens an empty Drawer, and BottomBar plumbing with zero items.

Mechanically the flow is strong: PhoneNumberField normalizes Persian/Arabic digits, caps at 11, forces LTR entry; OtpInput is a 5-box group with auto-advance, backspace-to-previous, paste distribution, digit normalization, and dir="ltr"; the resend countdown is seeded from the server's resendAvailableInSeconds via a leak-free useCountdown; wrong-code, expired, lockout (with resend as the escape hatch) and 429 states are all explicitly handled. Visually, however, it is a default-MUI form: no illustration, no brand surface, no trust content, and the "logo" (AppIcon icon="logo") resolves to the starter's PencilIcon — a hard-coded multicolor Twemoji pencil whose path fills ignore the passed var(--bal-primary).

select-role (client/src/components/auth/SelectRole.tsx) is a clean radio-card picker (accessible role="radio"/aria-checked) with generic filled MUI icons (nurse = a house icon). Customer onboarding (client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx) is a 2-step wizard (relation → patient form) using a default MUI Stepper (StepperHeader) and RelationSelect radio cards where every option shares the same 'account' icon. It renders inside the full CustomerLayout (5-tab bottom nav, support icon, notification bell), and CustomerHomePage force-redirects any customer with zero patients into it — there is no skip path and no welcome moment. Colors come from a well-built token system (client/src/theme/tokens.css) with a complete dark scheme, and the Mikhak Persian typeface loads only on fa routes.

## Problems (16)

- **[high]** `client/src/components/common/AppIcon/config.ts` — The brand 'logo' used on every auth screen (BrandMark, TopBar logo button) is the starter kit's Twemoji pencil. Its SVG paths carry hard-coded fills (#EA596E red, #FFCC4D yellow, #D99E82, #CCD6DD), so BrandMark's color="var(--bal-primary)" is silently ignored — the first thing a family sees on a healthcare-trust product is a cartoon pencil, identical in light and dark mode.
  - evidence: config.ts line 115 `logo: PencilIcon`; client/src/components/common/AppIcon/icons/PencilIcon.tsx lines 8-24 hard-coded `fill="#EA596E"` etc.; client/src/components/auth/BrandMark.tsx line 21 passes `color="var(--bal-primary)"` which cannot override path-level fills
- **[high]** `client/src/layout/PublicLayout.tsx` — The login screen — the product's only front door (middleware redirects all unauthenticated traffic here) — is wrapped in the starter dashboard shell: a fixed AppBar whose title is the hard-coded English string 'Unauthorized - Balinyaar' shown untranslated on the fa default locale, a pencil-logo IconButton that opens a completely empty sidebar Drawer (SIDE_BAR_ITEMS = []), and dead BottomBar plumbing (BOTTOM_BAR_ITEMS = []).
  - evidence: line 9 `const TITLE_PUBLIC = 'Unauthorized - Balinyaar'`; lines 14/19 empty nav arrays; TopBarAndSideBarLayout.tsx line 71 hard-coded tooltip `'Open Sidebar'`
- **[high]** `client/src/components/OtpInput/OtpInput.tsx` — No autoComplete="one-time-code" on the inputs and no WebOTP integration, so iOS/Android never offer the SMS code as a keyboard suggestion and Chrome cannot auto-read it. Automatic OTP fill is table-stakes in the Iranian market (Snapp/Digikala/Tapsi all auto-fill); its absence makes every login feel worse than the apps users compare against.
  - evidence: slotProps.htmlInput (lines 121-128) sets inputMode/maxLength/aria-label but no autoComplete; repo-wide grep for 'one-time-code'/'OTPCredential' finds nothing
- **[high]** `client/src/components/auth/PhoneStep.tsx` — No terms-of-service / privacy consent anywhere in the login flow. In a phone-OTP market login IS signup — entering a phone number creates an account — yet there is no 'با ورود، شرایط استفاده و حریم خصوصی را می‌پذیرید' line and no terms/privacy routes exist in the app. Legal exposure and a missing trust cue on a product whose entire pitch is trust.
  - evidence: PhoneStep renders only title/subtitle/field/CTA/role-switch (lines 56-102); grep for شرایط/حریم/terms/privacy in client/messages/fa.json finds no auth-namespace strings and no terms page exists under (public-routes)
- **[medium]** `client/src/components/auth/AuthCard.tsx` — The login card is a bare outlined Paper with zero brand or trust presence: no illustration, no teal/cream hero treatment, no mention of nurse verification, licensed-nurse vetting, or escrowed payment. For a trust-first healthcare marketplace with no landing page, the first impression carries no evidence of trustworthiness at all — it reads as a generic admin-template form.
  - evidence: lines 13-30: Stack + Paper elevation={0} borderColor:'divider' — the entire visual identity of the screen
- **[medium]** `client/src/components/auth/OtpStep.tsx` — The masked phone echo is interpolated into an RTL Persian sentence with no bidi isolation. '0912•••1234' is two European-number runs separated by neutral bullets; the Unicode bidi algorithm can reorder the segments in an RTL paragraph (the classic digits-around-neutrals reversal), rendering the number scrambled for exactly the string that tells users where their code went.
  - evidence: line 100 `{t('otp_sent_to', { phone: maskIranMobile(phone) })}` — no <bdi>, dir="ltr" span, or LRM wrapping; fa.json line 636 embeds {phone} mid-sentence
- **[medium]** `client/src/components/auth/PhoneStep.tsx` — The 429 rate-limit message renders as helperText while error={invalid} is false, so it appears in low-contrast grey secondary text with no error styling on the field — the one state where the user is blocked and most needs to notice the message is the least visible one.
  - evidence: line 75-76 `error={invalid} helperText={invalid ? t('phone_invalid') : rateLimited ? t('rate_limited') : ' '}` — rateLimited never sets error
- **[medium]** `client/src/components/auth/SelectRole.tsx` — The first decision a new user makes is presented with generic filled MUI icons: nurse = a Home (house) icon, customer = AccountCircle. A house for 'I am a nurse' is semantically confusing, and selection feedback is only a border-color change — no background tint, no check indicator, no warmth on what should be a welcoming moment.
  - evidence: lines 21-24 `{ role: 'customer', icon: 'account' }, { role: 'nurse', icon: 'home' }`; lines 74-83 selected state = borderColor only
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx` — First-run onboarding renders inside the full CustomerLayout app shell — 5-tab bottom nav, support icon, notification bell — so a user who hasn't finished setup can tab away mid-wizard, and there is no focused 'welcome' framing. Combined with the home page force-redirecting zero-patient customers here, families cannot browse or search nurses at all until they register a patient, with no 'skip for now' affordance.
  - evidence: onboarding sits in the (customer) route group whose layout.tsx wraps children in CustomerLayout; (customer)/page.tsx line 63 `if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`)`
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx` — All four relation options (پدر/مادر، همسر، فرزند، خودم) are given the identical 'account' icon, producing four visually indistinguishable cards in the very first product interaction after signup.
  - evidence: line 31 `RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`), icon: 'account' }))`
- **[low]** `client/src/components/auth/OtpStep.tsx` — The resend countdown renders Latin digits ('01:23') inside a Persian sentence; Persian-market apps localize timers to Persian numerals (۰۱:۲۳). formatMmSs uses raw String/padStart with no locale-aware digit formatting.
  - evidence: lines 24-28 `formatMmSs` + line 140 `{t('resend_in', { time: formatMmSs(countdown.seconds) })}`
- **[low]** `client/src/components/common/AppButton/AppButton.tsx` — Starter-kit AppButton defaults every button to margin: theme.spacing(1), forcing every auth/onboarding call site to pass sx={{ m: 0 }} to undo it — spacing becomes inconsistent the moment anyone forgets, and the workaround is repeated on at least four auth CTAs.
  - evidence: lines 9-11 `DEFAULT_SX_VALUES = { margin: 1 }`; countered by sx={{ m: 0 }} in PhoneStep.tsx:88, OtpStep.tsx:132, SelectRole.tsx:106, onboarding/page.tsx:70
- **[low]** `client/middleware.ts` — The auth redirect discards the original destination — no returnUrl/next param is carried to /login — so any deep link (a shared nurse profile, a booking detail from an SMS) dumps the user on their role home after login instead of where they were going.
  - evidence: line 29 `NextResponse.redirect(new URL(`/${locale}${ROUTES.LOGIN}`, request.url))` with no query param for the attempted pathname
- **[low]** `client/src/components/OtpInput/OtpInput.tsx` — Backspace on an empty box only moves focus to the previous box without clearing it, so deleting a mistyped code takes two key presses per digit — minor friction on the highest-frequency correction gesture.
  - evidence: lines 89-93: `if (event.key === 'Backspace' && !chars[index]) focusBox(index - 1)` — never clears chars[index-1]
- **[low]** `client/src/layout/TopBarAndSideBarLayout.tsx` — Persistent-sidebar offset uses physical paddingLeft/paddingRight keyed on anchor strings 'left'/'right' rather than logical properties — an RTL hazard pattern in the shell that hosts the public/auth routes (dormant on login because the variant is temporary, but live starter debt in the shared chrome).
  - evidence: lines 53-60 `paddingLeft: … anchor?.includes('left') ? SIDE_BAR_WIDTH : undefined` (and mirrored paddingRight)
- **[low]** `client/src/layout/components/SideBar.tsx` — Hard-coded English strings in chrome reachable from the auth shell: the logout tooltip 'Logout Current User' and the empty drawer that opens from the login screen's pencil button containing only a dark-mode switch — untranslated, purposeless starter UI on the fa default locale.
  - evidence: line 76 `title="Logout Current User"`; PublicLayout passes items=[] so SideBarNavList renders nothing

## Opportunities (10)

- **Rebrand the login as a trust-forward hero screen** (impact: high, effort: medium) — Since login is the product's only front door, redesign it as a branded moment: cream (--bal-bg-default) backdrop, a real Balinyaar logotype, a warm nurse-and-family illustration, and 2-3 trust bullets under the card (پرستاران دارای پروانه نظام پرستاری و احراز هویت‌شده، پرداخت امن نزد بالین‌یار تا پایان خدمت، پشتیبانی) — the same escrow/verification facts the product already implements. Drop PublicLayout's dashboard chrome entirely for auth routes (a minimal logo-only header is enough).
- **WebOTP + one-time-code autofill with auto-submit** (impact: high, effort: small) — Add autoComplete="one-time-code" to the OTP inputs, wire navigator.credentials.get({otp}) (WebOTP) with an AbortController fallback, and format the SMS with the @origin #code convention server-side. Combined with the existing onComplete auto-verify, most users would never type the code — the single biggest perceived-quality jump available for the flow, at Snapp/Digikala parity.
- **Real logo asset replacing the Twemoji pencil** (impact: high, effort: small) — Commission/derive a simple Balinyaar mark (currentColor SVG so the existing AppIcon color plumbing and dark scheme work), register it as ICONS.logo, and align favicon.ico + site.webmanifest icons with it. One-file swap that fixes the brand mark on login, splash, error, select-role, and the app shells simultaneously.
- **A focused first-run journey with a welcome moment** (impact: high, effort: medium) — Give onboarding its own chrome-free layout (like AuthCard, no bottom nav), open with a one-screen welcome ('خوش آمدید — برای شروع، بگویید مراقبت برای چه کسی است'), use distinct relation iconography (elderly/family icons already exist in ICONS), add a 'بعداً تکمیل می‌کنم' skip that lands on a browse-capable home with a persistent complete-your-profile nudge, and close with a small success state before Home. Turns a forced form into a warm ramp and removes the browse-blocking wall.
- **Terms/privacy consent line + static pages** (impact: high, effort: small) — Add the standard implicit-consent line under the login CTA linking to new /terms and /privacy public routes. Closes the legal gap and doubles as a trust cue; the (public-routes) group already exists to host them.
- **Public landing page for unauthenticated visitors** (impact: high, effort: large) — Replace the blanket redirect-to-login with a real marketing front door at /: value proposition, how-it-works (search → book → escrow → confirmed care), trust/verification explainer, service categories, and a prominent nurse-recruitment CTA (?role=nurse). This is also the only path to SEO for a marketplace whose customers arrive via search.
- **Persian-digit localization utility** (impact: medium, effort: small) — A tiny formatNumber/formatDigits helper on Intl.NumberFormat(locale) applied to the resend timer (and reusable for prices/dates app-wide), so fa users see ۰۱:۲۳ instead of 01:23 everywhere a timer or count renders.
- **Carry a returnUrl through login** (impact: medium, effort: small) — middleware appends ?next=<attempted path>; RoleRouter honors it (validated same-origin, role-permitting) before falling back to resolveRoleDestination. Makes SMS deep links, shared nurse profiles, and session-expiry re-logins land where the user intended.
- **OTP delivery fallback affordances** (impact: medium, effort: medium) — After a failed resend cycle or lockout, surface an escalation path — 'کد را دریافت نکردید؟' with a voice-call OTP option or a support link. Iranian SMS delivery is flaky enough (promotional-SMS blocking is widespread) that best-in-class local apps all offer a second channel; today the dead end is silent.
- **Elevate select-role into an illustrated fork** (impact: medium, effort: small) — Two larger illustrated cards (family receiving care vs. nurse professional), a selected-state fill using --bal-primary-soft plus a check glyph, and a reassurance line that the other role can be added later (dual-role sessions are already supported by RoleGuard). This screen is each new user's first branded decision — worth more than two grey bordered rows.

## Keep (do not regress)

- OTP input mechanics are genuinely well-built: auto-advance, paste distribution across boxes, Persian/Arabic→ASCII digit normalization, and dir="ltr" forcing on both the OTP group and the phone field so codes/numbers read correctly inside the RTL layout (client/src/components/OtpInput/OtpInput.tsx, client/src/components/PhoneNumberField/PhoneNumberField.tsx)
- Server-driven resend cooldown (resendAvailableInSeconds seeds the countdown) with a leak-free one-shot useCountdown, and the deliberate rule that resend stays available during lockout as the recovery path (OtpStep.tsx line 91)
- Explicit, distinct error states throughout: wrong/expired code, max-attempts lockout via OTP_LOCKED_CODE, and 429 rate-limit handling — plus the ' ' helperText placeholder that prevents layout jump when errors appear (PhoneStep.tsx line 76)
- Auto-verify on the fifth digit with a disabled CTA until complete, and the masked phone echo (0912•••1234) protecting the number on the OTP screen (fix its bidi wrapping, keep the masking)
- The routing hardening is excellent UX engineering: RoleRouter + branded AuthSplash so the wrong actor shell never flashes, AuthAccountError as an explicit recovery instead of silently downgrading a nurse to the customer shell, and RoleGuard's redirect-with-toast on role mismatch
- One login stack parameterized by intendedRole — no forked customer/nurse login trees — with the intent carried through to select-role pre-selection
- The token system in src/theme/tokens.css: complete, thoughtfully lifted dark scheme, soft tints, and brand-harmonized feedback colors; BrandMark's wordmark correctly uses primary.main so it tracks the scheme
- Accessible selection semantics on the radio cards (role="radio", aria-checked, tabIndex, Enter/Space handlers) in SelectRole and RelationSelect, and per-box aria-labels on the OTP inputs
- Mikhak Persian typeface loaded only on fa routes (preload:false + conditional className in the locale layout) — correct i18n-aware font strategy
- Onboarding's relation choice pre-shapes the patient form and hides the already-answered relation field (no double-asking), and the home-page redirect gate waits for a settled patients list so a fresh create never bounces the user back into onboarding
