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.
388 lines
14 KiB
TypeScript
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()
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|