a phone number is entered the way its owner writes it

Choosing Phone used to relabel the email field. It swaps in a control now: the
country, then the number as it is written there. One field cannot do the second —
(913) 777-9708 and 07911 123456 and 090-1234-5678 are the same amount of number in
three countries, and grouping them all the American way tells most of the world we
do not know where they live.

The grouping is libphonenumber's, the country names are Intl.DisplayNames, so no
country table ships and the names arrive in the reader's language. 245 countries,
formatted while you type.

WHAT IS SENT IS NOT THE DIAL CODE GLUED TO WHAT WAS TYPED. Most of the world
writes a trunk prefix that exists only inside the country and is dropped once the
country code leads; concatenating gives +44 07911 123456, which normalises to
44079111… against a stored 447911… and finds nobody. It was written that way first
and it passed, because in the NANP concatenation is accidentally correct — which
is the whole trap. libphonenumber composes it now, and five countries with a trunk
prefix are pinned so getting one right cannot pass for getting them right.

The 143 kB of country rules load when somebody chooses Phone, not on every sign-in.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-09 23:49:48 -07:00
co-authored by Hanzo Dev
parent 25fdc16407
commit 683ce8e263
9 changed files with 385 additions and 33 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.44",
"version": "0.1.45",
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19, styled from @hanzo/design tokens. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"type": "module",
"scripts": {
+56
View File
@@ -269,6 +269,62 @@ body {
here by design, and animating a property nothing changes costs a frame. */
transition: background var(--duration-fast) var(--ease-out);
}
/* Holds the space the phone control will take, so choosing Phone does not shove
the button below it down when the country rules finish loading. Two 52px fields
plus the gap between them, plus their two labels. */
.hanzo-id-phone-loading {
min-height: 172px;
}
/* The phone control: country over number, one gap, same rhythm as two stacked
fields — because that is what it is. */
.hanzo-id-phone {
display: flex;
flex-direction: column;
gap: 16px;
}
/* The dial code sits INSIDE the field's box rather than beside it, so the pair
reads as one control and the 52px shape is unbroken. The input carries the
border; the wrapper only positions. */
.hanzo-id-phone-number {
position: relative;
display: flex;
align-items: center;
}
.hanzo-id-phone-number .hanzo-id-input {
/* Room for the dial code. 3ch fits +1 through +998; the padding is what keeps
the digits from starting under it. */
padding-left: 62px;
}
.hanzo-id-dial {
position: absolute;
left: 22px;
color: var(--text-secondary);
font-size: var(--text-base);
pointer-events: none;
}
/* A <select> is a control like the others, so it takes the same box — the UA
default is 22px tall with its own bevel and would sit next to a 52px pill
looking like a different product. appearance:none removes the bevel; the
chevron is drawn as a background so no extra element is needed. */
select.hanzo-id-input {
appearance: none;
padding-right: 44px;
background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%),
linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%);
background-position: calc(100% - 25px) center, calc(100% - 19px) center;
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
/* The option list is drawn by the OS, which does not inherit the page's colours —
an unset background paints white text on white on a dark-mode build. */
select.hanzo-id-input option {
background: var(--background);
color: var(--foreground);
}
.hanzo-id-input::placeholder { color: var(--text-disabled); }
.hanzo-id-input:disabled { opacity: 0.5; cursor: not-allowed; }
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.2.67",
"version": "0.2.69",
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
"packageManager": "[email protected]",
"scripts": {
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-auth",
"version": "0.1.20",
"version": "0.1.21",
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
"license": "BSD-3-Clause",
"type": "module",
@@ -19,10 +19,11 @@
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/iam": "^0.21.1",
"@hanzo/id-connect": "workspace:*",
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.21.1",
"@paulmillr/qr": "^0.3.0"
"@paulmillr/qr": "^0.3.0",
"libphonenumber-js": "^1.13.10"
},
"peerDependencies": {
"react": ">=19",
+19 -10
View File
@@ -180,9 +180,11 @@ test('an application that checks no password is not shown a password box', async
assert.equal(document.querySelector('[data-arm-switch]'), null)
})
// A phone number has always worked on the wire; nothing said so. The toggle is the
// saying — and it must stay a LABEL, not a second normalizer.
test('the phone toggle renames the field and changes the keyboard, never the wire', async () => {
// The phone entry used to RELABEL this field; it swaps in a control of its own now,
// because a number needs its country stated — the dial code rides on it and the
// grouping is a fact about where it lives. What leaves is still one identifier
// string, which is all IAM ever wanted.
test('choosing phone swaps in the phone control and posts one identifier', async () => {
const { posts, fetchImpl } = iam({ login: { status: 'error', msg: 'the username or password is incorrect' } })
render(<Harness client={createAuthClient({ org: org(), fetchImpl })} />)
await settled()
@@ -192,21 +194,28 @@ test('the phone toggle renames the field and changes the keyboard, never the wir
const phone = await waitFor(() => {
const input = field('Phone number')
assert.ok(input, 'the field is not renamed')
assert.ok(input, 'the phone control did not arrive')
return input!
})
assert.equal(phone.getAttribute('inputmode'), 'tel', 'a number pad, on a phone')
assert.equal(phone.getAttribute('autocomplete'), 'tel')
assert.equal(phone.getAttribute('type'), 'text', 'never type=tel: this field still takes an email')
// tel-national, NOT tel: the country is a separate control, so this field holds
// the national part and a password manager filling a full number here would put
// the dial code in twice.
assert.equal(phone.getAttribute('autocomplete'), 'tel-national')
assert.equal(phone.getAttribute('type'), 'tel', 'this one only ever takes a number')
assert.ok(document.querySelector('[data-phone-country]'), 'no country control')
type(phone, '+1 (415) 555-0134')
type(phone, '4155550134')
type(field('Password')!, 'pw')
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await waitFor(() => assert.equal(posts.length, 1))
// Exactly as typed. IAM's own lookup normalizes (store.NormalizePhone inside
// GetUserByPhone); a second normalizer here is how two spellings stop agreeing.
assert.equal(JSON.parse(posts[0]!.body).username, '+1 (415) 555-0134')
// ONE identifier, in international form. The field shows (415) 555-0134 and sends
// the number with its country code — which is composition, not normalization:
// IAM's own lookup still normalizes what it receives (store.NormalizePhone inside
// GetUserByPhone). Concatenating the dial code onto what was typed would be wrong
// everywhere a trunk prefix exists, and right here, which is the trap.
assert.equal(JSON.parse(posts[0]!.body).username, '+1 415 555 0134')
})
// MEASURED on live hanzo.id: after a failed sign-in, document.activeElement.tagName
+32 -14
View File
@@ -2,6 +2,7 @@ import { useEffect, useId, useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
import type { AppLogin, LoginResponse } from '../types'
import { Alert } from './Alert'
import { lazy, Suspense } from 'react'
import { PasswordField } from './PasswordField'
import { Submit } from './Submit'
@@ -53,6 +54,13 @@ export interface LoginFormProps {
* inside `GetUserByPhone`), and a second normalizer is how two spellings of one
* number stop agreeing.
*/
/* The country and formatting rules are 143 kB of metadata for 245 countries, and
only this one arm reads them — so they load when somebody chooses Phone, not on
every sign-in. Loading them eagerly put 34 kB gz on the default path for a field
most people never open. The fallback is the field's own height so the form does
not jump while the chunk arrives. */
const PhoneField = lazy(() => import('./PhoneField').then((m) => ({ default: m.PhoneField })))
export function LoginForm(props: LoginFormProps) {
const { client } = props
const [identifier, setIdentifier] = useState('')
@@ -192,20 +200,30 @@ export function LoginForm(props: LoginFormProps) {
return (
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
<label className="hanzo-id-field">
<span>{kind === 'phone' ? 'Phone number' : 'Email or username'}</span>
<input
className="hanzo-id-input"
type="text"
inputMode={kind === 'phone' ? 'tel' : 'text'}
autoComplete={kind === 'phone' ? 'tel' : 'username'}
aria-invalid={invalid || undefined}
aria-describedby={errorId}
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
</label>
{/* Phone is its own control, not this one relabelled. A number needs the
country stated — the dial code rides on it, and how the digits group is
a fact about where the number lives. Everything else IAM resolves from
one string (name, then email, then phone), so everything else is one
field. */}
{kind === 'phone' ? (
<Suspense fallback={<div className="hanzo-id-phone-loading" aria-hidden="true" />}>
<PhoneField label="Phone number" onChange={setIdentifier} invalid={invalid} describedBy={errorId} />
</Suspense>
) : (
<label className="hanzo-id-field">
<span>Email or username</span>
<input
className="hanzo-id-input"
type="text"
autoComplete="username"
aria-invalid={invalid || undefined}
aria-describedby={errorId}
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
</label>
)}
{/* The switch that used to live here — a text link reading "Use a phone
number instead" — is now an entry in the sign-in strip, so every way in
is a button in one column instead of one of them being a sentence under
+112
View File
@@ -0,0 +1,112 @@
/**
* The phone identifier, mounted.
*
* The case that matters is not the formatting — that is libphonenumber's and it is
* right. It is what leaves the field: most of the world writes a trunk prefix that
* exists only inside the country, and it is DROPPED once the country code leads. A
* field that concatenates sends `+44 07911 123456`, which normalises to 44079111…
* against a stored 447911… and finds nobody. That failure is invisible in the
* NANP, where concatenation is accidentally correct — so a US-only test passes
* while every other country is broken.
*/
import { afterEach, test } from 'vitest'
import assert from 'node:assert/strict'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { PhoneField } from './PhoneField'
afterEach(cleanup)
/** Mount the field and drive it, reporting what it would post. */
async function field() {
let sent = ''
render(<PhoneField label="Phone number" onChange={(v) => (sent = v)} />)
await waitFor(() => assert.ok(document.querySelector('[data-phone-national]')))
const input = document.querySelector('[data-phone-national]') as HTMLInputElement
const select = document.querySelector('select') as HTMLSelectElement
return {
type(v: string) {
fireEvent.change(input, { target: { value: v } })
},
pick(c: string) {
fireEvent.change(select, { target: { value: c } })
},
get shown() {
return input.value
},
get sent() {
return sent
},
get dial() {
return document.querySelector('.hanzo-id-dial')?.textContent?.trim()
},
get countries() {
return select.options.length
},
}
}
test('the trunk prefix is dropped once the country code leads', async () => {
const f = await field()
f.pick('GB')
f.type('07911123456')
assert.equal(f.shown, '07911 123456', 'shown as it is written in the country')
assert.equal(f.sent, '+44 7911 123456', 'sent WITHOUT the trunk 0')
})
// Four more countries, because the bug is per-country and getting one right proves
// nothing about the rest. Note BR: the national form groups 98765-4321 with a
// hyphen and the international form uses a space. What is shown and what is sent
// are different strings — that is the whole point of composing rather than
// concatenating, and it is why these expectations were read off the library
// instead of guessed (this one was guessed first, and was wrong).
test('every trunk-prefix country composes correctly', async () => {
for (const [country, typed, sent] of [
['DE', '015112345678', '+49 1511 2345678'],
['FR', '0612345678', '+33 6 12 34 56 78'],
['JP', '09012345678', '+81 90 1234 5678'],
['BR', '11987654321', '+55 11 98765 4321'],
] as const) {
const f = await field()
f.pick(country)
f.type(typed)
assert.equal(f.sent, sent, `${country} composed wrong`)
cleanup()
}
})
test('a NANP number keeps its own shape', async () => {
const f = await field()
f.pick('US')
f.type('9137779708')
assert.equal(f.shown, '(913) 777-9708')
assert.equal(f.sent, '+1 913 777 9708')
assert.equal(f.dial, '+1')
})
// Choosing the country after typing is what someone does when the default was
// wrong, so the digits survive it.
test('switching country keeps the digits and moves the dial code', async () => {
const f = await field()
f.pick('US')
f.type('9137779708')
f.pick('GB')
assert.equal(f.dial, '+44')
assert.match(f.shown, /9137779708/, 'the digits are still there')
})
test('an empty field sends nothing at all', async () => {
const f = await field()
f.type('123')
f.type('')
assert.equal(f.sent, '', 'an empty field is not a dial code')
})
// Every country, from the library — a hand-kept list is a list that goes stale.
test('the whole world is offered', async () => {
const f = await field()
assert.ok(f.countries > 200, `expected the full country list, got ${f.countries}`)
})
+153
View File
@@ -0,0 +1,153 @@
import { useMemo, useState } from 'react'
import { AsYouType, getCountries, getCountryCallingCode, type CountryCode } from 'libphonenumber-js/min'
/**
* A phone number, entered the way the person who owns it writes it.
*
* Two controls, because they answer two questions: WHICH country, then the number
* as it is written THERE. A single field cannot do the second — (913) 777-9708 and
* 07911 123456 and 090-1234-5678 are the same amount of number in three countries,
* and a field that groups them all the American way tells two thirds of the world
* it does not know where they live.
*
* The grouping is libphonenumber's, not ours. 245 countries' rules are a library,
* not a regex we were going to get right, and `AsYouType` also handles the partial
* case that matters most — it formats while you are still typing, so the number
* settles into shape rather than jumping when it is finished.
*
* The names are `Intl.DisplayNames`, so they arrive in the reader's own language
* and we ship no country table at all. A runtime without it (or a region code it
* does not know) falls back to the ISO code, which is still identifiable.
*
* What is SHOWN and what is SENT are different strings, and only the second one
* matters to IAM — see `compose`, which is where the trunk prefix is dealt with.
*/
/**
* What gets SENT: the number in international form, which is not the dial code
* glued to what was typed.
*
* Most of the world writes a trunk prefix that exists only inside the country —
* GB 07911 123456, DE 01511…, FR 06…, JP 090… — and it is DROPPED when the country
* code leads. Concatenating gives `+44 07911 123456`, which is not that number;
* normalised it reads 44079111… against a stored 447911…, and the account is never
* found. Every country with a trunk prefix would have failed, which is everywhere
* except the NANP — where concatenation happens to be right, and where this was
* tested.
*
* So libphonenumber composes it, the same library that formats it. Mid-typing
* there is nothing to parse yet, and the dial code plus the digits so far is the
* honest intermediate — it is never submitted, because a partial number does not
* resolve either way.
*
* This is not a second normaliser. IAM's `GetUserByPhone` still normalises what it
* receives; this only writes the number the way the number is written.
*/
function compose(typing: AsYouType, country: CountryCode, national: string): string {
if (national.trim() === '') return ''
const parsed = typing.getNumber()
return parsed ? parsed.formatInternational() : `+${getCountryCallingCode(country)} ${national}`
}
/**
* Where the browser says it is. A phone field that opens on the wrong country is
* a wrong dial code on every number typed into it, and the region is already
* stated in the locale — no lookup, no request.
*/
function here(): CountryCode {
const known = new Set<string>(getCountries())
for (const tag of typeof navigator === 'undefined' ? [] : navigator.languages ?? [navigator.language]) {
const region = new Intl.Locale(tag).maximize().region
if (region && known.has(region)) return region as CountryCode
}
return 'US'
}
export function PhoneField({
label,
onChange,
invalid,
describedBy,
}: {
readonly label: string
readonly onChange: (identifier: string) => void
readonly invalid?: boolean
readonly describedBy?: string
}) {
const [country, setCountry] = useState<CountryCode>(here)
const [national, setNational] = useState('')
// Sorted by NAME in the reader's language, so the list reads alphabetically to
// the person using it rather than to the ISO registry.
const countries = useMemo(() => {
let name: (c: string) => string
try {
const names = new Intl.DisplayNames(undefined, { type: 'region' })
name = (c) => names.of(c) ?? c
} catch {
name = (c) => c
}
return getCountries()
.map((c) => ({ code: c, name: name(c), dial: getCountryCallingCode(c) }))
.sort((a, b) => a.name.localeCompare(b.name))
}, [])
// Re-group under whichever country's rules apply. Digits that are not a number
// in that country come back ungrouped, which is the truth about them — a US
// number does not become a British one by choosing GB.
function regroup(to: CountryCode, digits: string) {
const typing = new AsYouType(to)
const shown = typing.input(digits.replace(/[^\d+]/g, ''))
setNational(shown)
onChange(compose(typing, to, shown))
}
function pick(next: CountryCode) {
setCountry(next)
// Keep what was typed: choosing the country AFTER starting is exactly what
// someone does when the default was wrong, and clearing the field punishes it.
regroup(next, national)
}
return (
<div className="hanzo-id-phone">
<label className="hanzo-id-field">
<span>Country</span>
<select
className="hanzo-id-input"
data-phone-country={country}
value={country}
onChange={(e) => pick(e.target.value as CountryCode)}
>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name} (+{c.dial})
</option>
))}
</select>
</label>
<label className="hanzo-id-field">
<span>{label}</span>
{/* The dial code is shown, not typed: it belongs to the country chosen
above, so an editable copy of it is a second place to get it wrong. */}
<div className="hanzo-id-phone-number">
<span className="hanzo-id-dial" aria-hidden="true">
+{getCountryCallingCode(country)}
</span>
<input
className="hanzo-id-input"
type="tel"
inputMode="tel"
autoComplete="tel-national"
data-phone-national="true"
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
value={national}
onChange={(e) => regroup(country, e.target.value)}
required
/>
</div>
</label>
</div>
)
}
+7 -4
View File
@@ -121,6 +121,9 @@ importers:
'@paulmillr/qr':
specifier: ^0.3.0
version: 0.3.0
libphonenumber-js:
specifier: ^1.13.10
version: 1.13.10
devDependencies:
'@types/react':
specifier: ^19.0.0
@@ -1719,8 +1722,8 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
[email protected].7:
resolution: {integrity: sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==}
[email protected].10:
resolution: {integrity: sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==}
[email protected]:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
@@ -2594,7 +2597,7 @@ snapshots:
'@hanzo/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
jose: 6.2.3
libphonenumber-js: 1.13.7
libphonenumber-js: 1.13.10
passport-oauth2: 1.8.0
optionalDependencies:
react: 19.2.7
@@ -3731,7 +3734,7 @@ snapshots:
[email protected]: {}
[email protected].7: {}
[email protected].10: {}
[email protected]:
optional: true