Files
hanzo.ai/components/auth/phone.tsx
zeekay ac2da21e5f sign-in: wallet and phone, and a person claims a wallet's account
/login and /signup draw "Continue with wallet" and "Continue with phone"
after Google and GitHub, each only where IAM's methods say it is on.

Wallet: where IAM signs a wallet in (@hanzo/iam 0.21.37 `wallet`) and the
browser injects an EIP-1193 wallet; the press is loginWithWallet (IAM's
challenge, personal_sign of its EIP-4361 text, IAM's verify) and ends as a
password's sign-in ends. A wallet IAM has not seen is made an account in an
org of its own, with no email.

Phone: where IAM says it sends codes (`code`) and has answered. The number
is read in its country with the SMS consent ticked, then IAM's code flow.
IAM's address lookup answers an email address only, so a number is texted as
any is and the account that holds it is signed in by the code.

Settings -> General -> Sign in another way adds an email or a phone to the
signed-in account through IAM's own proof (addAddress, PUT /v1/iam/address),
drawn only where IAM's methods say `address`. Same account, org, keys and
balance; nothing is created, so nothing earns twice.

Gates: wallet.spec (a throwaway secp256k1 key behind an injected provider),
phone.spec, address.spec. login.spec expects the phone after the providers.
2026-10-26 16:47:32 -07:00

183 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* The two steps of a sign-in by phone, drawn in the panel (components/auth/panel),
* which runs every call: the number, with its country and the SMS consent, then
* the code IAM texted to it. A number is sent to IAM in E.164 (@hanzo/iam/validation
* `e164`), read in the country picked. IAM answers no question about whose number
* it is, so the page says what the code does and leaves the rest to IAM's sentence.
*/
import { useEffect, useId, useMemo, useState, type FormEvent } from 'react'
import { e164 } from '@hanzo/iam/validation'
import { Checkbox, Input, InputGroup, PhoneCountryPicker, Text, XStack, YStack } from '@hanzo/ui'
import { Action } from '@hanzo/ui/marketing'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Code, Held, Note, Other, Wrong } from '@/components/auth/steps'
import { LARGE } from '@/components/shell/focus'
import { CONSENT, countries, shown, wait } from '@/lib/auth/phone'
/** Said under the number when it is not one that can be dialled in the country picked. */
const WHOLE = phrase('Enter a full phone number, with its area code.')
/** Said when the consent is not ticked. */
const AGREE = phrase('Agree to receive the text to continue.')
/**
* The number and the SMS consent, in a form of their own: `onSend` is handed the
* number in E.164. Drawn by the sign-in (`Dial`) and by Settings, which adds a
* number to an account; `send` names the button, and `focus` puts the cursor in
* the number when the form is the page's one task.
*/
export function Entry({
busy,
wrong,
send,
focus = false,
onSend,
}: {
busy: boolean
wrong: string | null
send: string
focus?: boolean
onSend: (number: string) => void
}) {
const { t, locale } = useI18n()
const list = useMemo(() => countries(locale), [locale])
const [region, setRegion] = useState('US')
const [typed, setTyped] = useState('')
const [agree, setAgree] = useState(false)
const [bad, setBad] = useState<string | null>(null)
const ids = { number: useId(), consent: useId(), wrong: useId() }
const said = bad ?? wrong
function submit(e: FormEvent) {
e.preventDefault()
const number = e164(typed, region)
if (!number) return setBad(t(WHOLE))
if (!agree) return setBad(t(AGREE))
setBad(null)
onSend(number)
}
return (
<form onSubmit={submit} noValidate>
<YStack gap="$4">
<InputGroup size="lg" fill>
<PhoneCountryPicker
countries={list}
value={region}
onValueChange={(v) => v && setRegion(v)}
label={t('Country calling code')}
searchLabel={t('Search countries')}
emptyLabel={t('No country found')}
/>
<Input
id={ids.number}
type="tel"
inputMode="tel"
autoComplete="tel"
name="phone"
aria-label={t('Phone number')}
placeholder={t('Phone number')}
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoFocus={focus}
aria-invalid={said ? true : undefined}
aria-describedby={said ? ids.wrong : undefined}
/>
</InputGroup>
<XStack gap="$3" items="flex-start">
<Checkbox id={ids.consent} checked={agree} onCheckedChange={(v) => setAgree(v === true)} mt="$1" />
{/* The A2P wording, untranslated: carriers compare it across surfaces word for word. */}
<Text render="label" htmlFor={ids.consent} fontSize="$1" lineHeight="1.125rem" color="var(--muted-foreground)" flex={1}>
{CONSENT}
</Text>
</XStack>
<Wrong id={ids.wrong} text={said} />
<Action render="button" type="submit" fill disabled={busy} {...LARGE}>
{t(busy ? 'One moment…' : send)}
</Action>
</YStack>
</form>
)
}
/** The sign-in's first phone step: the number and the consent, under what the code does. */
export function Dial({ busy, wrong, onSend, onBack }: { busy: boolean; wrong: string | null; onSend: (number: string) => void; onBack: () => void }) {
const { t } = useI18n()
return (
<YStack gap="$4">
<Held address={t('We’ll text you a code.')} title={t('Continue with phone')} onBack={onBack} />
<Text fontSize="$2" lineHeight="1.125rem" color="var(--muted-foreground)">
{t('It signs in the account that uses this number.')}
</Text>
<Entry busy={busy} wrong={wrong} send="Text me a code" focus onSend={onSend} />
</YStack>
)
}
/**
* The code IAM texted to `number` at `at` (ms). Another is a press away once IAM
* will send one (`RESEND`); `spent` is a code tried as often as IAM allows, so
* only another can sign in.
*/
export function Texted({
number,
at,
code,
busy,
spent,
wrong,
note,
onCode,
onVerify,
onAgain,
onBack,
}: {
number: string
at: number
code: string
busy: boolean
spent: boolean
wrong: string | null
note: string | null
onCode: (code: string) => void
onVerify: () => void
onAgain: () => void
onBack: () => void
}) {
const { t } = useI18n()
const id = useId()
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const tick = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(tick)
}, [])
const left = wait(at, now)
return (
<form
onSubmit={(e) => {
e.preventDefault()
onVerify()
}}
noValidate
>
<YStack gap="$4">
<Held address={shown(number)} title={t('Enter the code we texted you')} onBack={onBack} />
<Code id={id} value={code} onChange={onCode} />
<Wrong text={wrong} />
<Note text={note} />
<Action render="button" type="submit" fill disabled={busy || spent} {...LARGE}>
{t(busy ? 'One moment…' : 'Continue')}
</Action>
<Other onClick={onAgain} disabled={busy || left > 0}>
{left > 0 ? t('Send a new code in {n}s', { n: left }) : t('Send a new code')}
</Other>
</YStack>
</form>
)
}