Add generic eSign portal for all compliance document types

Reusable signing flow: service handler generates document → inserts
esign_records row → emails JWT link → client reviews PDF + signs →
API stores signature + resumes pipeline. Works for RMD, CPNI, CALEA,
499-A engagement, discontinuance, CRTC, and any future doc types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-05-04 10:45:37 -05:00
parent 37a22cf474
commit 40844b2aff
6 changed files with 879 additions and 0 deletions

View file

@ -0,0 +1,313 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Review & Sign — Performance West Inc.</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script>
window.__PW_API = (function() {
var h = window.location.hostname;
if (h === "localhost" || h === "127.0.0.1") return "http://" + h + ":3001";
if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net";
return "https://api.performancewest.net";
})();
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;line-height:1.6}
.wrap{max-width:720px;margin:0 auto;padding:2rem 1rem 4rem}
.header{background:#1e3a5f;color:#fff;border-radius:12px;padding:1.75rem 1.5rem;margin-bottom:1.5rem}
.header h1{margin:0 0 .3rem;font-size:1.35rem;font-weight:700}
.header p{margin:0;opacity:.8;font-size:.875rem}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 1px 4px rgba(0,0,0,.05)}
.card h2{font-size:1rem;font-weight:700;color:#1e3a5f;margin:0 0 .75rem}
.hint{font-size:.875rem;color:#475569;margin:0 0 .75rem}
.pdf-frame{width:100%;height:520px;border:1px solid #cbd5e1;border-radius:8px;background:#f8fafc}
#sig-canvas{width:100%;height:160px;border:2px solid #cbd5e1;border-radius:8px;cursor:crosshair;touch-action:none;background:#fafafa;display:block}
#sig-canvas.has-sig{border-color:#1e3a5f}
.sig-actions{display:flex;align-items:center;justify-content:space-between;margin-top:.5rem;font-size:.8rem;color:#64748b}
.sig-clear{background:none;border:1px solid #e2e8f0;color:#475569;padding:.3rem .75rem;border-radius:6px;cursor:pointer;font-size:.8rem}
.sig-clear:hover{background:#f1f5f9}
/* Typed signature */
.sig-tabs{display:flex;gap:.5rem;margin-bottom:.75rem}
.sig-tab{flex:1;padding:.5rem;border:2px solid #e2e8f0;border-radius:8px;background:#fff;cursor:pointer;font-weight:600;font-size:.85rem;text-align:center;color:#475569;font-family:inherit}
.sig-tab.active{border-color:#1e3a5f;color:#1e3a5f;background:#eff6ff}
#typed-sig{width:100%;padding:.75rem;border:2px solid #cbd5e1;border-radius:8px;font-size:1.5rem;font-family:'Brush Script MT','Segoe Script','Comic Sans MS',cursive;text-align:center;color:#1e3a5f}
#typed-sig:focus{outline:none;border-color:#1e3a5f}
.typed-preview{text-align:center;font-family:'Brush Script MT','Segoe Script','Comic Sans MS',cursive;font-size:2rem;color:#1e3a5f;padding:1rem;min-height:80px;border:1px dashed #cbd5e1;border-radius:8px;margin-top:.5rem}
.confirm-row{display:flex;gap:.75rem;align-items:flex-start;padding:1rem;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;font-size:.85rem;color:#374151;margin-bottom:.75rem}
.confirm-row input[type=checkbox]{margin-top:2px;flex-shrink:0;width:16px;height:16px;accent-color:#1e3a5f}
.perjury{font-size:.8rem;color:#6b7280;font-style:italic;padding:.75rem;background:#fefce8;border:1px solid #fde68a;border-radius:8px;margin-bottom:.75rem}
.submit-btn{width:100%;background:#1e3a5f;color:#fff;border:none;border-radius:10px;padding:.9rem;font-size:1rem;font-weight:700;cursor:pointer;font-family:inherit}
.submit-btn:hover:not(:disabled){background:#162e4d}
.submit-btn:disabled{opacity:.5;cursor:not-allowed}
.status{font-size:.875rem;margin-top:.75rem;min-height:1.25rem;text-align:center}
.err{color:#dc2626}.ok{color:#16a34a}
#success{display:none;text-align:center;padding:3rem 1rem}
#success .check{width:56px;height:56px;background:#dcfce7;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 1rem}
#success h2{color:#1e3a5f;margin:0 0 .5rem}
#success p{color:#475569;font-size:.9rem;max-width:400px;margin:0 auto}
#loading{text-align:center;padding:4rem 1rem;color:#64748b}
#error-screen{display:none;text-align:center;padding:3rem 1rem}
.hidden{display:none}
</style>
</head>
<body>
<div class="wrap">
<div id="loading"><p>Loading your document...</p><p style="font-size:.8rem;color:#94a3b8">Verifying your link</p></div>
<div id="main-ui" class="hidden">
<div class="header">
<h1 id="hdr-title">Review & Sign</h1>
<p id="hdr-subtitle"></p>
</div>
<!-- Step 1: Review -->
<div class="card">
<h2>Step 1 — Review Your Document</h2>
<p class="hint" id="review-hint">Please read the full document before signing.</p>
<div id="pdf-container">
<div style="display:flex;align-items:center;justify-content:center;height:200px;color:#94a3b8;font-size:.875rem;border:1px dashed #cbd5e1;border-radius:8px">Loading document preview...</div>
</div>
</div>
<!-- Step 2: Sign -->
<div class="card">
<h2>Step 2 — Your Signature</h2>
<div class="sig-tabs">
<button type="button" class="sig-tab active" data-mode="draw">Draw</button>
<button type="button" class="sig-tab" data-mode="type">Type</button>
</div>
<div id="draw-mode">
<p class="hint">Sign below using your mouse, trackpad, or finger.</p>
<canvas id="sig-canvas"></canvas>
<div class="sig-actions">
<span id="sig-hint" style="font-style:italic">Draw your signature above</span>
<button type="button" class="sig-clear" id="sig-clear">Clear</button>
</div>
</div>
<div id="type-mode" class="hidden">
<p class="hint">Type your full legal name below.</p>
<input type="text" id="typed-sig" placeholder="Your full name" autocomplete="name">
<div class="typed-preview" id="typed-preview"></div>
</div>
</div>
<!-- Step 3: Confirm -->
<div class="card">
<h2>Step 3 — Confirm & Submit</h2>
<div id="perjury-box" class="perjury hidden">
I declare under penalty of perjury under the laws of the United States of America that the foregoing is true and correct. Executed on <span id="perjury-date"></span>.
</div>
<div class="confirm-row">
<input type="checkbox" id="agree-chk">
<label for="agree-chk" id="agree-label">
I confirm that I have reviewed the document above and that my signature constitutes
my legal electronic signature. I authorize Performance West Inc. to submit this
document on behalf of <strong id="entity-confirm"></strong>.
</label>
</div>
<button class="submit-btn" id="submit-btn" disabled>Submit Signed Document</button>
<p class="status" id="status-msg"></p>
</div>
</div>
<div id="success" class="hidden">
<div class="check">
<svg width="28" height="28" fill="none" stroke="#16a34a" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
</div>
<h2>Document Signed Successfully</h2>
<p id="success-msg">Your signed document has been submitted. You'll receive a confirmation email shortly.</p>
</div>
<div id="error-screen" class="hidden">
<div class="card" style="border-color:#fca5a5;background:#fef2f2">
<h2 style="color:#991b1b">Error</h2>
<p style="color:#7f1d1d" id="error-msg"></p>
</div>
</div>
</div>
<script>
(function() {
var API = window.__PW_API;
var params = new URLSearchParams(window.location.search);
var token = params.get("token");
if (!token) {
document.getElementById("loading").style.display = "none";
document.getElementById("error-screen").style.display = "block";
document.getElementById("error-msg").textContent = "No signing token provided. Please use the link from your email.";
return;
}
// Load document info
fetch(API + "/api/v1/portal/esign?token=" + encodeURIComponent(token))
.then(function(r) { return r.json(); })
.then(function(data) {
document.getElementById("loading").style.display = "none";
if (data.error) {
document.getElementById("error-screen").style.display = "block";
document.getElementById("error-msg").textContent = data.error;
return;
}
if (data.already_signed) {
document.getElementById("success").style.display = "block";
var signedDate = data.signed_at ? new Date(data.signed_at).toLocaleDateString("en-US", {year:"numeric",month:"long",day:"numeric",hour:"numeric",minute:"2-digit"}) : "";
document.getElementById("success-msg").textContent = "This document was already signed" + (signedDate ? " on " + signedDate : "") + ". No further action needed.";
return;
}
// Populate UI
document.getElementById("main-ui").classList.remove("hidden");
document.getElementById("hdr-title").textContent = data.document_title || "Review & Sign";
document.getElementById("hdr-subtitle").textContent = data.entity_name + (data.order_number ? " — " + data.order_number : "");
document.getElementById("entity-confirm").textContent = data.entity_name;
document.getElementById("perjury-date").textContent = new Date().toLocaleDateString("en-US", {year:"numeric",month:"long",day:"numeric"});
if (data.requires_perjury) {
document.getElementById("perjury-box").classList.remove("hidden");
}
// PDF preview
if (data.document_url) {
document.getElementById("pdf-container").innerHTML =
'<iframe src="' + data.document_url + '" class="pdf-frame" title="Document preview"></iframe>';
} else {
document.getElementById("pdf-container").innerHTML =
'<div style="display:flex;align-items:center;justify-content:center;height:120px;color:#64748b;font-size:.875rem;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc">' +
'Document preview not available. Please contact us if you need a copy before signing.</div>';
}
// Store for submit
window._esignData = data;
})
.catch(function() {
document.getElementById("loading").style.display = "none";
document.getElementById("error-screen").style.display = "block";
document.getElementById("error-msg").textContent = "Could not load document. The link may have expired.";
});
// ── Signature canvas ──
var canvas = document.getElementById("sig-canvas");
var ctx = canvas.getContext("2d");
var drawing = false;
var hasSig = false;
function resizeCanvas() {
var rect = canvas.getBoundingClientRect();
canvas.width = rect.width * 2;
canvas.height = rect.height * 2;
ctx.scale(2, 2);
ctx.strokeStyle = "#1e3a5f";
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
function getPos(e) {
var rect = canvas.getBoundingClientRect();
var t = e.touches ? e.touches[0] : e;
return { x: t.clientX - rect.left, y: t.clientY - rect.top };
}
canvas.addEventListener("mousedown", function(e) { drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); });
canvas.addEventListener("mousemove", function(e) { if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSig = true; canvas.classList.add("has-sig"); document.getElementById("sig-hint").textContent = "Signature captured"; updateSubmit(); });
canvas.addEventListener("mouseup", function() { drawing = false; });
canvas.addEventListener("mouseleave", function() { drawing = false; });
canvas.addEventListener("touchstart", function(e) { e.preventDefault(); drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); }, {passive:false});
canvas.addEventListener("touchmove", function(e) { e.preventDefault(); if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSig = true; canvas.classList.add("has-sig"); document.getElementById("sig-hint").textContent = "Signature captured"; updateSubmit(); }, {passive:false});
canvas.addEventListener("touchend", function() { drawing = false; });
document.getElementById("sig-clear").addEventListener("click", function() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
hasSig = false;
canvas.classList.remove("has-sig");
document.getElementById("sig-hint").textContent = "Draw your signature above";
updateSubmit();
});
// ── Typed signature ──
var typedInput = document.getElementById("typed-sig");
var typedPreview = document.getElementById("typed-preview");
typedInput.addEventListener("input", function() {
typedPreview.textContent = this.value;
updateSubmit();
});
// ── Tab switching ──
var sigMode = "draw";
document.querySelectorAll(".sig-tab").forEach(function(tab) {
tab.addEventListener("click", function() {
document.querySelectorAll(".sig-tab").forEach(function(t) { t.classList.remove("active"); });
this.classList.add("active");
sigMode = this.dataset.mode;
document.getElementById("draw-mode").classList.toggle("hidden", sigMode !== "draw");
document.getElementById("type-mode").classList.toggle("hidden", sigMode !== "type");
updateSubmit();
});
});
// ── Submit logic ──
var agreeChk = document.getElementById("agree-chk");
agreeChk.addEventListener("change", updateSubmit);
function updateSubmit() {
var hasSignature = sigMode === "draw" ? hasSig : typedInput.value.trim().length >= 2;
document.getElementById("submit-btn").disabled = !(hasSignature && agreeChk.checked);
}
document.getElementById("submit-btn").addEventListener("click", async function() {
var btn = this;
var statusEl = document.getElementById("status-msg");
btn.disabled = true;
btn.textContent = "Submitting...";
statusEl.textContent = "";
var signatureData;
if (sigMode === "draw") {
signatureData = { type: "drawn", image_b64: canvas.toDataURL("image/png") };
} else {
signatureData = { type: "typed", name: typedInput.value.trim() };
}
try {
var resp = await fetch(API + "/api/v1/portal/esign", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token,
},
body: JSON.stringify({
signature: signatureData,
agreed_at: new Date().toISOString(),
user_agent: navigator.userAgent,
}),
});
var result = await resp.json();
if (!resp.ok) throw new Error(result.error || "Submission failed");
document.getElementById("main-ui").classList.add("hidden");
document.getElementById("success").style.display = "block";
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = "status err";
btn.disabled = false;
btn.textContent = "Submit Signed Document";
}
});
})();
</script>
</body>
</html>