Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

354 lines
13 KiB
TypeScript

'use client'
/**
* Organization: its logo, its name, where it does business, its ID, and the two
* acts that change what it is — deleting it, or (for an org of one) starting a
* team.
*
* The name is IAM's display name, written through the profile route that
* touches no other field (lib/hanzo/team.ts `setOrganizationProfile`); the handle
* under it never changes, and it is the ID. The address has no column on IAM's
* row, so it is kept in cloud's per-org settings (lib/hanzo/org.ts). An owner or
* admin edits both; a member reads them.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { Building } from 'lucide-react'
import {
Button,
Dialog,
DialogContent,
DialogTitle,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Text,
XStack,
YStack,
toast,
} from '@hanzo/ui'
import { Field } from '@/components/account/form'
import { orgs, pick, renew, superAdmin } from '@hanzo/app/lib/session'
import { setOrganizationMark, setOrganizationProfile } from '@hanzo/app/lib/invite'
import { mark } from '@hanzo/app/lib/mark'
import {
EMPTY_ADDRESS,
deleteOrg,
createOrg,
readAddress,
saveAddress,
say,
slug,
type Address,
} from '@hanzo/app/lib/org'
import { useStanding } from './Frame'
import { Confirm, Heading, Line, Section, Value } from './kit'
import { ORGANIZATION } from './nav'
/** ISO 3166-1 alpha-2, every assigned code; the names come from the browser's own table. */
const REGIONS =
'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(
' ',
)
function useRegions(): { code: string; name: string }[] {
return useMemo(() => {
const names = new Intl.DisplayNames(['en'], { type: 'region' })
return REGIONS.map((code) => ({ code, name: names.of(code) ?? code })).sort((a, b) => a.name.localeCompare(b.name))
}, [])
}
export function Organization() {
const { org, admin, row, rowWrong, personal, reloadRow } = useStanding()
const regions = useRegions()
const [name, setName] = useState('')
const [address, setAddress] = useState<Address>(EMPTY_ADDRESS)
const [stored, setStored] = useState<Address | null>(null)
const [addressWrong, setAddressWrong] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [founding, setFounding] = useState(false)
const picking = useRef<HTMLInputElement | null>(null)
const [logo, setLogo] = useState<string | null>(null)
const [logoBusy, setLogoBusy] = useState(false)
useEffect(() => {
if (row) setName(row.displayName || row.name)
}, [row])
useEffect(() => {
if (!org) return
let live = true
setAddressWrong(null)
readAddress()
.then((a) => {
if (!live) return
setAddress(a)
setStored(a)
})
.catch((e: unknown) => live && setAddressWrong(say(e)))
return () => {
live = false
}
}, [org])
if (!org) return null
const edit = (k: keyof Address) => (e: { target: { value: string } }) => setAddress((a) => ({ ...a, [k]: e.target.value }))
const renamed = Boolean(row) && name.trim() !== (row?.displayName || row?.name) && name.trim() !== ''
const moved = stored !== null && JSON.stringify(address) !== JSON.stringify(stored)
const save = async (e: React.FormEvent) => {
e.preventDefault()
if (!admin || saving) return
setSaving(true)
try {
if (renamed) {
await setOrganizationProfile('admin', org, { displayName: name.trim() })
reloadRow()
}
if (moved) setStored(await saveAddress(address))
toast.success('Organization saved')
} catch (err) {
toast.error(say(err))
} finally {
setSaving(false)
}
}
const locked = !admin
// The logo goes through `mark`, the crop-and-encode a person's photograph takes
// (square, 256px, inside the 96 KiB IAM's row holds), and IAM's avatar route,
// which writes that field and no other. Organizations are filed under `admin`.
const upload = async (file: File | undefined) => {
if (!file || logoBusy) return
setLogoBusy(true)
try {
const avatar = await mark(file)
await setOrganizationMark('admin', org, { avatar })
setLogo(avatar)
reloadRow()
toast.success('Logo updated')
} catch (err) {
toast.error(say(err))
} finally {
setLogoBusy(false)
}
}
const shown = logo ?? row?.avatar
return (
<>
<Heading
title="Organization"
lede={admin ? 'How the organization reads across Hanzo, and where it does business.' : 'Only an owner or admin of this organization changes these.'}
/>
{rowWrong ? <Line tone="bad">{rowWrong}</Line> : null}
<XStack items="center" gap="$4" mb="$5">
<YStack width={64} height={64} items="center" justify="center" rounded="$4" bg="$raised" overflow="hidden" shrink={0}>
{shown ? (
<YStack render="img" {...{ src: shown, alt: '' }} width="100%" height="100%" objectFit="cover" />
) : (
<Building size={28} aria-hidden color="var(--muted-foreground)" />
)}
</YStack>
{admin ? (
<>
<input
ref={picking}
type="file"
hidden
accept="image/png,image/jpeg,image/gif,image/webp"
aria-label="Organization logo"
onChange={(e) => {
void upload(e.target.files?.[0])
e.target.value = ''
}}
/>
<Button variant="outline" disabled={logoBusy || !row} onClick={() => picking.current?.click()}>
{logoBusy ? 'Uploading…' : 'Upload logo'}
</Button>
</>
) : null}
</XStack>
<form onSubmit={save}>
<YStack gap="$5" maxW={576}>
<Field id="org-name" label="Organization name">
<Input id="org-name" value={name} onChange={(e) => setName(e.target.value)} disabled={locked || !row} />
</Field>
<YStack gap="$3">
<Text render="label" htmlFor="org-line1" fontSize="$3" fontWeight="500" color="$ink">
Primary business address
</Text>
<Input id="org-line1" aria-label="Address line 1" placeholder="Line 1" value={address.line1} onChange={edit('line1')} disabled={locked} autoComplete="address-line1" />
<Input aria-label="Address line 2" placeholder="Line 2" value={address.line2} onChange={edit('line2')} disabled={locked} autoComplete="address-line2" />
{/* One row where the width allows three, wrapping to one per line on a phone. */}
<XStack gap="$3" flexWrap="wrap">
<YStack grow={2} flexBasis={150}>
<Field id="org-city" label="City">
<Input id="org-city" value={address.city} onChange={edit('city')} disabled={locked} autoComplete="address-level2" />
</Field>
</YStack>
<YStack grow={2} flexBasis={150}>
<Field id="org-state" label="State or province">
<Input id="org-state" value={address.state} onChange={edit('state')} disabled={locked} autoComplete="address-level1" />
</Field>
</YStack>
<YStack grow={1} flexBasis={110}>
<Field id="org-postal" label="Postal code">
<Input id="org-postal" value={address.postalCode} onChange={edit('postalCode')} disabled={locked} autoComplete="postal-code" />
</Field>
</YStack>
</XStack>
</YStack>
<YStack gap="$2">
<Text fontSize="$3" fontWeight="500" color="$ink">
Country
</Text>
<Select value={address.country || undefined} onValueChange={(v: string) => setAddress((a) => ({ ...a, country: v }))} disabled={locked}>
<SelectTrigger aria-label="Country" width="100%">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{regions.map((r) => (
<SelectItem key={r.code} value={r.code}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</YStack>
{addressWrong ? <Line tone="bad">The address could not be read: {addressWrong}</Line> : null}
{admin ? (
<XStack>
<Button type="submit" disabled={saving || (!renamed && !moved)}>
{saving ? 'Saving…' : 'Save'}
</Button>
</XStack>
) : null}
</YStack>
</form>
<XStack items="center" gap="$2" flexWrap="wrap">
<Text fontSize="$3" color="$soft">
Organization ID:
</Text>
<Value value={org} label="Copy organization ID" />
</XStack>
{admin && personal === false && org !== 'admin' ? (
<Section title="Delete organization" lede="Removes the organization and everything named inside it from IAM. Every session issued under it stops working. There is no undo.">
<XStack>
<Button variant="destructive" onClick={() => setDeleting(true)}>
Delete organization
</Button>
</XStack>
<Confirm
open={deleting}
title="Delete this organization?"
body={`${row?.displayName || org} and everything named inside it are removed from IAM.`}
act="Delete organization"
typed={org}
onClose={() => setDeleting(false)}
onConfirm={async () => {
await deleteOrg(org)
const next = orgs().find((o) => o !== org)
if (next) pick(next)
renew()
window.location.assign('/')
}}
/>
</Section>
) : null}
{personal === true && !superAdmin() ? (
<Section
title="Convert to team organization"
lede="Invite teammates and manage members."
action={<Button variant="outline" onClick={() => setFounding(true)}>Convert to team</Button>}
>
<Found
open={founding}
onClose={() => setFounding(false)}
onFounded={(made) => {
// The token in hand predates the org; the next load mints one that carries it.
renew()
pick(made)
window.location.assign(`${ORGANIZATION}/members`)
}}
/>
</Section>
) : null}
</>
)
}
/**
* Starts a team: a new organization the reader founds and owns. Their personal
* organization stays theirs — an org of one is where a person's own wallet lives
* (HIP-0527 I4), so a team is a second org beside it, not the first one renamed.
*/
function Found({ open, onClose, onFounded }: { open: boolean; onClose: () => void; onFounded: (org: string) => void }) {
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const [refused, setRefused] = useState<string | null>(null)
const handle = slug(name)
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : onClose())}>
<DialogContent showCloseButton={false} width="calc(100vw - 32px)" maxW={480} rounded="$6" p="$5" gap="$4">
<DialogTitle fontSize="$6" fontWeight="500" color="$ink">
Start a team organization
</DialogTitle>
<Text render="p" m={0} fontSize="$3" color="$soft">
You own it and invite the team into it. Your personal organization stays yours, with its own balance.
</Text>
<form
onSubmit={async (e) => {
e.preventDefault()
if (busy || handle.length < 2) return
setBusy(true)
setRefused(null)
try {
onFounded(await createOrg(name.trim()))
} catch (err) {
setRefused(say(err))
setBusy(false)
}
}}
>
<YStack gap="$4">
<Field id="team-name" label="Team name">
<Input id="team-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Acme" autoFocus />
</Field>
{handle ? (
<Text fontSize="$2" color="$soft">
Organization ID: {handle}
</Text>
) : null}
{refused ? <Line tone="bad">{refused}</Line> : null}
<XStack justify="flex-end" gap="$2">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" disabled={busy || handle.length < 2}>
{busy ? 'Creating…' : 'Create team'}
</Button>
</XStack>
</YStack>
</form>
</DialogContent>
</Dialog>
)
}