sign-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 528326727848
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 1da91c033c738ab74538d13d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/DocPage.tsx
// The owner's view of a document: prepare it (signers and fields), send it, then follow it to the signed PDF.
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as RKeyboardEvent, type MouseEvent as RMouseEvent } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { Modal, Menu, toast, copy, share, canShare } from "./ui";
import { Pages } from "./Pages";
import { PageSkeleton, PagePlaceholder } from "./Skeleton";
import { openPdf, type PdfDoc } from "./lib/pdfjs";
import { loadBase, type Base } from "./lib/docdata";
import { rememberDoc, docKey } from "./lib/mydocs";
import { copyDocument } from "./lib/actions";
import { buildSignedPdf, download } from "./lib/finalpdf";
import { SITE_URL } from "../functions/lib/config";
import {
  FIELD_LABEL, FIELD_SIZE, SIGNER_COLORS, MAX_SIGNERS, parseJson, randomId,
  type Doc, type Field, type FieldType, type Signer, type Signature, type SignEvent,
} from "../functions/lib/doc";

type Row<T> = T & { id: string };
const fmt = (sec: number) => new Date(sec * 1000).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const adminLink = (docId: string, key: string) => `${SITE_URL}/d/${docId}#k=${key}`;

export function DocPage({ docId }: { docId: string }) {
  const [state, setState] = useState<"loading" | "owner" | "denied" | "error">("loading");
  const [error, setError] = useState("");
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const uid = await me();
        const fromHash = new URLSearchParams(location.hash.slice(1)).get("k");
        const owned = await get(`docs/${docId}/owners/${uid}`);
        if (!owned) {
          const key = fromHash || docKey(docId);
          if (!key) { if (!cancelled) setState("denied"); return; }
          try {
            await setMany([
              { path: `docclaims/${docId}/c/${uid}`, document: { key } },
              { path: `docs/${docId}/owners/${uid}`, document: { at: serverTimestamp() } },
            ]);
          } catch {
            if (!cancelled) setState("denied");
            return;
          }
        }
        if (fromHash) {
          // The title fills in once the document loads.
          rememberDoc({ id: docId, key: fromHash, title: "Document", at: Date.now() });
          history.replaceState(null, "", `/d/${docId}`);
        }
        if (!cancelled) setState("owner");
      } catch (e) {
        if (!cancelled) { setError(explain(e)); setState("error"); }
      }
    })();
    return () => { cancelled = true; };
  }, [docId]);

  if (state === "loading") return <PageSkeleton side />;
  if (state === "denied") return (
    <div className="wrap page-pad narrow enter">
      <div className="empty-state">
        <img className="art" src="/art/card-sign.webp" alt="" width="150" height="125" />
        <h1>This document is private</h1>
        <p>Only its owner can open this page. If you made it, open the admin link you saved. If someone asked you to sign, use the signing link they sent you.</p>
        <a className="btn" href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Send your own document</a>
      </div>
    </div>
  );
  if (state === "error") return <LoadError text={error} />;
  return <OwnerDoc docId={docId} />;
}

export function LoadError({ text }: { text: string }) {
  return (
    <div className="wrap page-pad narrow enter">
      <div className="error-state" role="alert">
        <h1>The document did not open</h1>
        <p>{text}</p>
        <button className="btn" type="button" onClick={() => location.reload()}>Try again</button>
      </div>
    </div>
  );
}

