Spreadsheet-UX op een echte relationele engine

main

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

7.0 kB Ruw bestand
import { useState, type ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { ROLES, type AgentKey, type Role } from '@wellformed/shared';
import { api } from '../api/client';
import { useAgentKeys } 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';

/**
 * The keys an AI assistant works through.
 *
 * The thing worth being clear about on this screen is that a key is *yours*.
 * It acts as you, with your role, and everything it does lands in the audit
 * trail under your name with the key's label beside it. So the label is not
 * decoration — it is how someone reading the trail in three months knows which
 * assistant did what.
 */
export function AgentKeysDialog({ onClose }: { onClose(): void }): ReactNode {
  const session = useSession();
  const toasts = useToasts();
  const queryClient = useQueryClient();
  const { t } = useI18n();
  const isAdmin = session.can('admin');
  const myRole = session.workspace?.role ?? 'viewer';

  const keys = useAgentKeys(true);

  const [creating, setCreating] = useState(false);
  const [label, setLabel] = useState('');
  const [ceiling, setCeiling] = useState<Role>('viewer');
  const [busy, setBusy] = useState(false);
  /** Shown until this dialog closes, because there is no second chance to read it. */
  const [issued, setIssued] = useState<AgentKey | null>(null);

  const refresh = () => queryClient.invalidateQueries({ queryKey: ['agentKeys'] });

  /** A ceiling above your own role would be inert, so it is not offered. */
  const available = ROLES.filter((r) => rank(r) <= rank(myRole));

  async function create() {
    setBusy(true);
    try {
      const { key } = await api.createAgentKey({ label: label.trim(), ceiling });
      await refresh();
      setIssued(key);
      setCreating(false);
      setLabel('');
      await copy(key.secret!);
      toasts.push(t('keys.copied'), 'success');
    } catch (error) {
      toasts.fail(error);
    } finally {
      setBusy(false);
    }
  }

  async function revoke(key: AgentKey) {
    const ok = window.confirm(t('keys.revokeConfirm', key.label));
    if (!ok) return;
    try {
      await api.revokeAgentKey(key.id);
      if (issued?.id === key.id) setIssued(null);
      await refresh();
    } catch (error) {
      toasts.fail(error);
    }
  }

  const rows = keys.data?.keys ?? [];

  return (
    <Modal
      title={t('keys.title')}
      wide
      onClose={onClose}
      footer={
        <>
          <div className="grow" />
          {!creating && (
            <button className="btn" onClick={() => setCreating(true)}>
              {t('keys.newKey')}
            </button>
          )}
          <button className="btn primary" onClick={onClose}>
            {t('common.done')}
          </button>
        </>
      }
    >
      <div className="mb">
        <p className="hint" style={{ marginTop: 0 }}>
          {t('keys.intro')}
        </p>

        {keys.isLoading && <div className="hint">{t('common.loading')}</div>}

        {!keys.isLoading && rows.length === 0 && !creating && (
          <div className="gridstate">{t('keys.noneYet')}</div>
        )}

        {rows.length > 0 && (
          <table className="plain">
            <thead>
              <tr>
                <th>{t('keys.label')}</th>
                <th>{t('keys.can')}</th>
                {isAdmin && <th>{t('keys.heldBy')}</th>}
                <th>{t('keys.lastUsed')}</th>
                <th />
              </tr>
            </thead>
            <tbody>
              {rows.map((key) => (
                <tr key={key.id}>
                  <td>{key.label}</td>
                  <td>{t(`role.${key.ceiling}`)}</td>
                  {isAdmin && (
                    <td className="hint" style={{ fontWeight: 400 }}>
                      {key.accountId === session.account?.id ? t('keys.you') : key.accountName}
                    </td>
                  )}
                  <td className="hint" style={{ fontWeight: 400 }}>
                    {key.lastUsedAt ? key.lastUsedAt.replace(' ', ' · ') : t('keys.never')}
                  </td>
                  <td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
                    <button
                      className="iconbtn danger"
                      style={{ padding: '3px 7px' }}
                      onClick={() => void revoke(key)}
                    >
                      {t('keys.revoke')}
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}

        {issued?.secret && (
          <div className="notice">
            <div className="hint">{t('keys.copyNow')}</div>
            <input
              className="mono"
              readOnly
              aria-label={t('keys.newKeyAria')}
              value={issued.secret}
              onFocus={(e) => e.target.select()}
            />
            <div className="hint" style={{ marginTop: 8 }}>
              {t('keys.pointBefore')}
              <span className="mono">{absolute(`/api/w/${session.workspace?.slug ?? ''}/ai/manifest`)}</span>
              {t('keys.pointMiddle')}
              <span className="mono">Authorization: Bearer &lt;key&gt;</span>
              {t('keys.pointAfter')}
            </div>
          </div>
        )}

        {creating && (
          <div style={{ borderTop: '1px solid var(--line)', paddingTop: 12, display: 'grid', gap: 10 }}>
            <label>
              {t('keys.whatFor')}
              <input
                value={label}
                onChange={(e) => setLabel(e.target.value)}
                placeholder="Claude Code"
                autoFocus
              />
            </label>
            <label>
              {t('keys.itMay')}
              <select value={ceiling} onChange={(e) => setCeiling(e.target.value as Role)}>
                {available.map((r) => (
                  <option key={r} value={r}>
                    {t(`role.${r}`)} — {t(`keys.ceilingHelp.${r}`)}
                  </option>
                ))}
              </select>
            </label>
            <span className="hint">{t('keys.ceilingHint', t(`role.${myRole}`))}</span>
            <div className="rowend">
              <button className="btn" onClick={() => setCreating(false)}>
                {t('common.cancel')}
              </button>
              <button
                className="btn primary"
                onClick={() => void create()}
                disabled={busy || !label.trim()}
              >
                {t('keys.createKey')}
              </button>
            </div>
          </div>
        )}
      </div>
    </Modal>
  );
}

function rank(role: Role): number {
  return ROLES.indexOf(role);
}

async function copy(text: string): Promise<void> {
  try {
    await navigator.clipboard.writeText(text);
  } catch {
    // Clipboard access can be refused; the key is on screen either way.
  }
}