Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<Role>('editor');
const [busy, setBusy] = useState(false);
const [lastLink, setLastLink] = useState<string | null>(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 (
<Modal
title={t('people.title', session.workspace?.name ?? t('people.thisWorkspace'))}
wide
onClose={onClose}
footer={
<>
<div className="grow" />
{isAdmin && !inviting && (
<button className="btn" onClick={() => setInviting(true)}>
{t('people.inviteSomeone')}
</button>
)}
<button className="btn primary" onClick={onClose}>
{t('common.done')}
</button>
</>
}
>
<div className="mb">
{members.isLoading && <div className="hint">{t('common.loading')}</div>}
{!members.isLoading && (
<table className="plain">
<thead>
<tr>
<th>{t('common.name')}</th>
<th>{t('common.email')}</th>
<th>{t('common.role')}</th>
<th>{t('people.lastSeen')}</th>
<th />
</tr>
</thead>
<tbody>
{(members.data?.members ?? []).map((member) => (
<tr key={member.id} className={member.disabled ? 'disabled' : undefined}>
<td>
{member.name}
{member.id === session.account?.id && (
<span className="hint" style={{ display: 'inline', marginLeft: 6 }}>
{t('people.you')}
</span>
)}
</td>
<td className="mono" style={{ fontSize: 11.5 }}>
{member.email}
</td>
<td>
{isAdmin ? (
<select
value={member.role}
aria-label={t('people.roleFor', member.email)}
onChange={(e) => void setRoleFor(member, e.target.value as Role)}
>
{ROLES.map((r) => (
<option key={r} value={r} title={t(`people.roleHelp.${r}`)}>
{t(`role.${r}`)}
</option>
))}
</select>
) : (
t(`role.${member.role}`)
)}
</td>
<td className="hint" style={{ fontWeight: 400 }}>
{member.lastSeenAt ? member.lastSeenAt.replace(' ', ' · ') : t('people.never')}
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{isAdmin && member.id !== session.account?.id && (
<button
className="iconbtn danger"
style={{ padding: '3px 7px' }}
onClick={() => void remove(member)}
>
{t('common.remove')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{isAdmin && (invites.data?.invites.length ?? 0) > 0 && (
<>
<h4 className="sectionhead">{t('people.pendingHead')}</h4>
<table className="plain">
<tbody>
{invites.data!.invites.map((pending) => (
<tr key={pending.id}>
<td className="mono" style={{ fontSize: 11.5 }}>
{pending.email}
</td>
<td>{t(`role.${pending.role}`)}</td>
<td className="hint" style={{ fontWeight: 400 }}>
{t('people.expires', pending.expiresAt.slice(0, 10))}
</td>
<td style={{ textAlign: 'right' }}>
<button
className="iconbtn danger"
style={{ padding: '3px 7px' }}
onClick={() => void revoke(pending)}
>
{t('people.revoke')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
{lastLink && (
<div className="notice">
<div className="hint">{t('people.linkHint')}</div>
<input className="mono" readOnly value={lastLink} onFocus={(e) => e.target.select()} />
</div>
)}
{inviting && (
<div style={{ borderTop: '1px solid var(--line)', paddingTop: 12, display: 'grid', gap: 10 }}>
<label>
{t('common.email')}
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" autoFocus />
</label>
<label>
{t('common.role')}
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
{ROLES.map((r) => (
<option key={r} value={r}>
{t(`role.${r}`)} — {t(`people.roleHelp.${r}`)}
</option>
))}
</select>
</label>
<span className="hint">{t('people.noEmailHint')}</span>
<div className="rowend">
<button className="btn" onClick={() => setInviting(false)}>
{t('common.cancel')}
</button>
<button
className="btn primary"
onClick={() => void invite()}
disabled={busy || !email.trim()}
>
{t('people.createLink')}
</button>
</div>
</div>
)}
</div>
</Modal>
);
}
async function copy(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard access can be refused; the link is on screen either way.
}
}