Files
hanzo.ai/components/settings/Address.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

185 lines
6.0 KiB
TypeScript

'use client'
/**
* Address: how a person gives the account they are signed in to an email address
* or a phone number to sign in by. An account an agent made with a wallet has
* neither, so the wallet's key is the only way in; this is how a human claims it.
*
* It is IAM's own proof, never ours: a code is sent to the new address
* (`sendLoginCode`, the same send the sign-in uses) and IAM adds the address to
* THIS account when the code comes back (`addAddress`, PUT /v1/iam/address). IAM
* adds and never replaces, so an address the account already has is not offered,
* and an address another account holds is refused in the words a wrong code gets.
* The account, its organization, keys and balance are the same ones afterwards:
* nothing is created, so nothing earns a second time.
*
* Drawn only where IAM says an address can be added (`methods().address`), so the
* form appears the day IAM serves the route and not before.
*/
import { useEffect, useId, useState, type FormEvent } from 'react'
import { Button, Input, Text, XStack, YStack, toast } from '@hanzo/ui'
import { useAccount } from '@hanzo/app/lib/account'
import { here, useClient } from '@hanzo/app/lib/signout'
import { Field } from '@/components/account/form'
import { Entry } from '@/components/auth/phone'
import { Code, said, Wrong } from '@/components/auth/steps'
import { shown } from '@/lib/auth/phone'
import { Section } from './kit'
/** The shape of an email address worth sending a code to (IAM judges the rest). */
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@.]{2,}$/
type Kind = 'email' | 'phone'
/** The two addresses an account signs in with, as IAM answers them. */
type Signers = { email: string; phone: string }
/** One address to add: typed, sent its code, and added when the code comes back. */
function Add({ kind, onAdded }: { kind: Kind; onAdded: (now: Signers) => void }) {
const iam = useClient()
const ids = { email: useId(), code: useId() }
const [typed, setTyped] = useState('')
const [sent, setSent] = useState<string | null>(null)
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [wrong, setWrong] = useState<string | null>(null)
async function send(to: string) {
setBusy(true)
setWrong(null)
try {
await iam.sendLoginCode(to)
setSent(to)
setCode('')
} catch (err) {
setWrong(said(err))
} finally {
setBusy(false)
}
}
function mail(e: FormEvent) {
e.preventDefault()
const address = typed.trim()
if (!EMAIL.test(address)) return setWrong('Enter a full email address, like [email protected].')
void send(address)
}
async function add(e: FormEvent) {
e.preventDefault()
if (!sent) return
if (!code.trim()) return setWrong('Enter the code we sent.')
setBusy(true)
setWrong(null)
try {
onAdded(await iam.addAddress(sent, code.trim()))
toast.success(kind === 'email' ? 'Email added. You can sign in with it.' : 'Phone added. You can sign in with it.')
} catch (err) {
setWrong(said(err))
} finally {
setBusy(false)
}
}
if (sent) {
return (
<form onSubmit={(e) => void add(e)} noValidate>
<YStack gap="$3" maxW={576}>
<Text fontSize="$3" color="$soft">
{kind === 'email' ? `We sent a code to ${sent}.` : `We texted a code to ${shown(sent)}.`}
</Text>
<Code id={ids.code} value={code} onChange={setCode} />
<Wrong text={wrong} />
<XStack gap="$2" flexWrap="wrap">
<Button type="submit" disabled={busy}>
{busy ? 'Adding…' : kind === 'email' ? 'Add email' : 'Add phone'}
</Button>
<Button
type="button"
variant="ghost"
disabled={busy}
onClick={() => {
setSent(null)
setWrong(null)
}}
>
{kind === 'email' ? 'Use a different email' : 'Use a different number'}
</Button>
</XStack>
</YStack>
</form>
)
}
return kind === 'email' ? (
<form onSubmit={mail} noValidate>
<YStack gap="$3" maxW={576}>
<Field id={ids.email} label="Email">
<Input
id={ids.email}
type="email"
autoComplete="email"
placeholder="[email protected]"
value={typed}
onChange={(e) => setTyped(e.target.value)}
aria-invalid={wrong ? true : undefined}
/>
</Field>
<Wrong text={wrong} />
<XStack>
<Button type="submit" variant="outline" disabled={busy}>
{busy ? 'Sending…' : 'Send code'}
</Button>
</XStack>
</YStack>
</form>
) : (
<YStack gap="$2" maxW={576}>
<Text fontSize="$3" fontWeight="500" color="$ink">
Phone number
</Text>
<Entry busy={busy} wrong={wrong} send="Text me a code" onSend={(number) => void send(number)} />
</YStack>
)
}
export function Address() {
const { user, setUser } = useAccount()
const iam = useClient()
const [on, setOn] = useState(false)
useEffect(() => {
if (!here()) return
let live = true
iam
.methods()
.then((m) => live && setOn(m.address))
.catch(() => {})
return () => {
live = false
}
}, [iam])
if (!on || !user) return null
const needs: Kind[] = [...(user.email ? [] : (['email'] as const)), ...(user.phone ? [] : (['phone'] as const))]
if (!needs.length) return null
return (
<Section
title="Sign in another way"
lede="Add an email address or a phone number to this account, and sign in with it as well. We send a code to prove it is yours. The account, its organization, keys and balance stay the same."
>
<YStack gap="$6">
{needs.map((kind) => (
<Add
key={kind}
kind={kind}
onAdded={(now) => setUser({ ...user, email: now.email || user.email, phone: now.phone || user.phone })}
/>
))}
</YStack>
</Section>
)
}