mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
354 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|