vtc-service 0.10.13

Service for Verifiable Trust Communities
// Invitations plugin — issue an Invitation Credential (VIC) to a prospective
// member, then hand it off out-of-band (copy / download / QR).
//
// The operator enters an invitee DID (and optional validity); the VTC mints a
// short-lived, revocable VIC bound to that DID. The invitee presents it back in
// a join request and is auto-admitted by the default `join.rego` (a verified,
// trusted, unconsumed invitation → allow). See `routes/invitations.rs`.

import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, Ticket, Trash2 } from "lucide-react";

import {
  issueInvitation,
  listInvitations,
  revokeInvitation,
  type InvitationListItem,
  type IssueInvitationResponse,
} from "@/lib/api";
import { CopyButton } from "@/components/CopyButton";
import { useToast } from "@/lib/toast";

/// QR capacity ceiling: a version-40 QR at error-correction level L holds
/// ~2,953 bytes. A signed VIC is typically ~1.5–2.5 KB, so it usually fits; we
/// still offer copy + download, and fall back to those for the rare VIC that
/// exceeds the QR limit.
const QR_MAX_CHARS = 2900;

export function Invitations() {
  const toast = useToast();
  const queryClient = useQueryClient();
  const [did, setDid] = useState("");
  const [validityDays, setValidityDays] = useState("");
  const [role, setRole] = useState("member");

  const invitations = useQuery({
    queryKey: ["invitations"],
    queryFn: async () => (await listInvitations()).invitations,
  });

  const revoke = useMutation<unknown, Error, string>({
    mutationFn: (id: string) => revokeInvitation(id),
    onSuccess: () => {
      toast.push("success", "Invitation revoked");
      void queryClient.invalidateQueries({ queryKey: ["invitations"] });
    },
    onError: (e) => toast.pushFromError(e),
  });

  const mutation = useMutation<IssueInvitationResponse, Error, void>({
    mutationFn: () => {
      const days = validityDays.trim() === "" ? undefined : Number(validityDays);
      if (days !== undefined && (!Number.isInteger(days) || days < 1)) {
        return Promise.reject(new Error("Validity must be a whole number of days ≥ 1"));
      }
      // "member" is the server default — send a role only when it differs.
      return issueInvitation(did.trim(), days, role === "member" ? undefined : role);
    },
    onSuccess: () => {
      toast.push("success", "Invitation issued");
      void queryClient.invalidateQueries({ queryKey: ["invitations"] });
    },
    onError: (e) => toast.pushFromError(e),
  });

  const result = mutation.data;
  const vicJson = result ? JSON.stringify(result.vic, null, 2) : "";

  return (
    <div className="page">
      <header className="page-header">
        <h2>
          <Ticket size={20} strokeWidth={1.75} /> Invitations
        </h2>
        <p className="muted">
          Issue a Verifiable Invitation Credential (VIC) for a prospective
          member. The holder presents it when joining and is auto-admitted — no
          manual approval needed.
        </p>
      </header>

      <section className="card">
        <form
          onSubmit={(e) => {
            e.preventDefault();
            if (did.trim()) mutation.mutate();
          }}
        >
          <label className="field">
            <span className="field-label">Invitee DID</span>
            <input
              type="text"
              value={did}
              onChange={(e) => setDid(e.target.value)}
              placeholder="did:key:… or did:webvh:…"
              autoComplete="off"
              spellCheck={false}
            />
          </label>
          <label className="field">
            <span className="field-label">Validity (days, optional)</span>
            <input
              type="number"
              min={1}
              max={90}
              value={validityDays}
              onChange={(e) => setValidityDays(e.target.value)}
              placeholder="7"
            />
          </label>
          <label className="field">
            <span className="field-label">Role on join</span>
            <select value={role} onChange={(e) => setRole(e.target.value)}>
              <option value="member">member</option>
              <option value="moderator">moderator</option>
              <option value="issuer">issuer</option>
            </select>
          </label>
          <button
            type="submit"
            className="btn primary"
            disabled={!did.trim() || mutation.isPending}
          >
            {mutation.isPending ? "Issuing…" : "Issue invitation"}
          </button>
        </form>
      </section>

      {result && (
        <section className="card">
          <h3>Invitation for {result.subjectDid}</h3>
          {result.validUntil && (
            <p className="muted">
              Valid until <code>{result.validUntil}</code>
            </p>
          )}
          <div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 8 }}>
            <CopyButton
              value={vicJson}
              label="Copy invitation JSON"
              successMessage="Invitation copied"
            />
            <DownloadButton filename={`vic-${shortId(result.subjectDid)}.json`} text={vicJson} />
          </div>
          <VicQr text={vicJson} />
          <textarea
            readOnly
            value={vicJson}
            rows={14}
            spellCheck={false}
            style={{ width: "100%", fontFamily: "monospace", fontSize: 12 }}
          />
        </section>
      )}

      <section className="card">
        <h3>Issued invitations</h3>
        {invitations.isPending && <p className="muted">Loading…</p>}
        {invitations.isError && (
          <p className="muted">Could not load invitations.</p>
        )}
        {invitations.data && invitations.data.length === 0 && (
          <p className="muted">No invitations issued yet.</p>
        )}
        {invitations.data && invitations.data.length > 0 && (
          <table className="data-table">
            <thead>
              <tr>
                <th>Invitee</th>
                <th>Role</th>
                <th>Issued</th>
                <th>Status</th>
                <th />
              </tr>
            </thead>
            <tbody>
              {invitations.data.map((inv: InvitationListItem) => (
                <tr key={inv.id}>
                  <td>
                    <code className="truncate">{inv.subjectDid}</code>
                  </td>
                  <td>{inv.role ?? "member"}</td>
                  <td>{inv.issuedAt.slice(0, 10)}</td>
                  <td>
                    {inv.revokedAt ? (
                      <span className="muted">revoked</span>
                    ) : (
                      "live"
                    )}
                  </td>
                  <td>
                    {!inv.revokedAt && (
                      <button
                        type="button"
                        className="btn"
                        disabled={revoke.isPending}
                        onClick={() => revoke.mutate(inv.id)}
                        title="Revoke this invitation"
                      >
                        <Trash2 size={16} strokeWidth={1.75} /> Revoke
                      </button>
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </section>
    </div>
  );
}

function DownloadButton({ filename, text }: { filename: string; text: string }) {
  const onClick = () => {
    const blob = new Blob([text], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    a.click();
    URL.revokeObjectURL(url);
  };
  return (
    <button type="button" className="btn" onClick={onClick}>
      <Download size={16} strokeWidth={1.75} /> Download .json
    </button>
  );
}

/// Renders a QR of the VIC when it's small enough to scan; otherwise explains
/// that copy/download is the hand-off path. The `qrcode` module is loaded
/// lazily so it doesn't weigh on the shell bundle.
function VicQr({ text }: { text: string }) {
  const [dataUrl, setDataUrl] = useState<string | null>(null);
  const tooBig = text.length > QR_MAX_CHARS;

  useEffect(() => {
    let cancelled = false;
    if (tooBig) {
      setDataUrl(null);
      return;
    }
    import("qrcode")
      // Level L (lowest EC) maximises payload capacity so a full VIC fits; the
      // larger render size keeps the denser code scannable.
      .then((qr) =>
        qr.toDataURL(text, {
          margin: 1,
          width: 360,
          errorCorrectionLevel: "L",
        }),
      )
      .then((url) => {
        if (!cancelled) setDataUrl(url);
      })
      .catch(() => {
        // Exceeds even a version-40 QR — fall back to copy / download.
        if (!cancelled) setDataUrl(null);
      });
    return () => {
      cancelled = true;
    };
  }, [text, tooBig]);

  if (tooBig) {
    return (
      <p className="muted">
        This invitation is too large for a scannable QR code — use{" "}
        <strong>Copy</strong> or <strong>Download</strong> to hand it off.
      </p>
    );
  }
  if (!dataUrl) return null;
  return (
    <div style={{ marginBottom: 8 }}>
      <img src={dataUrl} alt="Invitation QR code" width={240} height={240} />
    </div>
  );
}

function shortId(did: string): string {
  return did.replace(/[^a-zA-Z0-9]/g, "").slice(-8) || "invite";
}