Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

8.6 kB Ruw bestand
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.
  }
}