import { useState, type ReactNode } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { ROLES, type Member, type PendingInvite, type Role } from '@wellformed/shared'; import { api } from '../api/client'; import { useInvites, useMembers } from '../api/hooks'; import { useI18n } from '../i18n'; import { useSession } from '../state/session'; import { useToasts } from '../state/toasts'; import { absolute } from '../state/route'; import { Modal } from './Modal'; /** * Who is in this workspace. * * Notice what an admin can and cannot do here. They can change someone's role * in *this* workspace and remove them from it. They cannot reset a password or * disable an account: those belong to the person, not to the workspace, and * the same account may well be running someone else's workspace next door. */ export function PeopleDialog({ onClose }: { onClose(): void }): ReactNode { const session = useSession(); const toasts = useToasts(); const queryClient = useQueryClient(); const { t } = useI18n(); const isAdmin = session.can('admin'); const members = useMembers(true); const invites = useInvites(isAdmin); const [inviting, setInviting] = useState(false); const [email, setEmail] = useState(''); const [role, setRole] = useState('editor'); const [busy, setBusy] = useState(false); const [lastLink, setLastLink] = useState(null); const refresh = () => Promise.all([ queryClient.invalidateQueries({ queryKey: ['members'] }), queryClient.invalidateQueries({ queryKey: ['invites'] }), ]); async function invite() { setBusy(true); try { const { invite: created } = await api.createInvite({ email: email.trim(), role }); await refresh(); const link = absolute(created.acceptPath!); setLastLink(link); setInviting(false); setEmail(''); await copy(link); toasts.push(t('people.linkCopied'), 'success'); } catch (error) { toasts.fail(error); } finally { setBusy(false); } } async function setRoleFor(member: Member, next: Role) { try { await api.updateMember(member.id, { role: next }); await refresh(); // Demoting yourself changes what the rest of the app may show. if (member.id === session.account?.id) await session.refresh(); } catch (error) { toasts.fail(error); } } async function remove(member: Member) { const ok = window.confirm(t('people.removeConfirm', member.email)); if (!ok) return; try { await api.removeMember(member.id); await refresh(); } catch (error) { toasts.fail(error); } } async function revoke(pending: PendingInvite) { try { await api.revokeInvite(pending.id); await refresh(); } catch (error) { toasts.fail(error); } } return (
{isAdmin && !inviting && ( )} } >
{members.isLoading &&
{t('common.loading')}
} {!members.isLoading && ( {(members.data?.members ?? []).map((member) => ( ))}
{t('common.name')} {t('common.email')} {t('common.role')} {t('people.lastSeen')}
{member.name} {member.id === session.account?.id && ( {t('people.you')} )} {member.email} {isAdmin ? ( ) : ( t(`role.${member.role}`) )} {member.lastSeenAt ? member.lastSeenAt.replace(' ', ' · ') : t('people.never')} {isAdmin && member.id !== session.account?.id && ( )}
)} {isAdmin && (invites.data?.invites.length ?? 0) > 0 && ( <>

{t('people.pendingHead')}

{invites.data!.invites.map((pending) => ( ))}
{pending.email} {t(`role.${pending.role}`)} {t('people.expires', pending.expiresAt.slice(0, 10))}
)} {lastLink && (
{t('people.linkHint')}
e.target.select()} />
)} {inviting && (
{t('people.noEmailHint')}
)}
); } async function copy(text: string): Promise { try { await navigator.clipboard.writeText(text); } catch { // Clipboard access can be refused; the link is on screen either way. } }