mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
/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.
183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
'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>
|
||
)
|
||
}
|