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.
367 lines
13 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|