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>
This commit is contained in:
587
apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx
Normal file
587
apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user