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.
185 lines
6.0 KiB
TypeScript
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>
|
|
)
|
|
}
|