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>
588 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|