S280 Handoff — International phone input (UxPhoneInputComponent)
Engineering brief to replace US-only behavior with country-aware E.164 input without breaking auth, admin, or API contracts.
S280 Handoff: International UxPhoneInputComponent
Executive summary
UxPhoneInputComponent(selector:ux-phone-input) is the only shared UI surface for collecting phone numbers for SMS/WhatsApp flows. It still assumes US (+1) and 10-digit national input in practice, which blocks non-US users across login, password reset, and admin phone verification.- The platform contract upstream and downstream is unchanged: E.164 strings (
++ country calling code + national digits). API validation already matches^\+[1-9]\d{6,14}$in shared DTOs; the gap is UI + normalization, not the wire format. - The smart move is to implement country metadata + variable length + paste/intl parsing in the ui-forms library, keep
ControlValueAccessoroutput identical (E.164), and optionally alignValidationUtils.normalizePhoneToE164so server- and client-side normalization do not diverge.
Why this matters (impact map)
| Area | Symptom today | After fix |
|---|---|---|
| End users (non-US) | Cannot complete SMS/WhatsApp login or SMS/WhatsApp password reset | Same flows work when Twilio supports the destination |
| Admins | gograb-admin user detail uses ux-phone-input inside a light card; control is visually “dark-theme” — cosmetic follow-up |
Functional parity; optional theme variant later |
| Backend | Already stores/sends E.164; admin phone OTP is per user id | No API redesign required if UI emits valid E.164 |
Confidence: Certain for E.164 contract (DTOs + services); likely for Twilio coverage per country (product/ops).
System contract (do not break)
flowchart LR
subgraph ui["Browser"]
UX[UxPhoneInputComponent]
end
subgraph api["backend-api"]
DTO["PhoneAuthRequestDto / PhoneAuthVerifyDto\n@IsMatches E.164"]
ADM["Admin phone verify\n(normalize + uniqueness)"]
end
UX -->|"string: +cc…"| DTO
UX -->|"string: +cc…"| ADM
- Output:
onChange/ model value must remain a single string, E.164, matching existing validators (e.g.^\+[1-9]\d{6,14}$). - Input:
writeValuemay receive stored E.164 from API — component must round-trip (display national part + correct country). - Selector:
ux-phone-input— unchanged. - Public inputs:
[countryCode]today means “default ITU-T calling code digits without+” (e.g.'1'). After work, it should mean “initial selected country calling code” when no value is set — backward compatible for callers that pass'1'.
Related code (read before coding):
- Component:
libs/libs-ui-core/ui-forms/src/lib/phone-input/phone-input.component.ts - Shared normalization (US-centric):
libs/core/utilities/src/lib/validation/validation-utils.ts—ValidationUtils.normalizePhoneToE164 - DTOs:
libs/libs-api/api-contracts/src/lib/auth/auth.dto.ts—PhoneAuthRequestDto,PhoneAuthVerifyDto
Current implementation snapshot
| Behavior | Detail |
|---|---|
| Prefix | Template: static +{{ countryCode() }}; default countryCode input = '1' |
| Input cap | National digits effectively US-length in onInput / display logic |
writeValue |
Assumes value maps to selected country + national (today effectively +1 + 10 digits) |
| Styling | Inline styles tuned for dark surfaces; consumers include light admin forms |
Consumers (verify after changes):
| App / lib | Entry |
|---|---|
libs/libs-ui/auth-ui |
login.component (template + TS) |
apps/gograb-space |
forgot-password.page.ts (inline template) |
apps/gograb-admin |
user-detail (profile phone + verification) |
Run a quick ripgrep before shipping: ux-phone-input / UxPhoneInputComponent.
Requirements (ranked)
P0 — Must ship
- Country selection — User can choose calling country (ITU-T country calling code), not only
+1. - Variable national length — Do not hard-cap at 10 digits; length depends on selected country (and sometimes number type — see P1).
- Default country — If parent does not bind
[countryCode], infer a sensible default fromnavigator.language/Intllocale (e.g.es-MX→ Mexico). Document fallback ('1'or'44') when ambiguous. - E.164 emission — On blur/commit (and when pasting full international number), emit one normalized E.164 string;
ngModel/formControlunchanged semantically. - Paste / typed international — Pasting
+44 7911 123456or0049…should select correct country and fill national digits when unambiguous. - Searchable country list — Filter by country name, ISO 3166-1 alpha-2, and calling code (keyboard and screen-reader friendly).
- Accessibility — Combobox or listbox pattern: label,
aria-expanded, active option, no “flags only” without text.
P1 — Strongly recommended
- Validation: Prefer
libphonenumber-js(e.g.parsePhoneNumberFromString+format('E.164')) for validity and leading zero rules (IT, etc.). Tree-shakemaxorminbuild per bundle budget. - As-you-type formatting — Optional display mask per region without changing emitted model until blur (reduces user error).
- Theme — Optional
@Input() variant: 'dark' | 'light' | 'inherit'or CSS variables so admin does not need a wrapper hack.
P2 — Nice to have
- Favorites — Pin last-used countries in dropdown.
- Geo-IP hint — Only as a default suggestion, never as security signal.
Out of scope (explicit)
- Replacing Twilio or changing OTP semantics.
intl-tel-input/ jQuery stacks (weight, imperative DOM).- Changing REST paths or DTO field names.
Design decisions (tradeoff matrix)
| Option | Pros | Cons |
|---|---|---|
| A. Custom country table + libphonenumber-js | Accurate validation, smaller than full Google lib, TS-native | Bundle cost — measure per app; lazy-load country metadata if needed |
| B. Custom table only (min/max lengths) | No new dependency | Wrong for many carriers / variable lengths; high maintenance |
| C. Wrap a web component / CDN widget | Fast POC | Style integration, CSP, Angular CVA glue fragile |
Recommendation: Option A — country list can stay a static curated JSON (top N countries + “rest” fallback), validation via libphonenumber-js.
Suggested file layout (library)
libs/libs-ui-core/ui-forms/src/lib/phone-input/
├── phone-input.component.ts # CVA + template
├── phone-input.component.scss # move inline styles; support variants
├── country-codes.ts # ISO2, name, dialCode, priority, optional region
├── phone-country-picker.component.ts # optional: searchable overlay (same bundle)
└── phone-parse.ts # paste + E.164 parse helpers (unit-test heavy)
Export new symbols only from libs/libs-ui-core/ui-forms/src/index.ts if consumers need the picker; otherwise keep private to the module.
Alignment with ValidationUtils
Today ValidationUtils.normalizePhoneToE164 is US-centric (10 digits → +{defaultCountryCode}). After international input ships:
- Either delegate to the same parsing helper used by
ux-phone-input, or - Document that UI is authoritative and utilities are legacy for non-phone flows only.
Avoid two conflicting definitions of “normalized phone” in the monorepo.
Edge cases (minimum test matrix)
| # | Scenario | Expected |
|---|---|---|
| 1 | User selects DE, enters valid mobile | E.164 +49… |
| 2 | Paste full E.164 with spaces | Country + national inferred; model E.164 |
| 3 | Paste competing prefixes (00 vs +) |
Deterministic parse per chosen helper lib |
| 4 | Switch country mid-typing | National digits truncated or re-validated — no silent wrong country |
| 5 | writeValue from API with stored E.164 |
Display matches stored number |
| 6 | Empty value | null or '' per existing CVA behavior — unchanged contract |
| 7 | Invalid for region | Show inline error; do not emit invalid E.164 on blur |
Verification checklist (before merge)
-
nx/ CI:libs-ui-core/ui-formsbuilds; apps that bundle auth-ui / gograb-space / gograb-admin compile. - Manual: Login (phone channel), Forgot password (SMS/WhatsApp), Admin user phone request + verify still hit API with E.164.
- Screen reader: country control announces name + dial code.
- Bundle diff: compare main chunk size vs main branch (document delta in PR).
Rollout / risk
| Risk | Mitigation |
|---|---|
| Bundle size | Lazy-load libphonenumber-js metadata or import minimal entry point |
| Wrong default country | Allow parent [countryCode] override; test en-GB, es-MX, de-DE |
| Multi-instance backend | Unrelated to this UI task; admin OTP storage is server-side |
Definition of done
- Non-US users can complete phone-based flows where Twilio supports the destination and the number is valid E.164.
- No change required in consumer templates unless we intentionally add optional inputs (e.g.
variant) — default path should just work. - Unit tests cover parse, paste, writeValue round-trip for at least 5 countries on three continents.
- This document’s P0 list is satisfied or explicitly deferred with a linked issue.
References
- Session context: S280 (2FA, password reset, admin phone verification).
- API shape:
libs/libs-api/api-contracts— phone auth DTOs. - Module path:
@libs-ui-core/ui-forms(see repotsconfigpaths; do not edittsconfig.base.jsonpaths by hand — followdocs/english/Rules/tsconfig-module-paths.md).
Appendix: locale → default country (algorithm sketch)
- Read
navigator.language(e.g.en-GB). - Map region subtag (
GB) → calling code via static map (GB → 44). If only language (en) with no region, do not guess US — fall back to configured default (product decision: often1for current user base). - If
[countryCode]is set by parent, it wins over locale.
Document the fallback in component JSDoc so future agents do not “fix” it incorrectly.