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.
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.
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
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync