Skip to content

Phone input: visible +1 (US) prefix + strict validation (#777) โ€‹

Date: 2026-08-03 Issue: #777 Status: approved (design), in implementation

Problem โ€‹

Every auth screen hand-rolls a free-text type="tel" input (placeholder +1 (555) 123-4567) and calls normalizePhoneNumber on submit. A human handling a raw number can drop the +1 or mistype the country code, and the resulting failure is opaque. (Surfaced during the #144 E2E setup: a Prelude sandbox number entered as +6195... instead of +16195... produced a confusing "Invalid Number".) The app normalizes correctly, but the input invites the mistake and does not clearly reject a malformed number.

Goal โ€‹

Make the +1 explicit and impossible to fat-finger on the phone inputs, and reject anything that is not a clean 10-digit US number so no account is created under a garbage string.

Scope (Tight) โ€‹

Convert two screens: PhonePinSignup (step 1) and PhonePinLogin. This also covers the admin-to-app-user bridge, which runs through PhonePinSignup's same step-1 phone field (isAdminFlow is detected after step 1, on the normalized number), so an admin signing up with their US number works unchanged.

Out of scope (later sweep, same component): AdminSignup, PhoneMigrationFlow, LockScreen, ForgotPassphraseModal, PhoneReclaimFlow, MerchantSignup. A full country-code selector is explicitly deferred (issue calls it low priority).

Design โ€‹

New shared component apps/web/src/components/PhoneNumberInput.jsx (mirrors the existing BirthDateInput shared-input pattern):

  • A fixed, non-editable +1 affix on the left; the user types only the local number.
  • Formats as typed: 619 555 0001. Strips non-digits, hard-caps at 10 digits.
  • onChange reports the local digits (0-10) to the parent; a toE164(localDigits) helper composes +1XXXXXXXXXX.
  • valid only when exactly 10 digits. Below that, the parent keeps its submit button disabled (as today) and can show an inline hint.
  • Accessible: <label> association, inputMode="tel", aria-invalid when a submit was attempted with < 10 digits.
  • Structured so a country <Select> (project wrapper) can replace the fixed +1 affix later without changing the parent contract.

normalizePhoneNumber stays the single source of truth. The component composes +1 + 10 local digits; the parent still calls normalizePhoneNumber on submit as the final gate. Because the component guarantees a 10-digit local US number, the normalized result is always a clean +1XXXXXXXXXX; a malformed string can no longer reach account creation.

Parent changes (both screens): replace the free-text <input type="tel"> with <PhoneNumberInput>; keep the existing phone/setPhone state but hold the composed E.164 (or hold local digits and compose on submit, whichever is cleaner per screen). Existing submit-disabled and error logic is preserved.

Testing โ€‹

  • Unit (PhoneNumberInput.test.jsx): formatting as typed, non-digit stripping, 10-digit cap, toE164 composition, valid transitions, paste of a full +1.../1.../(619) 555-0001 string normalizes to the same 10 local digits.
  • Story (PhoneNumberInput.stories.jsx): empty, partial, complete, invalid states (story coverage is enforced).
  • Manual test plan at docs/engineering/testing/runs/phone-input-plus1/README.md (operator-run browser scenarios: US number signup, admin-number signup, login, paste behavior, reject junk), linked in the PR.
  • Live/emulator: unit covers the component; a running-app or E2E pass confirms the signup/login flow still completes end to end.

Risks โ€‹

  • A fixed +1 blocks non-US numbers until the country selector lands. Accepted: matches the app's existing US-only normalization assumption and the issue's deferral.
  • Paste handling must not double-count a pasted +1/1 prefix; covered by a unit test.

Built with VitePress