function OwnerDoc({ docId }: { docId: string }) {
  const { data: doc, error: docError } = useQuery(`docs/${docId}`) as { data: Doc | null | undefined; error: Error | null };
  const { data: sigRows } = useQuery(`docs/${docId}/signatures`) as { data: Row<Signature>[] | undefined };
  const { data: eventRows } = useQuery(`docs/${docId}/events`, { limit: 500 }) as { data: Row<SignEvent>[] | undefined };
  const signers = useMemo(() => parseJson<Signer[]>(doc?.signers, []), [doc?.signers]);
  const [base, setBase] = useState<Base | null>(null);
  const [pdf, setPdf] = useState<PdfDoc | null>(null);
  const [loadError, setLoadError] = useState<string | null>(null);
  const textKey = doc?.sourceKind === "text" ? JSON.stringify([doc.title, doc.text, signers.map((s) => [s.id, s.name, s.email || ""])]) : "";

  // Text documents re-render when their title, text or signers change (the signature block lists the signers); PDFs load once.
  useEffect(() => {
    if (!doc) return;
    let cancelled = false;
    (async () => {
      try {
        const b = await loadBase(docId, doc, signers);
        const p = await openPdf(b.bytes);
        if (!cancelled) { setBase(b); setPdf(p); setLoadError(null); }
      } catch (e) {
        if (!cancelled) setLoadError(explain(e));
      }
    })();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [docId, Boolean(doc), textKey]);

  useEffect(() => {
    if (!doc?.title) return;
    document.title = `${doc.title} | Sign`;
    const key = docKey(docId);
    if (key) rememberDoc({ id: docId, key, title: doc.title, at: Date.now() });
  }, [docId, doc?.title]);

  if (docError && doc === undefined) return <LoadError text={explain(docError)} />;
  if (doc === undefined || sigRows === undefined || eventRows === undefined) return <PageSkeleton side />;
  if (!doc) return <LoadError text="This document could not be found." />;

  const signatures = Object.fromEntries(sigRows.map((s) => [s.id, s]));
  // A PDF's fields live on the document (they change while drafting); a text document's come from its render.
  const current = base && doc.sourceKind === "pdf" ? { ...base, fields: parseJson<Field[]>(doc.fields, []) } : base;
  return doc.status === "draft"
    ? <Prepare docId={docId} doc={doc} signers={signers} base={base} pdf={pdf} loadError={loadError} />
    : <Status docId={docId} doc={doc} signers={signers} base={current} pdf={pdf} loadError={loadError} signatures={signatures} events={eventRows} />;
}

function colorOf(signers: Signer[], id: string) {
  const i = signers.findIndex((s) => s.id === id);
  return SIGNER_COLORS[(i < 0 ? 0 : i) % SIGNER_COLORS.length];
}

/** The menu every owner page shares: the admin link (to open it elsewhere) and a fresh copy to send again. */
function DocMenu({ docId, doc, bytes, extra }: { docId: string; doc: Doc; bytes: Uint8Array | null; extra?: React.ReactNode }) {
  const key = docKey(docId);
  const makeCopy = async () => {
    toast("Making a copy...");
    try {
      const id = await copyDocument(doc, bytes);
      navigate(`/d/${id}`);
      toast("Copy ready. Change what you need, then send it.");
    } catch (e) { toast(e instanceof Error ? e.message : String(e)); }
  };
  return (
    <Menu label="More actions" icon>
      {key && <button type="button" onClick={() => copy(adminLink(docId, key), "Admin link copied. Anyone with it can manage this document.")}>Copy admin link</button>}
      <button type="button" onClick={makeCopy}>Make a copy to send again</button>
      {extra}
    </Menu>
  );
}

function Prepare({ docId, doc, signers: saved, base, pdf, loadError }: {
  docId: string; doc: Doc; signers: Signer[]; base: Base | null; pdf: PdfDoc | null; loadError: string | null;
}) {
  const isText = doc.sourceKind === "text";
  // Signers change here first and save in order, so adding two quickly never loses one to a stale list.
  const [signers, setSigners] = useState<Signer[]>(saved);
  const latest = useRef(saved);
  const writing = useRef(0);
  const queue = useRef<Promise<unknown>>(Promise.resolve());
  useEffect(() => { if (!writing.current) { latest.current = saved; setSigners(saved); } }, [saved]);
  const saveSigners = async (next: Signer[], extra: Record<string, string> = {}) => {
    latest.current = next;
    setSigners(next);
    writing.current++;
    try {
      const write = queue.current.catch(() => undefined).then(() => set(`docs/${docId}`, { signers: JSON.stringify(next), ...extra }));
      queue.current = write;
      await write;
    } catch (e) {
      latest.current = saved;
      setSigners(saved);
      throw e;
    } finally { writing.current--; }
  };
  const [fields, setFields] = useState<Field[]>(() => parseJson<Field[]>(doc.fields, []));
  const [active, setActive] = useState<string | null>(signers[0]?.id ?? null);
  const [placing, setPlacing] = useState<FieldType | null>(null);
  const [selected, setSelected] = useState<string | null>(null);
  const [settingsFor, setSettingsFor] = useState<string | null>(null);
  const [editing, setEditing] = useState<Signer | null>(null);
  const [addOpen, setAddOpen] = useState(false);
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [signerError, setSignerError] = useState<string | null>(null);
  const [message, setMessage] = useState(doc.message || "");
  const [title, setTitle] = useState(doc.title);
  const [error, setError] = useState<string | null>(null);
  const [sending, setSending] = useState(false);
  const saveTimer = useRef<number>();
  const nameInput = useRef<HTMLInputElement>(null);
  const shown = isText ? base?.fields ?? [] : fields;
  const activeSigner = signers.find((s) => s.id === active);

  useEffect(() => { if (!activeSigner && signers[0]) setActive(signers[0].id); }, [signers, activeSigner]);
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setPlacing(null); setSelected(null); } };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  const saveFields = useCallback((next: Field[]) => {
    window.clearTimeout(saveTimer.current);
    saveTimer.current = window.setTimeout(() => {
      set(`docs/${docId}`, { fields: JSON.stringify(next) }).catch((e) => setError(explain(e)));
    }, 400);
  }, [docId]);
  const update = (next: Field[]) => { setFields(next); saveFields(next); };
  const patchField = (id: string, patch: Partial<Field>) => update(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
  const removeField = (id: string) => { update(fields.filter((f) => f.id !== id)); setSelected(null); setSettingsFor(null); };

  const addSigner = async () => {
    const n = name.trim();
    const em = email.trim();
    if (!n) { setSignerError("Add the signer's name."); nameInput.current?.focus(); return; }
    if (em && !EMAIL_RE.test(em)) return setSignerError("That email address does not look right.");
    if (latest.current.length >= MAX_SIGNERS) return setSignerError(`A document can have up to ${MAX_SIGNERS} signers.`);
    const s: Signer = { id: randomId(10), name: n.slice(0, 80), ...(em ? { email: em.slice(0, 200) } : {}) };
    setName(""); setEmail(""); setSignerError(null); setError(null); setActive(s.id);
    nameInput.current?.focus();
    try {
      await saveSigners([...latest.current, s]);
    } catch (e) { setSignerError(explain(e)); setName(n); setEmail(em); }
  };
  const saveSigner = (s: Signer) => saveSigners(latest.current.map((x) => (x.id === s.id ? s : x)));
  const removeSigner = async (id: string) => {
    const nextFields = fields.filter((f) => f.signerId !== id);
    window.clearTimeout(saveTimer.current);
    await saveSigners(latest.current.filter((s) => s.id !== id), { fields: JSON.stringify(nextFields) });
    setFields(nextFields);
    if (active === id) setActive(null);
  };

  const newField = (type: FieldType, page: number, fx: number, fy: number): Field => {
    const size = FIELD_SIZE[type];
    return {
      id: randomId(10), signerId: active!, type, page,
      x: Math.min(Math.max(fx - size.w / 2, 0), 1 - size.w), y: Math.min(Math.max(fy - size.h / 2, 0), 1 - size.h),
      w: size.w, h: size.h, required: type !== "checkbox",
    };
  };
  const place = (page: number, fx: number, fy: number) => {
    if (!placing || !active) { setSelected(null); return; }
    const f = newField(placing, page, fx, fy);
    update([...fields, f]);
    setSelected(f.id);
    setPlacing(null);
  };
  /** Picking a field type: with a pointer, the next click places it; from the keyboard, it lands in the middle of the page in view. */
  const pick = (type: FieldType, e: RMouseEvent) => {
    if (e.detail !== 0) { setPlacing(placing === type ? null : type); return; }
    const pages = [...document.querySelectorAll<HTMLElement>(".page-wrap")];
    const mid = window.innerHeight / 2;
    const inView = pages.find((p) => { const r = p.getBoundingClientRect(); return r.top <= mid && r.bottom >= mid; }) ?? pages[0];
    const f = newField(type, Number(inView?.dataset.page ?? 0), 0.5, 0.5);
    update([...fields, f]);
    setSelected(f.id);
    requestAnimationFrame(() => document.querySelector<HTMLElement>(`[data-field="${f.id}"]`)?.focus());
    toast(`${FIELD_LABEL[type]} added. Move it with the arrow keys.`);
  };
  /** From the phone sheet: pick a type, then tap the page. */
  const pickFromSheet = (type: FieldType) => {
    setPlacing(type);
    setAddOpen(false);
    const pagesTop = document.querySelector(".doc-pages")?.getBoundingClientRect().top ?? 0;
    if (pagesTop > window.innerHeight * 0.6) document.querySelector(".doc-pages")?.scrollIntoView({ behavior: "smooth", block: "start" });
  };

  const send = async () => {
    setError(null);
    if (!signers.length) { setError("Add at least one signer."); nameInput.current?.focus(); return; }
    const missing = signers.filter((s) => !shown.some((f) => f.signerId === s.id && (f.type === "signature" || f.type === "initials")));
    if (!isText && missing.length) return setError(`Add a signature field for ${missing.map((s) => s.name).join(", ")}.`);
    setSending(true);
    try {
      // A signer saved a moment ago must land before the document locks.
      await queue.current.catch(() => undefined);
      const uid = await me();
      const tokens = signers.map(() => randomId(28));
      window.clearTimeout(saveTimer.current);
      await setMany([
        { path: `docs/${docId}`, document: { title: title.trim() || doc.title, message: message.trim().slice(0, 2000), fields: JSON.stringify(isText ? [] : fields), status: "sent", sentAt: serverTimestamp() } },
        ...signers.map((s, i) => ({ path: `signertokens/${docId}/s/${s.id}`, document: { token: tokens[i] } })),
        { path: `docs/${docId}/events/${randomId(12)}`, document: { type: "sent", signerId: "", at: serverTimestamp(), by: uid } },
      ]);
      window.scrollTo({ top: 0 });
      toast("Sent. Now give each signer their link.");
    } catch (e) {
      setError(explain(e));
      setSending(false);
    }
  };

  const overlay = (page: number) => shown.filter((f) => f.page === page).map((f) => {
    const who = signers.find((s) => s.id === f.signerId)?.name ?? "";
    return (
      <FieldBox key={f.id} field={f} color={colorOf(signers, f.signerId)} editable={!isText} selected={selected === f.id}
        label={`${f.label || FIELD_LABEL[f.type]}${signers.length > 1 ? ` · ${who}` : ""}`}
        name={`${f.label || FIELD_LABEL[f.type]} for ${who}, page ${f.page + 1}`}
        onSelect={() => setSelected(f.id)}
        onOpen={() => setSettingsFor(f.id)}
        onChange={(next) => setFields((all) => all.map((x) => (x.id === f.id ? next : x)))}
        onCommit={(next) => update(fields.map((x) => (x.id === next.id ? next : x)))}
        onDelete={() => removeField(f.id)} />
    );
  });
  const settingsField = fields.find((f) => f.id === settingsFor) ?? null;
  const palette = (onPick: (t: FieldType, e: RMouseEvent) => void) => (
    <div className="field-palette">
      {(Object.keys(FIELD_LABEL) as FieldType[]).map((t) => (
        <button key={t} type="button" className={`palette-btn press${placing === t ? " on" : ""}`} onClick={(e) => onPick(t, e)} disabled={!active} aria-pressed={placing === t}>
          <FieldIcon type={t} />{FIELD_LABEL[t]}
        </button>
      ))}
    </div>
  );
  const sendButton = <button className="btn btn-lg" type="button" onClick={send} disabled={!pdf} aria-busy={sending || undefined}>Send for signature</button>;

  return (
    <div className="doc enter">
      <header className="doc-head">
        <div className="wrap doc-head-inner">
          <div className="grow">
            <p className="eyebrow">Draft</p>
            <input className="title-input" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={200}
              onBlur={() => title.trim() && title !== doc.title && set(`docs/${docId}`, { title: title.trim() }).catch((e) => setError(explain(e)))} aria-label="Document title" />
          </div>
          <div className="head-actions">
            <span className="hide-phone">{sendButton}</span>
            <DocMenu docId={docId} doc={doc} bytes={base?.bytes ?? null} />
          </div>
        </div>
      </header>
      <div className="wrap doc-body">
        <div className="doc-pages">
          {placing && (
            <p className="placing-hint enter" role="status">
              <span><span className="on-fine">Click</span><span className="on-coarse">Tap</span> the page where {activeSigner?.name ?? "the signer"}'s {FIELD_LABEL[placing].toLowerCase()} goes.</span>
              <button className="btn btn-sm btn-quiet" type="button" onClick={() => setPlacing(null)}>Cancel</button>
            </p>
          )}
          {loadError ? <p className="notice notice-error">{loadError}</p> : pdf ? <Pages pdf={pdf} overlay={overlay} onPageClick={place} placing={Boolean(placing)} /> : <PagePlaceholder />}
        </div>
        <aside className="doc-side">
          <section className="card side-card" aria-labelledby="who-signs">
            <h2 className="h3" id="who-signs">Who signs</h2>
            {signers.length > 0 && (
              <ul className="signer-list">
                {signers.map((s) => (
                  <li key={s.id} className={!isText && active === s.id ? "active" : ""}>
                    <button type="button" className="signer-pick" onClick={() => setActive(s.id)} aria-pressed={!isText ? active === s.id : undefined} disabled={isText}>
                      <span className="dot" style={{ background: colorOf(signers, s.id) }} />
                      <span className="grow"><strong>{s.name}</strong>{s.email && <span className="muted signer-email">{s.email}</span>}</span>
                    </button>
                    <button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Edit ${s.name}`} onClick={() => setEditing(s)}>
                      <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M13.5 3.5l3 3L7 16H4v-3z" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" /></svg>
                    </button>
                  </li>
                ))}
              </ul>
            )}
            <form className="stack add-signer" style={{ ["--gap" as string]: "8px" }} onSubmit={(e) => { e.preventDefault(); addSigner(); }} noValidate>
              <input ref={nameInput} className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" maxLength={80} aria-label="Signer name" autoComplete="off"
                aria-invalid={Boolean(signerError) || undefined} aria-describedby={signerError ? "signer-error" : undefined} />
              <input className="input" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional)" type="email" maxLength={200} aria-label="Signer email" autoComplete="off" />
              {signerError && <span className="field-error" id="signer-error">{signerError}</span>}
              <button className="btn btn-line" type="submit">Add signer</button>
            </form>
          </section>
          {!isText && (
            <section className="card side-card hide-phone" aria-labelledby="fields-title">
              <h2 className="h3" id="fields-title">Fields</h2>
              {activeSigner ? (
                <>
                  <p className="muted small">For <strong style={{ color: colorOf(signers, activeSigner.id) }}>{activeSigner.name}</strong>. Pick a field, then click where it goes.</p>
                  {palette(pick)}
                  <p className="muted small">Drag a field to move it, or its corner to resize. Click a placed field for its settings.</p>
                </>
              ) : <p className="muted small">Add who signs first. Then place their fields on the pages.</p>}
            </section>
          )}
          {isText && <section className="card side-card"><h2 className="h3">Fields</h2><p className="muted small">Each signer gets a signature, full name and date at the end of the document.</p></section>}
          <section className="card side-card">
            <label className="field"><span>Message to signers <em className="muted">(optional)</em></span>
              <textarea className="textarea" value={message} onChange={(e) => setMessage(e.target.value)} maxLength={2000} placeholder="Hi, here is the agreement we discussed."
                onBlur={() => message.trim() !== (doc.message || "") && set(`docs/${docId}`, { message: message.trim().slice(0, 2000) }).catch((e) => setError(explain(e)))} />
            </label>
          </section>
          {error && <p className="notice notice-error enter" role="alert">{error}</p>}
        </aside>
      </div>
      <div className="action-bar phone-bar">
        {!isText && <button className="btn btn-lg btn-line" type="button" onClick={() => (signers.length ? setAddOpen(true) : (setError("Add who signs first."), nameInput.current?.focus()))}>Add field</button>}
        {sendButton}
      </div>

      <Modal open={addOpen} title="Add a field" onClose={() => setAddOpen(false)}>
        {signers.length > 1 && (
          <label className="field"><span>Who fills it in</span>
            <select className="select" value={active ?? ""} onChange={(e) => setActive(e.target.value)}>
              {signers.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
            </select>
          </label>
        )}
        {palette((t) => pickFromSheet(t))}
        <p className="muted small">Then tap the page where it goes. Drag a placed field to move it, and tap it again for its settings.</p>
      </Modal>

      <Modal open={Boolean(settingsField)} title={settingsField ? `${FIELD_LABEL[settingsField.type]} field` : ""} onClose={() => setSettingsFor(null)}
        footer={settingsField && <>
          <button className="btn btn-quiet btn-danger" type="button" onClick={() => removeField(settingsField.id)}>Remove field</button>
          <span style={{ flex: 1 }} />
          <button className="btn" type="button" onClick={() => setSettingsFor(null)}>Done</button>
        </>}>
        {settingsField && <FieldSettings field={settingsField} signers={signers} onChange={(patch) => patchField(settingsField.id, patch)} />}
      </Modal>

      <SignerDialog signer={editing} onClose={() => setEditing(null)} onSave={saveSigner} onRemove={removeSigner}
        fieldCount={editing ? fields.filter((f) => f.signerId === editing.id).length : 0} />
    </div>
  );
}

function FieldSettings({ field, signers, onChange }: { field: Field; signers: Signer[]; onChange: (patch: Partial<Field>) => void }) {
  const labelled = field.type === "text" || field.type === "checkbox";
  return (
    <>
      {signers.length > 1 && (
        <label className="field"><span>Who fills it in</span>
          <select className="select" value={field.signerId} onChange={(e) => onChange({ signerId: e.target.value })}>
            {signers.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
          </select>
        </label>
      )}
      {labelled && (
        <label className="field"><span>Label</span>
          <input className="input" value={field.label ?? ""} maxLength={60} placeholder={field.type === "text" ? "For example: Company name" : "For example: I agree to the terms"}
            onChange={(e) => onChange({ label: e.target.value || undefined })} />
          <small>The signer sees this, so they know what to fill in.</small>
        </label>
      )}
      {labelled ? (
        <label className="check"><input type="checkbox" checked={field.required !== false} onChange={(e) => onChange({ required: e.target.checked })} /> Required</label>
      ) : <p className="muted small">{field.type === "date" ? "Fills in with the date the signer signs." : field.type === "name" ? "Fills in with the signer's name, which they can correct." : "Required. The signer draws or types it."}</p>}
    </>
  );
}

function SignerDialog({ signer, fieldCount, onClose, onSave, onRemove }: {
  signer: Signer | null; fieldCount: number; onClose: () => void; onSave: (s: Signer) => Promise<void>; onRemove: (id: string) => Promise<void>;
}) {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);
  useEffect(() => { if (signer) { setName(signer.name); setEmail(signer.email || ""); setError(null); setBusy(false); } }, [signer]);
  const save = async () => {
    if (!signer) return;
    const n = name.trim(), em = email.trim();
    if (!n) return setError("Add the signer's name.");
    if (em && !EMAIL_RE.test(em)) return setError("That email address does not look right.");
    setBusy(true);
    try {
      await onSave({ id: signer.id, name: n.slice(0, 80), ...(em ? { email: em.slice(0, 200) } : {}) });
      onClose();
    } catch (e) { setError(explain(e)); setBusy(false); }
  };
  const remove = async () => {
    if (!signer) return;
    setBusy(true);
    try { await onRemove(signer.id); onClose(); } catch (e) { setError(explain(e)); setBusy(false); }
  };
  return (
    <Modal open={Boolean(signer)} title="Edit signer" onClose={onClose} footer={<>
      <button className="btn btn-quiet btn-danger" type="button" onClick={remove} disabled={busy}>Remove{fieldCount ? ` and their ${fieldCount} field${fieldCount === 1 ? "" : "s"}` : ""}</button>
      <span style={{ flex: 1 }} />
      <button className="btn" type="submit" form="signer-form" aria-busy={busy || undefined}>Save</button>
    </>}>
      <form id="signer-form" className="stack" onSubmit={(e) => { e.preventDefault(); save(); }} noValidate>
        <label className="field"><span>Name</span><input className="input" data-autofocus value={name} onChange={(e) => setName(e.target.value)} maxLength={80} /></label>
        <label className="field"><span>Email <em className="muted">(optional)</em></span><input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} maxLength={200} /></label>
        {error && <p className="field-error" role="alert">{error}</p>}
      </form>
    </Modal>
  );
}

function FieldIcon({ type }: { type: FieldType }) {
  const d: Record<FieldType, string> = {
    signature: "M3 14c2-6 4-8 5-6s-2 6 0 6 3-5 5-5 1 4 3 4h1M3 17h14",
    initials: "M5 6v8M9 14V6l4 8V6",
    date: "M4 6h12v10H4zM4 9h12M7 4v3M13 4v3",
    name: "M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM4 17c1-3 3.5-4.5 6-4.5s5 1.5 6 4.5",
    text: "M5 6h10M10 6v9",
    checkbox: "M4 4h12v12H4zM7 10l2.5 2.5L13.5 8",
  };
  return <svg className="field-icon" width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d={d[type]} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>;
}

function FieldBox({ field, color, label, name, editable, selected, onSelect, onOpen, onChange, onCommit, onDelete }: {
  field: Field; color: string; label: string; name: string; editable: boolean; selected: boolean;
  onSelect: () => void; onOpen: () => void; onChange: (f: Field) => void; onCommit: (f: Field) => void; onDelete: () => void;
}) {
  const drag = useRef<{ mode: "move" | "resize"; sx: number; sy: number; f: Field; pw: number; ph: number; moved: boolean; wasSelected: boolean; last: Field } | null>(null);
  // A tap on a field that was already selected opens its settings. It opens on click, the last event of a tap,
  // so the touch's trailing mouse events do not land on the new dialog's backdrop and close it.
  const openOnClick = useRef(false);
  const start = (mode: "move" | "resize") => (e: React.PointerEvent) => {
    if (!editable) return;
    e.stopPropagation();
    const pageEl = (e.currentTarget as HTMLElement).closest(".page") as HTMLElement;
    const r = pageEl.getBoundingClientRect();
    drag.current = { mode, sx: e.clientX, sy: e.clientY, f: field, pw: r.width, ph: r.height, moved: false, wasSelected: selected, last: field };
    onSelect();
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
  };
  const move = (e: React.PointerEvent) => {
    const d = drag.current;
    if (!d) return;
    if (!d.moved && Math.hypot(e.clientX - d.sx, e.clientY - d.sy) < 4) return;
    d.moved = true;
    const dx = (e.clientX - d.sx) / d.pw;
    const dy = (e.clientY - d.sy) / d.ph;
    d.last = d.mode === "move"
      ? { ...d.f, x: Math.min(Math.max(d.f.x + dx, 0), 1 - d.f.w), y: Math.min(Math.max(d.f.y + dy, 0), 1 - d.f.h) }
      : { ...d.f, w: Math.min(Math.max(d.f.w + dx, 0.02), 1 - d.f.x), h: Math.min(Math.max(d.f.h + dy, 0.015), 1 - d.f.y) };
    onChange(d.last);
  };
  const end = () => {
    const d = drag.current;
    drag.current = null;
    if (!d) return;
    if (d.moved) onCommit(d.last);
    // Any tap on a selected field opens it, including on its corner handle, which covers most of a small checkbox on a phone.
    openOnClick.current = !d.moved && d.wasSelected;
  };
  const key = (e: RKeyboardEvent) => {
    if (!editable) return;
    const step = e.shiftKey ? 0.05 : 0.005;
    const arrows: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
    if (arrows[e.key]) {
      e.preventDefault();
      const [dx, dy] = arrows[e.key];
      onCommit({ ...field, x: Math.min(Math.max(field.x + dx, 0), 1 - field.w), y: Math.min(Math.max(field.y + dy, 0), 1 - field.h) });
    } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); }
    else if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); onDelete(); }
  };
  return (
    <div className={`fieldbox${field.type === "checkbox" ? " checkbox" : ""}${selected ? " selected" : ""}${editable ? " editable" : ""}`} data-field={field.id}
      style={{ left: `${field.x * 100}%`, top: `${field.y * 100}%`, width: `${field.w * 100}%`, height: `${field.h * 100}%`, ["--c" as string]: color }}
      role={editable ? "button" : undefined} tabIndex={editable ? 0 : undefined} aria-label={editable ? `${name}. Arrow keys move it, Enter opens settings, Delete removes it.` : undefined}
      onFocus={editable ? onSelect : undefined} onKeyDown={key}
      onPointerDown={start("move")} onPointerMove={move} onPointerUp={end} onPointerCancel={() => { drag.current = null; }}
      onClick={() => { if (openOnClick.current) { openOnClick.current = false; onOpen(); } }}>
      {field.type === "checkbox" ? <SideLabel field={field} text={label} /> : <span className="fieldbox-label">{label}</span>}
      {editable && selected && <>
        <button className="fieldbox-del" type="button" aria-label="Remove field" tabIndex={-1} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onDelete(); }}>×</button>
        <span className="fieldbox-resize" onPointerDown={start("resize")} onPointerMove={move} onPointerUp={end} />
      </>}
    </div>
  );
}

function Status({ docId, doc, signers, base, pdf, loadError, signatures, events }: {
  docId: string; doc: Doc; signers: Signer[]; base: Base | null; pdf: PdfDoc | null; loadError: string | null;
  signatures: Record<string, Row<Signature>>; events: Row<SignEvent>[];
}) {
  const [tokens, setTokens] = useState<Record<string, string>>({});
  const [busy, setBusy] = useState(false);
  const [confirmVoid, setConfirmVoid] = useState(false);
  const [voiding, setVoiding] = useState(false);
  useEffect(() => {
    Promise.all(signers.map(async (s) => [s.id, ((await get(`signertokens/${docId}/s/${s.id}`)) as { token?: string } | null)?.token || ""] as const))
      .then((pairs) => setTokens(Object.fromEntries(pairs))).catch(() => undefined);
  }, [docId, signers]);

  const signedCount = signers.filter((s) => signatures[s.id]).length;
  const complete = doc.status === "sent" && signers.length > 0 && signedCount === signers.length;
  const waiting = doc.status === "sent" && !complete;

  // Say so when someone signs while this page is open.
  const seen = useRef(new Set(Object.keys(signatures)));
  useEffect(() => {
    for (const s of signers) {
      if (signatures[s.id] && !seen.current.has(s.id)) { seen.current.add(s.id); toast(`${s.name} signed`); }
    }
  }, [signatures, signers]);

  const link = (s: Signer) => (tokens[s.id] ? `${SITE_URL}/s/${docId}/${s.id}#t=${tokens[s.id]}` : "");
  const firstView = (id: string) => events.filter((e) => e.type === "viewed" && e.signerId === id).sort((a, b) => a.at - b.at)[0];
  const invite = (s: Signer) => `${doc.message ? `${doc.message}\n\n` : ""}Please review and sign "${doc.title}". It opens in your browser, no account needed:\n${link(s)}`;
  const mailto = (s: Signer) => `mailto:${encodeURIComponent(s.email || "")}?subject=${encodeURIComponent(`Please sign: ${doc.title}`)}&body=${encodeURIComponent(invite(s))}`;

  const downloadSigned = async () => {
    if (!base) return;
    setBusy(true);
    try {
      const bytes = await buildSignedPdf({ docId, doc, base: base.bytes, fields: base.fields, signers, signatures, events, siteUrl: SITE_URL });
      download(bytes, `${doc.title} (signed).pdf`);
    } catch (e) { toast(explain(e)); } finally { setBusy(false); }
  };
  const voidDoc = async () => {
    setVoiding(true);
    try {
      const uid = await me();
      await setMany([
        { path: `docs/${docId}`, document: { status: "voided" } },
        { path: `docs/${docId}/events/${randomId(12)}`, document: { type: "voided", signerId: "", at: serverTimestamp(), by: uid } },
      ]);
      setConfirmVoid(false);
      toast("Voided. The signing links no longer work.");
    } catch (e) { toast(explain(e)); } finally { setVoiding(false); }
  };

  const overlay = (page: number) => (base?.fields ?? []).filter((f) => f.page === page).map((f) => {
    const sig = signatures[f.signerId];
    const value = sig ? parseJson<Record<string, string>>(sig.values, {})[f.id] : undefined;
    return <FilledBox key={f.id} field={f} value={value} color={colorOf(signers, f.signerId)} />;
  });

  const statusLabel = doc.status === "voided" ? "Voided" : complete ? "Completed" : `${signedCount} of ${signers.length} signed`;
  const downloadButton = <button className="btn btn-lg" type="button" onClick={downloadSigned} disabled={!base} aria-busy={busy || undefined}>Download signed PDF</button>;
  const nameOf = (id?: string | null) => signers.find((s) => s.id === id)?.name ?? "a signer";
  return (
    <div className="doc enter">
      <header className="doc-head">
        <div className="wrap doc-head-inner">
          <div className="grow">
            <p className={`eyebrow status-${doc.status === "voided" ? "voided" : complete ? "done" : "waiting"}`}>{statusLabel}</p>
            <h1 className="doc-title">{doc.title}</h1>
          </div>
          <div className="head-actions">
            {complete && <span className="hide-phone">{downloadButton}</span>}
            <DocMenu docId={docId} doc={doc} bytes={base?.bytes ?? null}
              extra={waiting && <><hr /><button type="button" className="danger" onClick={() => setConfirmVoid(true)}>Void document</button></>} />
          </div>
        </div>
      </header>
      <div className="wrap doc-body">
        <div className="doc-pages">
          {loadError ? <p className="notice notice-error">{loadError}</p> : pdf ? <Pages pdf={pdf} overlay={overlay} /> : <PagePlaceholder />}
        </div>
        <aside className="doc-side">
          {complete && (
            <section className="card side-card done-card enter">
              <h2 className="h3">Everyone has signed</h2>
              <p className="small">The signed PDF has every signature and a certificate with the audit trail and the document's fingerprint. Each signer can download it from their own link.</p>
            </section>
          )}
          {doc.status === "voided" && (
            <section className="card side-card voided-card enter">
              <h2 className="h3">Voided</h2>
              <p className="small">The signing links no longer work. To send it again, make a copy from the menu.</p>
            </section>
          )}
          <section className="card side-card" aria-labelledby="signers-title">
            <div className="spread"><h2 className="h3" id="signers-title">Signers</h2><span className="muted small">{signedCount} of {signers.length} signed</span></div>
            {waiting && <p className="muted small">Send each person their own link, by email or any chat. Anyone with a link can sign as that person.</p>}
            <ul className="status-list">
              {signers.map((s) => {
                const sig = signatures[s.id];
                const viewed = firstView(s.id);
                const url = link(s);
                return (
                  <li key={s.id}>
                    <div className="spread">
                      <span className="signer-name"><span className="dot" style={{ background: colorOf(signers, s.id) }} /><strong>{s.name}</strong></span>
                      <span className={`chip ${sig ? "chip-free" : viewed ? "chip-cobalt" : ""}`}>{sig ? "Signed" : viewed ? "Opened" : "Not opened"}</span>
                    </div>
                    <p className="muted small">{sig ? `Signed ${fmt(sig.signedAt)}` : viewed ? `Opened ${fmt(viewed.at)}` : s.email || "No email added"}</p>
                    {waiting && !sig && (
                      <div className="row link-actions">
                        <button className="btn btn-sm" type="button" disabled={!url} onClick={() => copy(url, `Link for ${s.name} copied`)}>Copy link</button>
                        {canShare() ? <button className="btn btn-sm btn-line" type="button" disabled={!url} onClick={() => share(url, `Please sign: ${doc.title}`, invite(s).replace(url, "").trim())}>Share</button>
                          : s.email ? <a className={`btn btn-sm btn-line${url ? "" : " is-disabled"}`} href={url ? mailto(s) : undefined} aria-disabled={!url || undefined}>Email</a> : null}
                        <Menu label={`More for ${s.name}`} icon end={false} className="btn btn-quiet btn-sm">
                          <button type="button" disabled={!url} onClick={() => navigate(`/s/${docId}/${s.id}#t=${tokens[s.id]}`)}>Sign as {s.name} on this device</button>
                          {canShare() && s.email && <a href={mailto(s)}>Email the link</a>}
                        </Menu>
                      </div>
                    )}
                  </li>
                );
              })}
            </ul>
          </section>
          <section className="card side-card" aria-labelledby="activity-title">
            <h2 className="h3" id="activity-title">Activity</h2>
            <ul className="events">
              {[...events].sort((a, b) => b.at - a.at).map((e) => (
                <li key={e.id}><span>{e.type === "created" ? "Created" : e.type === "sent" ? "Sent for signature" : e.type === "viewed" ? `Opened by ${nameOf(e.signerId)}` : e.type === "signed" ? `Signed by ${nameOf(e.signerId)}` : "Voided"}</span><span className="muted">{fmt(e.at)}</span></li>
              ))}
            </ul>
            <p className="muted small fingerprint">Original's SHA-256 fingerprint: <code title={doc.fileHash}>{doc.fileHash.slice(0, 16)}</code></p>
          </section>
        </aside>
      </div>
      {complete && <div className="action-bar phone-bar">{downloadButton}</div>}
      <Modal open={confirmVoid} title="Void this document?" onClose={() => setConfirmVoid(false)} footer={<>
        <button className="btn btn-line" type="button" onClick={() => setConfirmVoid(false)}>Keep it</button>
        <button className="btn" type="button" onClick={voidDoc} aria-busy={voiding || undefined}>Void document</button>
      </>}>
        <p>Signing links stop working and nobody else can sign. Signatures already made stay on the record. This cannot be undone.</p>
      </Modal>
    </div>
  );
}

export function FilledBox({ field, value, color }: { field: Field; value?: string; color: string }) {
  return (
    <div className={`fieldbox filled${field.type === "checkbox" ? " checkbox" : ""}${value ? " has-value" : ""}`}
      style={{ left: `${field.x * 100}%`, top: `${field.y * 100}%`, width: `${field.w * 100}%`, height: `${field.h * 100}%`, ["--c" as string]: color }}>
      {field.type === "checkbox" ? <>
        {value === "true" && <span className="fill-text">✕</span>}
        {field.label && <SideLabel field={field} text={field.label} />}
      </> : value ? (
        field.type === "signature" || field.type === "initials" ? <img src={value} alt="" /> : <span className="fill-text">{value}</span>
      ) : <span className="fieldbox-label">{field.label || FIELD_LABEL[field.type]}</span>}
    </div>
  );
}

/** A checkbox is too small to hold its label, so the label sits beside it, on whichever side has room. */
export function SideLabel({ field, text }: { field: Field; text: string }) {
  return <span className={`fieldbox-side${field.x + field.w > 0.6 ? " before" : ""}`}>{text}</span>;
}

Change history

Quality pass: phone signing, edit signers, copy to send again
5283267278 · 5 hours ago
Bounded source sync
15f0fa2e27 · 1 day ago
Bounded source sync
d3aa1ca65b · 1 day ago
Bounded source sync
8e2ca0b773 · 1 day ago
Bounded source sync
5cfa84715c · 1 day ago
Bounded source sync
c849e7b286 · 1 day ago
Bounded source sync
68c1cabe03 · 1 day ago
Bounded source sync
62ae8944f0 · 1 day ago
Bounded source sync
7ed54e7381 · 1 day ago