Files
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

367 lines
13 KiB
TypeScript

'use client'
/**
* General: the person, not the org. Their profile as IAM keeps it and how the app
* looks to them.
*
* The profile is IAM's row, written through `PUT /v1/iam/account` — the one write
* a person may make about themselves (contexts/AccountContext.tsx). Four fields
* save: name, picture, bio and website. The email address is a login identifier
* and the place a sign-in code goes, so it is shown and not edited: IAM moves it
* by proving the new address, never by a profile save.
*
* An account a wallet made has no email address. It is the wallet that signs it
* in, so the wallet is shown where the address would be — read from the token's
* `wallets` claim, which IAM signs (lib/auth/wallet) — and the address field says
* there is none rather than standing empty. An address is added through IAM's own
* proof, a code sent to it (./Address), where IAM says it takes one.
*/
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowRight, Building2, Check, Wallet } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage, Button, Input, Text, Textarea, XStack, YStack, toast } from '@hanzo/ui'
import { Field } from '@/components/account/form'
import { Look } from '@hanzo/app/look'
import { LanguagePicker, useI18n } from '@hanzo/i18n/react'
import { claimsOf, useIam } from '@hanzo/iam/react'
import { useAccount } from '@hanzo/app/lib/account'
import { api } from '@hanzo/app/lib/api'
import { mark } from '@hanzo/app/lib/mark'
import { say } from '@hanzo/app/lib/org'
import { bearer } from '@hanzo/app/lib/session'
import { paying } from '@hanzo/app/lib/tier'
import { wallets } from '@/lib/auth/wallet'
import { Address } from './Address'
import { Heading, Section } from './kit'
/** Fetched on "Create a memoji", with the AI client it draws through. */
const Memoji = dynamic(() => import('./Memoji'), { ssr: false })
export function General() {
const { user, updateUserProfile } = useAccount()
const { accessToken } = useIam()
// The wallets that sign this account in, as IAM's token names them.
const signers = useMemo(() => wallets(claimsOf(accessToken)), [accessToken])
const { t } = useI18n()
const [name, setName] = useState('')
const [bio, setBio] = useState('')
const [website, setWebsite] = useState('')
const [avatar, setAvatar] = useState('')
const [saving, setSaving] = useState(false)
const picker = useRef<HTMLInputElement>(null)
// A memoji of yourself is a paid plan's (./Memoji).
const [member, setMember] = useState(false)
const [memoji, setMemoji] = useState(false)
const [walletAddress, setWalletAddress] = useState('')
const [walletSaving, setWalletSaving] = useState(false)
useEffect(() => {
const stop = new AbortController()
void paying(stop.signal).then(setMember, () => setMember(false))
if (typeof window !== 'undefined') {
const saved = window.localStorage.getItem('hanzo.rewards.wallet')
if (saved) setWalletAddress(saved)
}
const token = bearer()
if (token) {
fetch(`${api()}/v1/iam/preferences`, {
headers: { Authorization: `Bearer ${token}` },
signal: stop.signal,
})
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
const w = body?.data?.wallet?.address || body?.data?.onboarding?.walletAddress
if (typeof w === 'string' && w.trim()) {
setWalletAddress(w.trim())
if (typeof window !== 'undefined') window.localStorage.setItem('hanzo.rewards.wallet', w.trim())
}
})
.catch(() => {})
}
return () => stop.abort()
}, [])
const saveWallet = async (addr: string) => {
const trimmed = addr.trim()
setWalletSaving(true)
try {
if (typeof window !== 'undefined') {
if (trimmed) window.localStorage.setItem('hanzo.rewards.wallet', trimmed)
else window.localStorage.removeItem('hanzo.rewards.wallet')
}
const token = bearer()
if (token) {
await fetch(`${api()}/v1/iam/preferences`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
wallet: { address: trimmed, updatedAt: new Date().toISOString() },
onboarding: { wallet: Boolean(trimmed), walletAddress: trimmed },
}),
})
}
setWalletAddress(trimmed)
toast.success(trimmed ? 'Rewards payout wallet connected' : 'Wallet disconnected')
} catch (e) {
toast.error(say(e))
} finally {
setWalletSaving(false)
}
}
const connectBrowserWallet = async () => {
const eth = typeof window !== 'undefined' ? (window as unknown as { ethereum?: { request?: (args: { method: string }) => Promise<string[]> } }).ethereum : undefined
if (!eth?.request) {
toast.error('No Web3 browser wallet detected. Paste your EVM or Solana address below.')
return
}
try {
const accounts = await eth.request({ method: 'eth_requestAccounts' })
if (Array.isArray(accounts) && accounts[0]) {
await saveWallet(accounts[0])
}
} catch (e) {
toast.error(say(e))
}
}
useEffect(() => {
if (!user) return
setName(user.name || '')
setBio(user.bio || '')
setWebsite(user.website || '')
setAvatar(user.avatar || '')
}, [user])
const save = async (patch: Parameters<typeof updateUserProfile>[0], done: string) => {
setSaving(true)
try {
await updateUserProfile(patch)
toast.success(done)
} catch (e) {
setAvatar(user?.avatar || '')
toast.error(say(e))
} finally {
setSaving(false)
}
}
const pick = async (file: File | undefined) => {
if (!file) return
try {
const image = await mark(file)
setAvatar(image)
await save({ avatar: image }, 'Picture updated')
} catch (e) {
toast.error(say(e))
}
}
return (
<>
<Heading title="General" lede="Your profile and how Hanzo looks to you, on every Hanzo surface you sign in to." />
<Section title="Profile" ruled={false}>
<XStack items="center" gap="$4" flexWrap="wrap">
<Avatar size={64}>
<AvatarImage src={avatar || undefined} />
<AvatarFallback>
<Text fontSize="$6" color="$ink">
{(name || user?.email || '?')[0].toUpperCase()}
</Text>
</AvatarFallback>
</Avatar>
<input
ref={picker}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
onChange={(e) => {
void pick(e.target.files?.[0])
e.target.value = ''
}}
/>
<XStack gap="$2" flexWrap="wrap">
<Button variant="outline" size="sm" disabled={saving} onClick={() => picker.current?.click()}>
Upload picture
</Button>
{member && !memoji ? (
<Button variant="outline" size="sm" disabled={saving} onClick={() => setMemoji(true)}>
Create a memoji
</Button>
) : null}
{avatar ? (
<Button
variant="ghost"
size="sm"
disabled={saving}
onClick={() => {
setAvatar('')
void save({ avatar: '' }, 'Picture removed')
}}
>
Remove
</Button>
) : null}
</XStack>
</XStack>
{member && memoji ? (
<Memoji
busy={saving}
onCancel={() => setMemoji(false)}
onDrawn={async (image) => {
setAvatar(image)
setMemoji(false)
await save({ avatar: image }, 'Memoji saved')
}}
/>
) : null}
<form
onSubmit={(e) => {
e.preventDefault()
void save({ name: name.trim(), bio, website: website.trim() }, 'Profile updated')
}}
>
<YStack gap="$5" maxW={576}>
<Field id="settings-name" label="Name">
<Input id="settings-name" value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" />
</Field>
<Field id="settings-email" label="Email">
<Input
id="settings-email"
type="email"
value={user?.email ?? ''}
placeholder={signers.length ? 'None. This account signs in with its wallet.' : undefined}
disabled
readOnly
/>
</Field>
{signers.map((w, i) => (
<Field key={`${w.chain}:${w.address}`} id={`settings-signer-${i}`} label={w.chain === 'evm' ? 'Sign-in wallet (Ethereum)' : `Sign-in wallet (${w.chain})`}>
<Input id={`settings-signer-${i}`} value={w.address} fontFamily="$mono" disabled readOnly />
</Field>
))}
<Field id="settings-bio" label="Bio">
<Textarea id="settings-bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="A line about you" />
</Field>
<Field id="settings-website" label="Website">
<Input
id="settings-website"
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://example.com"
/>
</Field>
<XStack>
<Button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save profile'}
</Button>
</XStack>
</YStack>
</form>
</Section>
<Address />
<Section title="Appearance">
<Look />
</Section>
{/* @hanzo/i18n's own picker, the one every Hanzo surface draws: the
language Hanzo's words are shown in, kept with the account. In a row,
so the field is as wide as its longest name rather than the page. */}
<Section title={t('Language')} lede={t('The language Hanzo is shown in, on every Hanzo surface you sign in to.')}>
<XStack>
<LanguagePicker />
</XStack>
</Section>
<Section
title="Web3 Wallet & Rewards Payouts"
lede="Connect your Web3 wallet (Ethereum, Base, Arbitrum, or Solana) to collect referral payouts, affiliate commissions, and compute rewards."
>
<YStack gap="$4" maxW={576}>
<XStack items="center" gap="$3" flexWrap="wrap">
<Button
variant="outline"
size="sm"
disabled={walletSaving}
onClick={() => void connectBrowserWallet()}
gap={8}
>
<Wallet size={16} aria-hidden />
<span>{walletAddress ? 'Switch Web3 Wallet' : 'Connect Web3 Wallet'}</span>
</Button>
{walletAddress ? (
<XStack items="center" gap="$1.5" px="$2.5" py="$1" rounded={9999} borderWidth={1} borderColor="$edge">
<Check size={14} color="var(--state-online)" aria-hidden />
<Text fontSize="$2" fontFamily="$mono" color="$ink">
{walletAddress.length > 16 ? `${walletAddress.slice(0, 8)}…${walletAddress.slice(-6)}` : walletAddress}
</Text>
</XStack>
) : null}
</XStack>
<form
onSubmit={(e) => {
e.preventDefault()
void saveWallet(walletAddress)
}}
>
<YStack gap="$3">
<Field id="settings-wallet" label="Payout Wallet Address (0x… or Solana)">
<Input
id="settings-wallet"
value={walletAddress}
onChange={(e) => setWalletAddress(e.target.value)}
placeholder="0x… or Solana wallet address for rewards payouts"
/>
</Field>
<XStack gap="$2">
<Button type="submit" size="sm" disabled={walletSaving}>
{walletSaving ? 'Saving…' : 'Save payout wallet'}
</Button>
{walletAddress ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={walletSaving}
onClick={() => void saveWallet('')}
>
Disconnect
</Button>
) : null}
</XStack>
</YStack>
</form>
</YStack>
</Section>
<Section title="Organization">
<YStack gap="$3" maxW={576}>
<Text fontSize="$3" color="$soft">
Manage your organization details, members, billing, usage and API keys.
</Text>
<XStack>
<Text
render={<Link href="/settings/organization" />}
display="inline"
textDecorationLine="none"
>
<Button variant="outline" size="sm" gap={8}>
<Building2 size={16} aria-hidden />
<span>Go to Organization settings</span>
<ArrowRight size={14} aria-hidden data-i18n-mirror />
</Button>
</Text>
</XStack>
</YStack>
</Section>
</>
)
}