Files
eden/apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx
Matheus (Handix) 9c14671e19 feat(signatures): UI completa do módulo + rate limiting em rotas públicas
Fecha as duas pendências da leva anterior (módulo de Assinatura
Eletrônica): tela de backoffice para gerenciar envelopes (criação,
envio, cancelamento, checkpoint/finalização, auditoria) e as duas
páginas públicas (/assinar/:token, /verificar/:verificationId).

Backend: novos endpoints de streaming de PDF (documento congelado e
PDF final consolidado, autenticados e via token público) que faltavam
para a UI funcionar — o signatário não tinha como ver o próprio
documento antes de confirmar leitura. Rate limiting (guard próprio,
em memória, chaves por IP e por token) em todas as rotas sem sessão:
auth (login/mfa/forgot-password/reset-password/refresh/logout) e todo
o fluxo público de assinatura/verificação.

Suíte: 77/77 verdes. pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:51:18 -03:00

588 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, Badge, Button, Card, PageHeader, Select } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { hasPermission } from "../../lib/auth/permissions.js";
import { ApiError } from "../../lib/api/http.js";
import {
cancelEnvelope,
deleteEnvelope,
downloadFinalPdf,
finalizeEnvelope,
getEnvelope,
listAuditEvents,
openDocumentPdf,
sealCheckpoint,
sendInvite,
verifyAudit,
} from "../../lib/api/signatures.js";
import type {
EnvelopeWithDetails,
SendInviteTargets,
SignatureAuditEvent,
VerifyAuditResult,
} from "../../lib/api/signatures.js";
import { getCompany } from "../../lib/api/companies.js";
import type { Company } from "../../lib/api/companies.js";
import { formatDateTimeBR } from "../../lib/format.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
import {
ENVELOPE_STATUS_LABEL,
ENVELOPE_STATUS_VARIANT,
SIGNER_STATUS_LABEL,
SIGNER_STATUS_VARIANT,
actorTypeLabel,
auditEventLabel,
partyRoleLabel,
} from "./signatureStatus.js";
const DELETE_BLOCKED_STATUSES = new Set(["SIGNED", "FINALIZING", "COMPLETED"]);
const SEND_INVITE_BLOCKED_STATUSES = new Set(["COMPLETED", "CANCELLED", "DECLINED", "EXPIRED", "SUPERSEDED", "ERROR"]);
/** Mirrors `ENVELOPE_TRANSITIONS` in `packages/database/src/repositories/signatures.ts` — `CANCELLED` is only a valid target from these pre-signing states; anything past `SIGNING` (inclusive) 409s server-side. UX-only gating, the backend is still the one enforcing it. */
const CANCEL_BLOCKED_STATUSES = new Set([
"SIGNING",
"SIGNED",
"FINALIZING",
"COMPLETED",
"DECLINED",
"EXPIRED",
"CANCELLED",
"SUPERSEDED",
"ERROR",
]);
export function EnvelopeDetailPage() {
const { id } = useParams<{ id: string }>();
const { accessToken, permissions } = useAuth();
const [envelope, setEnvelope] = useState<EnvelopeWithDetails | null>(null);
const [company, setCompany] = useState<Company | null>(null);
const [auditEvents, setAuditEvents] = useState<SignatureAuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canEdit = hasPermission(permissions, "signatures", "edit");
const canDelete = hasPermission(permissions, "signatures", "delete");
const load = useCallback(() => {
if (!id || !accessToken) return;
setLoading(true);
setError(null);
getEnvelope(id, accessToken)
.then(async (result) => {
setEnvelope(result);
const [companyResult, events] = await Promise.all([
getCompany(result.company_id, accessToken).catch(() => null),
listAuditEvents(id, accessToken).catch(() => []),
]);
setCompany(companyResult);
setAuditEvents(events);
})
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o envelope."))
.finally(() => setLoading(false));
}, [id, accessToken]);
useEffect(() => {
load();
}, [load]);
if (loading) {
return (
<div>
<PageHeader title="Envelope de assinatura" />
<p className="text-sm text-default">Carregando envelope…</p>
</div>
);
}
if (error || !envelope) {
return (
<div>
<PageHeader title="Envelope de assinatura" />
<Alert variant="error">{error ?? "Envelope não encontrado."}</Alert>
</div>
);
}
const verificationUrl = envelope.verification_id ? `${window.location.origin}/verificar/${envelope.verification_id}` : null;
return (
<div>
<Breadcrumb items={[{ label: "Assinaturas", href: "/signatures" }, { label: envelope.envelope_number }]} />
<PageHeader
title={`Envelope ${envelope.envelope_number}`}
actions={<Badge variant={ENVELOPE_STATUS_VARIANT[envelope.status]}>{ENVELOPE_STATUS_LABEL[envelope.status]}</Badge>}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-1">
<Card title="Dados do envelope">
<dl className="space-y-3 text-sm">
<Row label="Empresa (contratada)" value={company?.company_name ?? envelope.company_id} />
<Row label="Cadastro de cliente" value={envelope.client_registration_id ?? "—"} />
<Row label="Revenda" value={envelope.reseller_id ?? "—"} />
<Row label="Criado em" value={formatDateTimeBR(envelope.created_at)} />
<Row label="Enviado em" value={formatDateTimeBR(envelope.sent_at)} />
<Row label="Assinado em" value={formatDateTimeBR(envelope.signed_at)} />
<Row label="Concluído em" value={formatDateTimeBR(envelope.completed_at)} />
<Row label="Cancelado em" value={formatDateTimeBR(envelope.cancelled_at)} />
</dl>
</Card>
{envelope.status === "COMPLETED" ? (
<Card title="Verificação pública">
<p className="text-sm text-default">
Qualquer pessoa com este link pode conferir a integridade do documento (sem autenticação):
</p>
{verificationUrl ? (
<a
href={`/verificar/${envelope.verification_id}`}
target="_blank"
rel="noopener noreferrer"
className="mt-2 block break-all font-mono text-xs text-primary underline"
>
{verificationUrl}
</a>
) : null}
</Card>
) : null}
<EnvelopeActionsCard
envelope={envelope}
accessToken={accessToken}
canEdit={canEdit}
canDelete={canDelete}
onChanged={load}
/>
</div>
<div className="space-y-4 lg:col-span-2">
<Card title="Documentos">
{envelope.documents.length === 0 ? (
<p className="text-sm text-default">Nenhum documento neste envelope.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="py-2 pe-3 font-semibold text-title">Documento</th>
<th className="py-2 pe-3 font-semibold text-title">Versão</th>
<th className="py-2 pe-3 font-semibold text-title">Páginas</th>
<th className="py-2 font-semibold text-title" />
</tr>
</thead>
<tbody>
{envelope.documents
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((doc) => (
<DocumentRow key={doc.id} envelopeId={envelope.id} document={doc} accessToken={accessToken} />
))}
</tbody>
</table>
</div>
)}
</Card>
<Card title="Signatários">
{envelope.signers.length === 0 ? (
<p className="text-sm text-default">Nenhum signatário cadastrado.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="py-2 pe-3 font-semibold text-title">Papel</th>
<th className="py-2 pe-3 font-semibold text-title">Nome</th>
<th className="py-2 pe-3 font-semibold text-title">Contato</th>
<th className="py-2 pe-3 font-semibold text-title">Cargo</th>
<th className="py-2 font-semibold text-title">Status</th>
</tr>
</thead>
<tbody>
{envelope.signers
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((signer) => (
<tr key={signer.id} className="border-b border-border-color last:border-0">
<td className="py-2 pe-3">
<Badge variant={signer.party === "contratada" ? "primary" : "info"}>
{partyRoleLabel(signer.party)}
</Badge>
</td>
<td className="py-2 pe-3 font-semibold text-title">
{signer.full_name}
{signer.cpf ? <span className="block text-xs font-normal text-default">{signer.cpf}</span> : null}
</td>
<td className="py-2 pe-3 text-default">
{signer.email}
{signer.phone ? <span className="block text-xs">{signer.phone}</span> : null}
</td>
<td className="py-2 pe-3 text-default">{signer.role_title ?? "—"}</td>
<td className="py-2">
<Badge variant={SIGNER_STATUS_VARIANT[signer.status]}>{SIGNER_STATUS_LABEL[signer.status]}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
<AuditTrailCard events={auditEvents} />
</div>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3">
<dt className="text-default">{label}</dt>
<dd className="text-right font-semibold text-title">{value}</dd>
</div>
);
}
function DocumentRow({
envelopeId,
document,
accessToken,
}: {
envelopeId: string;
document: EnvelopeWithDetails["documents"][number];
accessToken: string | null;
}) {
const [opening, setOpening] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleOpen() {
if (!accessToken) return;
setError(null);
setOpening(true);
try {
await openDocumentPdf(envelopeId, document.id, accessToken);
} catch {
setError("Não foi possível abrir o PDF.");
} finally {
setOpening(false);
}
}
return (
<tr className="border-b border-border-color last:border-0">
<td className="py-2 pe-3 font-semibold text-title">{document.title}</td>
<td className="py-2 pe-3 text-default">v{document.document_version}</td>
<td className="py-2 pe-3 text-default">{document.page_count ?? "—"}</td>
<td className="py-2 text-right">
<Button type="button" variant="secondary" size="sm" loading={opening} onClick={handleOpen}>
Baixar PDF
</Button>
{error ? <p className="mt-1 text-xs text-danger">{error}</p> : null}
</td>
</tr>
);
}
function EnvelopeActionsCard({
envelope,
accessToken,
canEdit,
canDelete,
onChanged,
}: {
envelope: EnvelopeWithDetails;
accessToken: string | null;
canEdit: boolean;
canDelete: boolean;
onChanged: () => void;
}) {
const [targets, setTargets] = useState<SendInviteTargets>("both");
const [sending, setSending] = useState(false);
const [sealing, setSealing] = useState(false);
const [finalizing, setFinalizing] = useState(false);
const [downloadingFinal, setDownloadingFinal] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<VerifyAuditResult | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const canSendInvite = canEdit && !SEND_INVITE_BLOCKED_STATUSES.has(envelope.status);
const canCancel = canEdit && !CANCEL_BLOCKED_STATUSES.has(envelope.status);
const canDeleteEnvelope = canDelete && !DELETE_BLOCKED_STATUSES.has(envelope.status);
const canSeal = canEdit && envelope.status === "SIGNED";
const canFinalize = canEdit && envelope.status === "FINALIZING";
const canDownloadFinal = envelope.status === "COMPLETED";
async function handleSend() {
if (!accessToken) return;
setActionError(null);
setSending(true);
try {
await sendInvite(envelope.id, targets, accessToken);
onChanged();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "Não foi possível enviar o convite.");
} finally {
setSending(false);
}
}
async function handleCancelConfirm() {
if (!accessToken) return;
setActionError(null);
setCancelling(true);
try {
await cancelEnvelope(envelope.id, cancelReason || undefined, accessToken);
setConfirmCancel(false);
setCancelReason("");
onChanged();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "Não foi possível cancelar o envelope.");
} finally {
setCancelling(false);
}
}
async function handleDeleteConfirm() {
if (!accessToken) return;
setActionError(null);
setDeleting(true);
try {
await deleteEnvelope(envelope.id, accessToken);
window.location.assign("/signatures");
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "Não foi possível excluir o envelope.");
setDeleting(false);
setConfirmDelete(false);
}
}
async function handleSeal() {
if (!accessToken) return;
setActionError(null);
setSealing(true);
try {
await sealCheckpoint(envelope.id, accessToken);
onChanged();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "Não foi possível selar o checkpoint.");
} finally {
setSealing(false);
}
}
async function handleFinalize() {
if (!accessToken) return;
setActionError(null);
setFinalizing(true);
try {
await finalizeEnvelope(envelope.id, accessToken);
onChanged();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "Não foi possível finalizar o envelope.");
} finally {
setFinalizing(false);
}
}
async function handleDownloadFinal() {
if (!accessToken) return;
setActionError(null);
setDownloadingFinal(true);
try {
await downloadFinalPdf(envelope.id, envelope.envelope_number, accessToken);
} catch {
setActionError("Não foi possível baixar o PDF final.");
} finally {
setDownloadingFinal(false);
}
}
async function handleVerify() {
if (!accessToken) return;
setActionError(null);
setVerifying(true);
setVerifyResult(null);
try {
const result = await verifyAudit(envelope.id, accessToken);
setVerifyResult(result);
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "Não foi possível verificar a integridade.");
} finally {
setVerifying(false);
}
}
const hasAnyAction =
canSendInvite || canCancel || canDeleteEnvelope || canSeal || canFinalize || canDownloadFinal;
return (
<Card title="Ações">
{actionError ? (
<Alert variant="error" className="mb-3">
{actionError}
</Alert>
) : null}
{!hasAnyAction ? <p className="text-sm text-default">Nenhuma ação disponível para este status.</p> : null}
<div className="space-y-4">
{canSendInvite ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase text-default">Enviar convite</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Select
options={[
{ value: "both", label: "Ambas as partes" },
{ value: "contratada", label: "Só contratada" },
{ value: "contratante", label: "Só contratante" },
]}
value={targets}
onChange={(e) => setTargets(e.target.value as SendInviteTargets)}
aria-label="Destinatários do convite"
/>
<Button type="button" loading={sending} onClick={handleSend}>
Enviar
</Button>
</div>
<p className="mt-1 text-xs text-default">Reenvia apenas para quem ainda não assinou.</p>
</div>
) : null}
{canSeal ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase text-default">Finalização — passo 1</p>
<Button type="button" loading={sealing} onClick={handleSeal}>
Selar checkpoint
</Button>
</div>
) : null}
{canFinalize ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase text-default">Finalização — passo 2</p>
<Button type="button" loading={finalizing} onClick={handleFinalize}>
Finalizar
</Button>
<p className="mt-1 text-xs text-default">
Pode levar alguns segundos — renderiza o certificado e consolida o PDF final no servidor.
</p>
</div>
) : null}
{canDownloadFinal ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase text-default">PDF final</p>
<Button type="button" variant="secondary" loading={downloadingFinal} onClick={handleDownloadFinal}>
Baixar PDF final
</Button>
</div>
) : null}
<div>
<p className="mb-2 text-xs font-semibold uppercase text-default">Auditoria</p>
<Button type="button" variant="secondary" loading={verifying} onClick={handleVerify}>
Verificar integridade da cadeia
</Button>
{verifyResult ? (
<Alert variant={verifyResult.valid ? "success" : "error"} className="mt-2">
{verifyResult.valid
? `Cadeia íntegra — ${verifyResult.eventsChecked} evento(s) verificado(s).`
: `Integridade comprometida (${verifyResult.eventsChecked} evento(s) verificados antes da falha) — ${verifyResult.reason ?? "motivo não informado"}.`}
</Alert>
) : null}
</div>
{canCancel ? (
<div className="border-t border-border-color pt-4">
{!confirmCancel ? (
<Button type="button" variant="danger" size="sm" onClick={() => setConfirmCancel(true)}>
Cancelar envelope
</Button>
) : (
<div className="space-y-2">
<p className="text-sm font-semibold text-title">Confirma o cancelamento deste envelope?</p>
<input
type="text"
placeholder="Motivo (opcional)"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
className="w-full rounded-md border border-border-color px-3 py-2 text-sm"
/>
<div className="flex gap-2">
<Button type="button" variant="danger" size="sm" loading={cancelling} onClick={handleCancelConfirm}>
Confirmar cancelamento
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => setConfirmCancel(false)}>
Voltar
</Button>
</div>
</div>
)}
</div>
) : null}
{canDeleteEnvelope ? (
<div className="border-t border-border-color pt-4">
{!confirmDelete ? (
<Button type="button" variant="danger" size="sm" onClick={() => setConfirmDelete(true)}>
Excluir envelope
</Button>
) : (
<div className="space-y-2">
<p className="text-sm font-semibold text-title">
Excluir permanentemente este envelope? Esta ação não pode ser desfeita.
</p>
<div className="flex gap-2">
<Button type="button" variant="danger" size="sm" loading={deleting} onClick={handleDeleteConfirm}>
Confirmar exclusão
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => setConfirmDelete(false)}>
Voltar
</Button>
</div>
</div>
)}
</div>
) : null}
</div>
</Card>
);
}
function AuditTrailCard({ events }: { events: SignatureAuditEvent[] }) {
const sorted = useMemo(() => events.slice().sort((a, b) => Number(a.seq) - Number(b.seq)), [events]);
return (
<Card title="Linha do tempo de auditoria">
{sorted.length === 0 ? (
<p className="text-sm text-default">Nenhum evento registrado ainda.</p>
) : (
<ol className="max-h-[32rem] space-y-4 overflow-y-auto">
{sorted.map((event) => (
<li key={event.id} className="relative border-s-2 border-border-color ps-4">
<span className="absolute -start-[5px] top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
<p className="text-sm font-semibold text-title">{auditEventLabel(event.event_type)}</p>
<p className="text-xs text-default">
{formatDateTimeBR(event.timestamp_utc)} · {actorTypeLabel(event.actor_type)}
{event.actor_role ? ` (${event.actor_role})` : ""}
{event.ip_address ? ` · ${event.ip_address}` : ""}
</p>
{Object.keys(event.metadata_json ?? {}).length > 0 ? (
<p className="mt-1 font-mono text-xs text-default">{JSON.stringify(event.metadata_json)}</p>
) : null}
</li>
))}
</ol>
)}
</Card>
);
}