Enterprise UI redesign — Phases 14–22 (Docusign-branded migration console) #1

Merged
paulh merged 24 commits from ui-redesign into master 2026-04-21 15:30:44 -05:00
4 changed files with 549 additions and 3 deletions
Showing only changes of commit 11b646d3b7 - Show all commits

133
tests/test_api_verify.py Normal file
View File

@ -0,0 +1,133 @@
"""
tests/test_api_verify.py
------------------------
Tests for /api/verify/* endpoints (send test envelope, status, void).
All DocuSign API calls are mocked with respx.
"""
import pytest
import respx
import httpx
from fastapi.testclient import TestClient
from web.app import app
from web.session import _serializer, _COOKIE_NAME
client = TestClient(app, raise_server_exceptions=True)
DS_BASE = "https://demo.docusign.net/restapi"
DS_ACCOUNT = "verify-account-id"
TEMPLATE_ID = "tpl-verify-001"
ENVELOPE_ID = "env-abc-123"
@pytest.fixture(autouse=True)
def patch_settings(monkeypatch):
import web.config as cfg
monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT)
monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE)
def _full_session():
return _serializer.dumps({
"adobe_access_token": "adobe-tok",
"docusign_access_token": "ds-tok",
})
def _ds_session():
return _serializer.dumps({"docusign_access_token": "ds-tok"})
class TestVerifySend:
def test_send_requires_auth(self):
"""No session → 401."""
resp = client.post(
"/api/verify/send",
json={"template_id": TEMPLATE_ID, "recipient_name": "Alice", "recipient_email": "alice@example.com"},
cookies={},
)
assert resp.status_code == 401
@respx.mock
def test_send_returns_envelope_id(self):
"""Authenticated + valid template → envelope_id returned."""
respx.post(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID}))
resp = client.post(
"/api/verify/send",
json={
"template_id": TEMPLATE_ID,
"recipient_name": "Alice Test",
"recipient_email": "alice@example.com",
},
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 200
assert resp.json()["envelope_id"] == ENVELOPE_ID
@respx.mock
def test_send_propagates_docusign_error(self):
"""DocuSign 400 → 502 with error detail."""
respx.post(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
).mock(return_value=httpx.Response(400, json={"message": "Invalid templateId"}))
resp = client.post(
"/api/verify/send",
json={"template_id": "bad-id", "recipient_name": "X", "recipient_email": "x@x.com"},
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 502
class TestVerifyStatus:
def test_status_requires_auth(self):
resp = client.get(f"/api/verify/status/{ENVELOPE_ID}", cookies={})
assert resp.status_code == 401
@respx.mock
def test_status_returns_envelope_state(self):
"""Authenticated → status and sent_at returned."""
respx.get(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}"
).mock(return_value=httpx.Response(200, json={
"envelopeId": ENVELOPE_ID,
"status": "sent",
"sentDateTime": "2026-04-21T12:00:00Z",
"completedDateTime": None,
}))
resp = client.get(
f"/api/verify/status/{ENVELOPE_ID}",
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "sent"
assert data["envelope_id"] == ENVELOPE_ID
assert data["sent_at"] == "2026-04-21T12:00:00Z"
class TestVerifyVoid:
def test_void_requires_auth(self):
resp = client.post(f"/api/verify/void/{ENVELOPE_ID}", json={"reason": "test"}, cookies={})
assert resp.status_code == 401
@respx.mock
def test_void_calls_docusign(self):
"""Authenticated → PUT envelope status to voided → voided: true."""
respx.put(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}"
).mock(return_value=httpx.Response(200, json={}))
resp = client.post(
f"/api/verify/void/{ENVELOPE_ID}",
json={"reason": "Verification complete"},
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 200
assert resp.json()["voided"] is True
assert resp.json()["envelope_id"] == ENVELOPE_ID

View File

@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
import os
from web.config import settings
from web.routers import auth, templates, migrate
from web.routers import auth, templates, migrate, verify
app = FastAPI(
title="Adobe Sign → DocuSign Migrator",
@ -24,9 +24,10 @@ app = FastAPI(
)
# Routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(templates.router, prefix="/api/templates", tags=["templates"])
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
app.include_router(verify.router, prefix="/api/verify", tags=["verify"])
# Static files (frontend)
_static_dir = os.path.join(os.path.dirname(__file__), "static")

131
web/routers/verify.py Normal file
View File

@ -0,0 +1,131 @@
"""
web/routers/verify.py
---------------------
Verification endpoints: send test envelopes, poll status, void.
Uses DocuSign Envelopes API to confirm migrated templates work end-to-end.
"""
from typing import Optional
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from web.config import settings
from web.session import get_session
router = APIRouter()
class SendRequest(BaseModel):
template_id: str
recipient_name: str
recipient_email: str
class VoidRequest(BaseModel):
reason: str = "Test envelope — voided after verification"
def _require_docusign(session: dict) -> Optional[JSONResponse]:
if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
return None
@router.post("/send")
async def send_test_envelope(body: SendRequest, request: Request):
"""Send a test envelope using a migrated DocuSign template."""
session = get_session(request)
err = _require_docusign(session)
if err:
return err
payload = {
"templateId": body.template_id,
"status": "sent",
"templateRoles": [
{
"email": body.recipient_email,
"name": body.recipient_name,
"roleName": "Signer",
}
],
"emailSubject": f"[Verification Test] Please sign this document",
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes",
headers={
"Authorization": f"Bearer {session['docusign_access_token']}",
"Content-Type": "application/json",
},
json=payload,
)
if not resp.is_success:
return JSONResponse(
{"error": "DocuSign API error", "detail": resp.text},
status_code=502,
)
data = resp.json()
return {"envelope_id": data.get("envelopeId")}
@router.get("/status/{envelope_id}")
async def envelope_status(envelope_id: str, request: Request):
"""Get the current status of a test envelope."""
session = get_session(request)
err = _require_docusign(session)
if err:
return err
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
)
if not resp.is_success:
return JSONResponse(
{"error": "DocuSign API error", "detail": resp.text},
status_code=502,
)
data = resp.json()
return {
"envelope_id": envelope_id,
"status": data.get("status"),
"completed_at": data.get("completedDateTime"),
"sent_at": data.get("sentDateTime"),
}
@router.post("/void/{envelope_id}")
async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
"""Void a test envelope after verification is complete."""
session = get_session(request)
err = _require_docusign(session)
if err:
return err
async with httpx.AsyncClient() as client:
resp = await client.put(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
headers={
"Authorization": f"Bearer {session['docusign_access_token']}",
"Content-Type": "application/json",
},
json={"status": "voided", "voidedReason": body.reason},
)
if not resp.is_success:
return JSONResponse(
{"error": "DocuSign API error", "detail": resp.text},
status_code=502,
)
return {"voided": True, "envelope_id": envelope_id}

View File

@ -0,0 +1,281 @@
// Verification view — send test envelopes to confirm migrated templates work
import { api } from './api.js';
import { state } from './state.js';
import { escHtml, formatDateTime } from './utils.js';
const POLL_MS = 5000;
const _envelopes = {}; // { adobeId: { envelopeId, status, sentAt, completedAt, polling } }
function getSettings() {
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
catch { return {}; }
}
export async function renderVerification(preloadedIds = null) {
const outlet = document.getElementById('router-outlet');
const ids = preloadedIds || state.verifyIds || null;
// Candidate templates: recently migrated (from state) or all migrated
const candidates = (state.templates || []).filter(t =>
t.status === 'migrated' || t.status === 'needs_update' || t.docusign_id
);
if (!state.auth.docusign) {
outlet.innerHTML = `
<div class="page-header"><div><div class="page-title">Verification</div></div></div>
<div class="callout info"><span class="callout-icon"></span>Connect DocuSign to send verification envelopes.</div>`;
return;
}
if (!candidates.length) {
outlet.innerHTML = `
<div class="page-header"><div><div class="page-title">Verification</div></div></div>
<div class="callout info"><span class="callout-icon"></span>
No migrated templates yet. Run a migration first, then return here to verify.
</div>`;
return;
}
_renderVerifyView(candidates);
}
function _renderVerifyView(candidates) {
const outlet = document.getElementById('router-outlet');
const settings = getSettings();
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Verification</div>
<div class="page-subtitle">Send test envelopes to confirm templates work end-to-end</div>
</div>
</div>
<div class="callout info" style="margin-bottom:20px">
<span class="callout-icon"></span>
Verification sends a real DocuSign envelope using each template. Test envelopes should be voided after use.
Configure default recipient in <a href="#/settings" style="color:var(--cobalt)">Settings</a>.
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Migrated Templates</span>
<span style="font-size:12px;color:var(--text-muted)">${candidates.length} templates</span>
</div>
<div class="table-wrap">
<table id="verify-table">
<thead>
<tr>
<th>Template</th>
<th>DocuSign ID</th>
<th>Verification Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${candidates.map(t => _verifyRow(t, settings)).join('')}
</tbody>
</table>
</div>
</div>
`;
// Wire Send Test buttons
document.querySelectorAll('.btn-send-test').forEach(btn => {
btn.addEventListener('click', () => {
const adobeId = btn.dataset.id;
const t = candidates.find(t => t.adobe_id === adobeId);
if (t) _showSendDialog(t, settings);
});
});
// Wire Void buttons
document.querySelectorAll('.btn-void-envelope').forEach(btn => {
btn.addEventListener('click', () => {
_voidEnvelope(btn.dataset.id, btn.dataset.envelopeid);
});
});
}
function _verifyRow(t, settings) {
const env = _envelopes[t.adobe_id];
let statusCell = '<span class="badge badge-gray">Not Tested</span>';
let actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Test</button>`;
if (env) {
if (env.status === 'completed') {
statusCell = '<span class="badge badge-green">✓ Verified</span>';
actionsCell = `<button class="btn btn-secondary btn-xs btn-void-envelope"
data-id="${escHtml(t.adobe_id)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
} else if (env.status === 'sent' || env.status === 'delivered') {
statusCell = `<span class="badge badge-blue"><span class="spinner spinner-sm"></span> ${env.status}</span>`;
actionsCell = `<button class="btn btn-secondary btn-xs btn-void-envelope"
data-id="${escHtml(t.adobe_id)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
} else if (env.status === 'voided') {
statusCell = '<span class="badge badge-gray">Voided</span>';
actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Again</button>`;
} else {
statusCell = `<span class="badge badge-amber">${escHtml(env.status || 'pending')}</span>`;
}
}
return `
<tr id="verify-row-${escHtml(t.adobe_id)}">
<td>
<div class="table-name">${escHtml(t.name)}</div>
<div class="table-sub">${escHtml(t.adobe_id)}</div>
</td>
<td class="mono" style="font-size:11px">${t.docusign_id ? escHtml(t.docusign_id.slice(0,12)) + '…' : '—'}</td>
<td id="verify-status-${escHtml(t.adobe_id)}">${statusCell}</td>
<td id="verify-actions-${escHtml(t.adobe_id)}">${actionsCell}</td>
</tr>
`;
}
function _showSendDialog(t, settings) {
const existing = document.getElementById('send-dialog');
if (existing) existing.remove();
const wrapper = document.createElement('div');
wrapper.id = 'send-dialog';
wrapper.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box modal-sm">
<div class="modal-header">
<span class="modal-title">Send Test Envelope</span>
<button class="modal-close" id="sd-close"></button>
</div>
<div class="modal-body">
<div style="font-size:13px;color:var(--text-muted);margin-bottom:14px">
Template: <strong>${escHtml(t.name)}</strong>
</div>
<div class="form-group">
<label class="form-label" for="sd-name">Recipient Name</label>
<input type="text" class="form-input" id="sd-name"
value="${escHtml(settings.testRecipientName || '')}"
placeholder="Test Recipient" />
</div>
<div class="form-group">
<label class="form-label" for="sd-email">Recipient Email</label>
<input type="email" class="form-input" id="sd-email"
value="${escHtml(settings.testRecipientEmail || '')}"
placeholder="test@example.com" />
<div class="form-error" id="sd-error"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="sd-cancel">Cancel</button>
<button class="btn btn-primary" id="sd-send">Send Test </button>
</div>
</div>
`;
document.body.appendChild(wrapper);
document.getElementById('sd-close').onclick = () => wrapper.remove();
document.getElementById('sd-cancel').onclick = () => wrapper.remove();
document.getElementById('sd-send').onclick = () => _sendEnvelope(t, wrapper);
}
async function _sendEnvelope(t, wrapper) {
const name = document.getElementById('sd-name')?.value.trim();
const email = document.getElementById('sd-email')?.value.trim();
const errorEl = document.getElementById('sd-error');
if (!name || !email) {
if (errorEl) errorEl.textContent = 'Recipient name and email are required.';
return;
}
if (errorEl) errorEl.textContent = '';
const sendBtn = document.getElementById('sd-send');
sendBtn.disabled = true;
sendBtn.textContent = 'Sending…';
try {
const data = await api.verify.send(t.docusign_id || t.adobe_id, name, email);
wrapper.remove();
_envelopes[t.adobe_id] = { envelopeId: data.envelope_id, status: 'sent', sentAt: new Date().toISOString() };
_updateVerifyRow(t.adobe_id);
_startPolling(t.adobe_id, data.envelope_id);
} catch (e) {
if (errorEl) errorEl.textContent = 'Send failed: ' + e.message;
sendBtn.disabled = false;
sendBtn.textContent = 'Send Test →';
}
}
function _startPolling(adobeId, envelopeId) {
const env = _envelopes[adobeId];
if (!env || env.polling) return;
env.polling = true;
const poll = async () => {
try {
const data = await api.verify.status(envelopeId);
if (_envelopes[adobeId]) {
_envelopes[adobeId].status = data.status;
_envelopes[adobeId].completedAt = data.completed_at;
_updateVerifyRow(adobeId);
if (data.status !== 'completed' && data.status !== 'voided') {
setTimeout(poll, POLL_MS);
} else {
_envelopes[adobeId].polling = false;
}
}
} catch (e) {
console.warn('Polling error:', e.message);
}
};
setTimeout(poll, POLL_MS);
}
function _updateVerifyRow(adobeId) {
const t = (state.templates || []).find(t => t.adobe_id === adobeId);
const env = _envelopes[adobeId];
if (!t || !env) return;
const statusEl = document.getElementById(`verify-status-${adobeId}`);
const actionsEl = document.getElementById(`verify-actions-${adobeId}`);
if (!statusEl) return;
if (env.status === 'completed') {
statusEl.innerHTML = '<span class="badge badge-green">✓ Verified</span>';
actionsEl.innerHTML = `<button class="btn btn-secondary btn-xs btn-void-envelope"
data-id="${escHtml(adobeId)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
} else if (env.status === 'sent' || env.status === 'delivered') {
statusEl.innerHTML = `<span class="badge badge-blue"><span class="spinner spinner-sm"></span> ${env.status}</span>`;
actionsEl.innerHTML = `<button class="btn btn-secondary btn-xs btn-void-envelope"
data-id="${escHtml(adobeId)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
} else if (env.status === 'voided') {
statusEl.innerHTML = '<span class="badge badge-gray">Voided</span>';
actionsEl.innerHTML = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(adobeId)}">Send Again</button>`;
}
// Re-wire newly injected buttons
actionsEl.querySelectorAll('.btn-void-envelope').forEach(btn => {
btn.onclick = () => _voidEnvelope(btn.dataset.id, btn.dataset.envelopeid);
});
actionsEl.querySelectorAll('.btn-send-test').forEach(btn => {
btn.onclick = () => {
const settings = getSettings();
_showSendDialog(t, settings);
};
});
}
async function _voidEnvelope(adobeId, envelopeId) {
if (!confirm('Void this test envelope?')) return;
try {
await api.verify.void(envelopeId);
if (_envelopes[adobeId]) {
_envelopes[adobeId].status = 'voided';
_envelopes[adobeId].polling = false;
}
_updateVerifyRow(adobeId);
} catch (e) {
alert('Failed to void envelope: ' + e.message);
}
}