Files
hanzo.ai/components/settings/Members.tsx
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

388 lines
14 KiB
TypeScript

'use client'
/**
* Members and invitations, on IAM's own surface (lib/hanzo/team.ts).
*
* Two ways in, both invitations an owner or admin makes (HIP-0527 §3): one
* emailed to an address, which works once for it, and one shared link up to
* LINK_SEATS people can join by. Everything here reads and
* writes IAM with the reader's own bearer: the roster, the invitations, a role
* change and a removal. A member sees the roster; the controls that change it
* are drawn for an owner or admin, and IAM decides every write regardless.
*/
import Link from 'next/link'
import { useCallback, useEffect, useState } from 'react'
import { MoreHorizontal } from 'lucide-react'
import {
Box,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Text,
XStack,
YStack,
toast,
} from '@hanzo/ui'
import { me } from '@hanzo/app/lib/session'
import {
InviteRefusal,
LINK_SEATS,
createInvitation,
inviteLink,
listInvitations,
members,
removeMember,
sendInvitation,
setRole,
shareable,
withdrawInvitation,
type Invitation,
type Member,
} from '@hanzo/app/lib/invite'
import { say } from '@hanzo/app/lib/org'
import { said, useStanding } from './Frame'
import { Confirm, Copy, Heading, Initial, Line, Section, Tag, day } from './kit'
import { ORGANIZATION } from './nav'
/** A role's rank. A role is given only up to the giver's own (HIP-0527 §3). */
const RANK: Record<string, number> = { member: 1, admin: 2, owner: 3 }
const ROLES = ['owner', 'admin', 'member'] as const
export function Members() {
const { org, admin, role, personal, name, email } = useStanding()
const [people, setPeople] = useState<Member[] | null>(null)
const [wrong, setWrong] = useState<string | null>(null)
const [invites, setInvites] = useState<Invitation[] | null>(null)
const [invitesWrong, setInvitesWrong] = useState<string | null>(null)
const [address, setAddress] = useState('')
const [sending, setSending] = useState(false)
const [unsent, setUnsent] = useState<{ inv: Invitation; reason: string } | null>(null)
const [leaving, setLeaving] = useState<Member | null>(null)
const [linkBusy, setLinkBusy] = useState(false)
const load = useCallback(async () => {
if (!org) return
try {
setPeople(await members(org, { name, email }))
setWrong(null)
} catch (e) {
setWrong(say(e))
}
if (!admin) return
try {
const rows = await listInvitations(org)
setInvites(rows.filter((r) => r.state === 'Active' && r.usedCount < r.quota))
setInvitesWrong(null)
} catch (e) {
setInvitesWrong(say(e))
}
}, [org, admin, name, email])
useEffect(() => {
void load()
}, [load])
if (!org) return null
if (personal === true) {
return (
<>
<Heading title="Members" lede="A personal organization holds one person: you." />
<Section title="Invite teammates" ruled={false} lede="Start a team organization to invite people and manage their roles. Your personal organization stays yours.">
<Link href={ORGANIZATION}>
<Text fontSize="$3" color="$ink" textDecorationLine="underline">
Convert to team on the Organization page
</Text>
</Link>
</Section>
</>
)
}
const invite = async (e: React.FormEvent) => {
e.preventDefault()
const to = address.trim()
if (!to || sending) return
setSending(true)
setUnsent(null)
let inv: Invitation
try {
inv = await createInvitation(org, to)
} catch (err) {
toast.error(say(err))
setSending(false)
return
}
try {
const sent = await sendInvitation(inv)
toast.success(`Invitation sent to ${sent || to}`)
setAddress('')
} catch (err) {
setUnsent({ inv, reason: say(err) })
} finally {
setSending(false)
void load()
}
}
const resend = async (inv: Invitation) => {
try {
toast.success(`Invitation sent to ${(await sendInvitation(inv)) || inv.email}`)
} catch (err) {
toast.error(err instanceof InviteRefusal && err.status === 429 ? err.message : `The email to ${inv.email} was not sent: ${say(err)}`)
}
}
const revoke = async (inv: Invitation) => {
try {
await withdrawInvitation(inv)
toast.success('Invitation revoked. Its link no longer works.')
void load()
} catch (err) {
toast.error(say(err))
}
}
const change = async (m: Member, next: string) => {
try {
const held = await setRole(org, m.id, next)
if (held === next) toast.success(`${m.name} is now ${said(next).toLowerCase()}`)
else toast.error(`IAM kept ${m.name} as ${held ?? m.role}. It does not change an existing member's role yet.`)
void load()
} catch (err) {
toast.error(say(err))
}
}
// The shared link: one open invitation for LINK_SEATS joins. A reset withdraws
// the old code before the new one is made, so the old link stops working even
// if making the new one fails.
const shared = shareable(invites ?? [])
const pending = (invites ?? []).filter((r) => r !== shared)
const link = async (reset: boolean) => {
if (linkBusy) return
setLinkBusy(true)
try {
if (reset && shared) await withdrawInvitation(shared)
await createInvitation(org, undefined, LINK_SEATS)
if (reset) toast.success('A new invite link is ready. The old one no longer works.')
} catch (err) {
toast.error(say(err))
} finally {
setLinkBusy(false)
void load()
}
}
const rank = RANK[role ?? ''] ?? 0
const self = me()
return (
<>
<Heading
title="Members"
lede={admin ? 'Who can act in this organization, and the invitations still open.' : 'Who can act in this organization. An owner or admin invites and removes people.'}
/>
{admin ? (
<Section title="Invite by email" ruled={false} lede="IAM emails a link that joins this organization. It works once, for that address.">
<form onSubmit={invite}>
<XStack gap="$2" flexWrap="wrap" items="center">
<Input
type="email"
required
value={address}
onChange={(e) => {
setAddress(e.target.value)
setUnsent(null)
}}
placeholder="[email protected]"
aria-label="Email address to invite"
grow={1}
flexBasis={220}
/>
<Button type="submit" disabled={sending || !address.trim()}>
{sending ? 'Sending…' : 'Send invite'}
</Button>
</XStack>
</form>
{unsent ? (
<YStack role="alert" gap="$2" p="$3" rounded="$4" bg="$sunken" borderWidth={1} borderColor="$edge">
<Text fontSize="$3" color="$ink">
The invitation for {unsent.inv.email} was made, and the email was not sent: {unsent.reason}
</Text>
<XStack items="center" gap="$2">
<Text fontSize="$2" color="$soft">
Send them the link yourself:
</Text>
<Copy value={inviteLink(unsent.inv.owner, unsent.inv.code)} label="Copy invitation link" />
</XStack>
</YStack>
) : null}
</Section>
) : null}
{admin ? (
<Section
title="Invite link"
lede={
shared
? `Anyone with this link can join · ${shared.usedCount} of ${shared.quota} used.`
: `One link up to ${LINK_SEATS} people can join by.`
}
>
<YStack render="section" aria-label="Invite link" gap="$2">
{shared ? (
<XStack items="center" gap="$2" flexWrap="wrap">
<Text flex={1} minW={0} fontSize="$2" fontFamily="$mono" color="$ink" numberOfLines={1}>
{inviteLink(shared.owner, shared.code)}
</Text>
<Copy value={inviteLink(shared.owner, shared.code)} label="Copy invite link" />
<Button variant="outline" size="sm" disabled={linkBusy} onClick={() => void link(true)}>
{linkBusy ? 'Resetting…' : 'Reset link'}
</Button>
</XStack>
) : invites === null ? null : (
<XStack>
<Button onClick={() => void link(false)} disabled={linkBusy}>
{linkBusy ? 'Creating…' : 'Create invite link'}
</Button>
</XStack>
)}
</YStack>
</Section>
) : null}
{admin ? (
<Section title="Pending invitations" lede={pending.length > 0 ? `${pending.length} not yet accepted.` : undefined}>
{invitesWrong ? <Line tone="bad">{invitesWrong}</Line> : null}
{invites === null && !invitesWrong ? <Line>Reading invitations…</Line> : null}
{invites && pending.length === 0 ? <Line>No invitations are open.</Line> : null}
{pending.map((inv) => (
<XStack
key={`${inv.owner}/${inv.name}`}
items="center"
gap="$3"
py="$2.5"
borderBottomWidth={1}
borderColor="$edge"
flexWrap="wrap"
>
<YStack flex={1} minW={180}>
<Text fontSize="$3" color="$ink" numberOfLines={1}>
{inv.email || 'Anyone with the link'}
</Text>
<Text fontSize="$2" color="$soft">
{inv.createdTime ? `Invited ${day(inv.createdTime)}` : 'Invited'}
</Text>
</YStack>
<XStack items="center" gap="$1">
<Copy value={inviteLink(inv.owner, inv.code)} label={`Copy the invitation link for ${inv.email || 'anyone'}`} />
{inv.email ? (
<Button variant="ghost" size="sm" onClick={() => void resend(inv)}>
Resend
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={() => void revoke(inv)}>
Revoke
</Button>
</XStack>
</XStack>
))}
</Section>
) : null}
<Section title="People" ruled={admin} lede={people ? `${people.length} ${people.length === 1 ? 'person' : 'people'}.` : undefined}>
{wrong ? <Line tone="bad">{wrong}</Line> : null}
{people === null && !wrong ? <Line>Reading the roster…</Line> : null}
{people && people.length === 0 ? <Line>IAM lists nobody in this organization.</Line> : null}
{(people ?? []).map((m) => {
const mine = m.id === self
const giveable = ROLES.filter((r) => r !== m.role && RANK[r] <= rank)
// Someone whose account lives here holds the org through the account, so
// neither a role write nor a removal reaches them (store.IsHomeOrg).
const acts = admin && !mine && !m.home
return (
<XStack key={m.id} items="center" gap="$3" py="$2.5" borderBottomWidth={1} borderColor="$edge">
<Initial name={m.name} />
<YStack flex={1} minW={0}>
<Text fontSize="$3" color="$ink" numberOfLines={1}>
{m.name}
{mine ? ' (you)' : ''}
</Text>
<Text fontSize="$2" color="$soft" numberOfLines={1}>
{m.email || m.id}
</Text>
</YStack>
<Tag>{said(m.role)}</Tag>
{acts ? (
<DropdownMenu placement="bottom-end">
<DropdownMenuTrigger asChild>
<Box
render="button"
aria-label={`Actions for ${m.name}`}
width={36}
height={36}
rounded="$4"
items="center"
justify="center"
bg="transparent"
borderWidth={0}
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
>
<MoreHorizontal size={16} aria-hidden color="var(--foreground)" />
</Box>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" minW={220}>
{giveable.map((r) => (
<DropdownMenuItem key={r} onSelect={() => void change(m, r)}>
<Text fontSize="$3" color="$ink">
Make {said(r).toLowerCase()}
</Text>
</DropdownMenuItem>
))}
{giveable.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem variant="destructive" onSelect={() => setLeaving(m)}>
<Text fontSize="$3" color="$bad">
Remove from organization
</Text>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Box width={36} shrink={0} />
)}
</XStack>
)
})}
{admin && (people ?? []).some((m) => m.home) ? (
<Line>
An account that lives in this organization takes its role from the account and is not removed here; IAM ends
that access by disabling the account.
</Line>
) : null}
</Section>
<Confirm
open={leaving !== null}
title={`Remove ${leaving?.name ?? ''}?`}
body="Their account stays. Their access to this organization, and any key they hold in it, ends."
act="Remove"
onClose={() => setLeaving(null)}
onConfirm={async () => {
if (!leaving) return
await removeMember(org, leaving.id)
toast.success(`${leaving.name} was removed`)
void load()
}}
/>
</>
)
}