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:
2026-09-17 21:51:18 -03:00
parent 20b114a0bf
commit 9c14671e19
30 changed files with 2946 additions and 13 deletions

View File

@@ -55,6 +55,11 @@ import { AfdImportPage } from "./routes/hr-timeclock/AfdImportPage.js";
import { AfdRecordsListPage } from "./routes/hr-timeclock/AfdRecordsListPage.js";
import { ForgotPasswordPage } from "./routes/ForgotPasswordPage.js";
import { ResetPasswordPage } from "./routes/ResetPasswordPage.js";
import { EnvelopesListPage } from "./routes/signatures/EnvelopesListPage.js";
import { EnvelopeFormPage } from "./routes/signatures/EnvelopeFormPage.js";
import { EnvelopeDetailPage } from "./routes/signatures/EnvelopeDetailPage.js";
import { PublicSignaturePage } from "./routes/PublicSignaturePage.js";
import { PublicVerifyPage } from "./routes/PublicVerifyPage.js";
export function App() {
return (
@@ -64,6 +69,8 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/assinar/:token" element={<PublicSignaturePage />} />
<Route path="/verificar/:verificationId" element={<PublicVerifyPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AuthenticatedLayout />}>
<Route path="/" element={<DashboardPage />} />
@@ -132,6 +139,10 @@ export function App() {
<Route path="/documents/generations" element={<GenerationsListPage />} />
<Route path="/documents/generations/new" element={<GenerationFormPage />} />
<Route path="/signatures" element={<EnvelopesListPage />} />
<Route path="/signatures/new" element={<EnvelopeFormPage />} />
<Route path="/signatures/:id" element={<EnvelopeDetailPage />} />
<Route path="/hr-timeclock/devices" element={<DevicesListPage />} />
<Route path="/hr-timeclock/devices/new" element={<DeviceFormPage />} />
<Route path="/hr-timeclock/devices/:id/edit" element={<DeviceFormPage />} />

View File

@@ -0,0 +1,76 @@
/**
* Client for `apps/api` `/v1/public-signature/:token/*` — the signer-facing
* flow reached via the emailed link `/assinar/:token` (see
* apps/api/src/signatures/public-signature.controller.ts +
* public-signature.service.ts for the source of truth). Deliberately NOT
* authenticated: the token in the URL is the entire authorization,
* there's never an `accessToken` header here — mirrored by never taking
* one as a parameter in this file, unlike `./signatures.ts`.
*/
import { API_BASE_URL, apiRequest } from "./http.js";
import type { PartyRole, PersonType, SignerStatus } from "./signatures.js";
export interface PublicSignerState {
id: string;
party: PartyRole;
person_type: PersonType | null;
full_name: string;
cpf: string | null;
email: string;
phone: string | null;
company_name: string | null;
cnpj: string | null;
role_title: string | null;
status: SignerStatus;
documentsRead: string[];
}
export interface PublicDocumentState {
id: string;
title: string;
documentVersion: number;
pageCount: number | null;
confirmedRead: boolean;
}
export interface PublicSignatureState {
envelope: { id: string; envelopeNumber: string; status: string };
signer: PublicSignerState;
documents: PublicDocumentState[];
}
export function getPublicSignatureState(token: string): Promise<PublicSignatureState> {
return apiRequest<PublicSignatureState>(`/public-signature/${token}`, { method: "GET" });
}
/** Opens the document's PDF in a new tab — no bearer token needed, the token is already in the path, so a plain `window.open` works (unlike the authenticated backoffice equivalent in `./signatures.ts`). */
export function publicDocumentPdfUrl(token: string, documentId: string): string {
return `${API_BASE_URL}/public-signature/${token}/documents/${documentId}/pdf`;
}
export function confirmDocumentRead(token: string, documentId: string): Promise<void> {
return apiRequest<void>(`/public-signature/${token}/documents/${documentId}/confirm-read`, { method: "POST" });
}
export function confirmSignerData(token: string): Promise<void> {
return apiRequest<void>(`/public-signature/${token}/confirm-data`, { method: "POST" });
}
export function acceptConsent(token: string): Promise<void> {
return apiRequest<void>(`/public-signature/${token}/consent`, { method: "POST" });
}
export function requestOtp(token: string): Promise<void> {
return apiRequest<void>(`/public-signature/${token}/otp/request`, { method: "POST" });
}
export function verifyOtp(token: string, code: string): Promise<void> {
return apiRequest<void>(`/public-signature/${token}/otp/verify`, {
method: "POST",
body: JSON.stringify({ code }),
});
}
export function commitSignature(token: string): Promise<void> {
return apiRequest<void>(`/public-signature/${token}/sign`, { method: "POST" });
}

View File

@@ -0,0 +1,39 @@
/**
* Client for `apps/api` `/v1/public-verify/:verificationId` — the public
* integrity-check page linked from the final certificate/e-mail
* (`/verificar/:verificationId`). See
* apps/api/src/signatures/public-verify.service.ts for the source of
* truth. No authentication, no PDF download here (deliberate — the final
* PDF only ever goes out by e-mail, see eden.md).
*/
import { apiRequest } from "./http.js";
export interface PublicVerificationDocument {
title: string;
version: number;
pageCount: number | null;
}
export interface PublicVerificationSigner {
/** Already masked server-side (e.g. "M***** S*****") — never the full name. */
name: string;
party: "contratada" | "contratante";
}
export interface PublicVerificationResult {
envelopeNumber: string;
completedAt: string;
documents: PublicVerificationDocument[];
signers: PublicVerificationSigner[];
integrityValid: boolean;
eventsChecked: number;
envelopeHash: string | null;
finalPdfSha256: string | null;
evidenceCheckpointHash: string | null;
verificationId: string | null;
method: string;
}
export function getPublicVerification(verificationId: string): Promise<PublicVerificationResult> {
return apiRequest<PublicVerificationResult>(`/public-verify/${verificationId}`, { method: "GET" });
}

View File

@@ -0,0 +1,255 @@
/**
* Client for `apps/api` `/v1/signature-envelopes` (backoffice, authenticated)
* — see apps/api/src/signatures/*.controller.ts,
* packages/database/src/repositories/signatures.ts, and the migration
* `1788536022387_signatures.cjs` for the source of truth. Shapes below are
* mirrored, not invented. The public (token-based) and public-verify
* surfaces live in `./public-signature.ts` and `./public-verify.ts` —
* different auth model, kept in separate files on purpose.
*/
import { API_BASE_URL, apiRequest } from "./http.js";
export const ENVELOPE_STATUSES = [
"DRAFT",
"READY",
"SENT",
"VIEWED",
"IDENTITY_PENDING",
"CONSENT_PENDING",
"OTP_PENDING",
"OTP_SENT",
"OTP_VERIFIED",
"READY_TO_SIGN",
"SIGNING",
"SIGNED",
"FINALIZING",
"COMPLETED",
"DECLINED",
"EXPIRED",
"CANCELLED",
"SUPERSEDED",
"ERROR",
] as const;
export type EnvelopeStatus = (typeof ENVELOPE_STATUSES)[number];
export const SIGNER_STATUSES = [
"PENDING",
"LINK_SENT",
"VIEWED",
"DATA_CONFIRMED",
"CONSENTED",
"OTP_VERIFIED",
"SIGNED",
"DECLINED",
] as const;
export type SignerStatus = (typeof SIGNER_STATUSES)[number];
export type PartyRole = "contratada" | "contratante";
export type PersonType = "pf" | "pj";
export type ActorType = "SIGNER" | "BACKOFFICE" | "ADMIN" | "SUPER_ADMIN" | "SYSTEM" | "SERVICE";
/** Response shape — snake_case, exactly as returned by the `signature_envelopes` table. */
export interface SignatureEnvelope {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
envelope_number: string;
company_id: string;
client_registration_id: string | null;
reseller_id: string | null;
status: EnvelopeStatus;
verification_id: string | null;
final_s3_key: string | null;
final_pdf_sha256: string | null;
certificate_s3_key: string | null;
evidence_checkpoint_hash: string | null;
envelope_hash: string | null;
sent_at: string | null;
viewed_at: string | null;
completed_at: string | null;
declined_at: string | null;
cancelled_at: string | null;
expired_at: string | null;
signed_at: string | null;
}
export interface SignatureSigner {
id: string;
created_at: string;
envelope_id: string;
sort_order: number;
party: PartyRole;
person_type: PersonType | null;
full_name: string;
cpf: string | null;
email: string;
phone: string | null;
company_name: string | null;
cnpj: string | null;
role_title: string | null;
status: SignerStatus;
signed_at: string | null;
secure_token_expires_at: string | null;
secure_token_revoked_at: string | null;
secure_token_first_used_at: string | null;
secure_token_last_used_at: string | null;
}
export interface SignatureDocument {
id: string;
envelope_id: string;
source_generation_id: string | null;
document_type: string | null;
title: string;
sort_order: number;
document_version: number;
source_filename: string | null;
source_mime_type: string | null;
original_sha256: string;
page_count: number | null;
status: "FROZEN" | "SUPERSEDED";
superseded_by_id: string | null;
frozen_at: string;
}
export interface SignatureAuditEvent {
id: string;
seq: string;
envelope_id: string;
document_id: string | null;
signer_id: string | null;
event_type: string;
actor_type: ActorType;
actor_id: string | null;
actor_role: string | null;
timestamp_utc: string;
ip_address: string | null;
user_agent: string | null;
browser: string | null;
operating_system: string | null;
device_type: string | null;
metadata_json: Record<string, unknown>;
previous_event_hash: string | null;
event_hash: string;
hash_algorithm_version: number;
}
export interface EnvelopeWithDetails extends SignatureEnvelope {
signers: SignatureSigner[];
documents: SignatureDocument[];
}
/** Body of `POST /signature-envelopes` — matches `CreateEnvelopeDto`. The `contratada` signer is never sent here, it's auto-filled server-side from `companies.legal_rep_*`. */
export interface SignerInput {
personType?: PersonType;
fullName: string;
cpf?: string;
email: string;
phone?: string;
companyName?: string;
cnpj?: string;
roleTitle?: string;
}
export interface CreateEnvelopeInput {
companyId: string;
clientRegistrationId?: string;
resellerId?: string;
documentGenerationIds: string[];
signers: SignerInput[];
}
export type SendInviteTargets = "contratada" | "contratante" | "both";
export interface VerifyAuditResult {
valid: boolean;
eventsChecked: number;
failedAt?: string;
reason?: string;
}
export function listEnvelopes(accessToken: string): Promise<SignatureEnvelope[]> {
return apiRequest<SignatureEnvelope[]>("/signature-envelopes", { method: "GET" }, { accessToken });
}
export function getEnvelope(id: string, accessToken: string): Promise<EnvelopeWithDetails> {
return apiRequest<EnvelopeWithDetails>(`/signature-envelopes/${id}`, { method: "GET" }, { accessToken });
}
export function createEnvelope(input: CreateEnvelopeInput, accessToken: string): Promise<EnvelopeWithDetails> {
return apiRequest<EnvelopeWithDetails>(
"/signature-envelopes",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function sendInvite(
id: string,
targets: SendInviteTargets,
accessToken: string,
): Promise<EnvelopeWithDetails> {
return apiRequest<EnvelopeWithDetails>(
`/signature-envelopes/${id}/send`,
{ method: "POST", body: JSON.stringify({ targets }) },
{ accessToken },
);
}
export function cancelEnvelope(
id: string,
reason: string | undefined,
accessToken: string,
): Promise<SignatureEnvelope> {
return apiRequest<SignatureEnvelope>(
`/signature-envelopes/${id}/cancel`,
{ method: "POST", body: JSON.stringify({ reason }) },
{ accessToken },
);
}
export function deleteEnvelope(id: string, accessToken: string): Promise<void> {
return apiRequest<void>(`/signature-envelopes/${id}`, { method: "DELETE" }, { accessToken });
}
export function verifyAudit(id: string, accessToken: string): Promise<VerifyAuditResult> {
return apiRequest<VerifyAuditResult>(`/signature-envelopes/${id}/verify-audit`, { method: "GET" }, { accessToken });
}
export function listAuditEvents(id: string, accessToken: string): Promise<SignatureAuditEvent[]> {
return apiRequest<SignatureAuditEvent[]>(`/signature-envelopes/${id}/audit-events`, { method: "GET" }, { accessToken });
}
export function sealCheckpoint(id: string, accessToken: string): Promise<SignatureEnvelope> {
return apiRequest<SignatureEnvelope>(`/signature-envelopes/${id}/seal-checkpoint`, { method: "POST" }, { accessToken });
}
export function finalizeEnvelope(id: string, accessToken: string): Promise<SignatureEnvelope> {
return apiRequest<SignatureEnvelope>(`/signature-envelopes/${id}/finalize`, { method: "POST" }, { accessToken });
}
/** Fetches a frozen document's PDF and opens it in a new tab — same "Blob + object URL" pattern as `documents.ts`'s `downloadGenerationPdf` (the route is behind `JwtAuthGuard`, a plain `<a href>` can't carry the bearer token). */
export async function openDocumentPdf(envelopeId: string, documentId: string, accessToken: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/signature-envelopes/${envelopeId}/documents/${documentId}/pdf`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) throw new Error("Não foi possível abrir o PDF do documento.");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
}
export async function downloadFinalPdf(envelopeId: string, envelopeNumber: string, accessToken: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/signature-envelopes/${envelopeId}/final-pdf`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) throw new Error("Não foi possível baixar o PDF final.");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${envelopeNumber}.pdf`;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -7,7 +7,7 @@ import {
login as apiLogin,
logout as apiLogout,
} from "../api/auth.js";
import type { PublicUser } from "../api/auth.js";
import type { PermissionGrant, PublicUser } from "../api/auth.js";
import { clearSession, readSession, writeSession } from "./session.js";
export type AuthStatus = "loading" | "authenticated" | "anonymous";
@@ -22,6 +22,8 @@ interface AuthContextValue {
status: AuthStatus;
user: PublicUser | null;
accessToken: string | null;
/** `null` while still loading; `"ALL"` for super-admin-style grants — see `hasPermission` in `./permissions.js`. Never used for actual access control (that's server-side only), only to hide/disable actions the user can't perform. */
permissions: "ALL" | PermissionGrant[] | null;
/** Resolves to `{ mfaRequired: true, mfaChallenge }` when a TOTP code is needed next, otherwise logs in fully. */
login: (email: string, password: string) => Promise<MfaPending | { mfaRequired: false }>;
submitMfa: (mfaChallenge: string, code: string) => Promise<void>;
@@ -35,6 +37,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<PublicUser | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState<string | null>(null);
const [permissions, setPermissions] = useState<"ALL" | PermissionGrant[] | null>(null);
useEffect(() => {
const stored = readSession();
@@ -51,6 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(me.user);
setAccessToken(stored.accessToken);
setRefreshToken(stored.refreshToken);
setPermissions(me.permissions);
setStatus("authenticated");
})
.catch(() => {
@@ -69,6 +73,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAccessToken(result.accessToken);
setRefreshToken(result.refreshToken);
setStatus("authenticated");
// `/auth/login` doesn't return permission grants (only `/auth/me` does)
// — fetch them right away so nav/action visibility is correct from the
// first authenticated render, not just after a manual refresh.
fetchMe(result.accessToken)
.then((me) => setPermissions(me.permissions))
.catch(() => setPermissions(null));
return { mfaRequired: false as const };
}, []);
@@ -79,6 +89,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAccessToken(result.accessToken);
setRefreshToken(result.refreshToken);
setStatus("authenticated");
fetchMe(result.accessToken)
.then((me) => setPermissions(me.permissions))
.catch(() => setPermissions(null));
}, []);
const logout = useCallback(async () => {
@@ -91,12 +104,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
setAccessToken(null);
setRefreshToken(null);
setPermissions(null);
setStatus("anonymous");
}, [refreshToken]);
const value = useMemo<AuthContextValue>(
() => ({ status, user, accessToken, login, submitMfa, logout }),
[status, user, accessToken, login, submitMfa, logout],
() => ({ status, user, accessToken, permissions, login, submitMfa, logout }),
[status, user, accessToken, permissions, login, submitMfa, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -0,0 +1,19 @@
import type { PermissionGrant } from "../api/auth.js";
/**
* Mirrors (loosely — this is UX only, not authorization) the shape of
* `maxGrantedScope` in `apps/api/src/auth/permissions.ts`: the backend is
* always the one deciding whether an action is allowed (every mutating
* route re-checks via `RequirePermission`/`maxGrantedScope` itself). This
* only answers "should the button even be shown/enabled" — hiding a
* button a user can't use is UX, not access control.
*/
export function hasPermission(
permissions: "ALL" | PermissionGrant[] | null,
resource: string,
action: string,
): boolean {
if (permissions === "ALL") return true;
if (!permissions) return false;
return permissions.some((grant) => grant.resource === resource && grant.action === action);
}

View File

@@ -25,6 +25,7 @@ export function AuthenticatedLayout() {
{ key: "client-registrations", label: "Clientes", href: "/client-registrations" },
{ key: "contracts", label: "Contratos", href: "/contracts" },
{ key: "documents", label: "Documentos", href: "/documents/templates" },
{ key: "signatures", label: "Assinaturas", href: "/signatures" },
{ key: "inventory", label: "Estoque", href: "/inventory/assets" },
{ key: "billing", label: "Faturamento", href: "/billing/subscriptions" },
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },

View File

@@ -0,0 +1,456 @@
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, Badge, Button, Card } from "@eden/ui";
import { ApiError } from "../lib/api/http.js";
import {
acceptConsent,
commitSignature,
confirmDocumentRead,
confirmSignerData,
getPublicSignatureState,
publicDocumentPdfUrl,
requestOtp,
verifyOtp,
} from "../lib/api/public-signature.js";
import type { PublicSignatureState } from "../lib/api/public-signature.js";
import { formatDocument } from "../lib/format.js";
type WizardStep = "documents" | "data" | "consent" | "otp" | "sign" | "done";
const STEP_LABEL: Record<WizardStep, string> = {
documents: "Documentos",
data: "Confirmar dados",
consent: "Consentimento",
otp: "Verificação",
sign: "Assinatura",
done: "Concluído",
};
const STEP_ORDER: WizardStep[] = ["documents", "data", "consent", "otp", "sign", "done"];
function currentStep(state: PublicSignatureState): WizardStep {
const { signer, documents } = state;
if (signer.status === "SIGNED") return "done";
if (signer.status === "OTP_VERIFIED") return "sign";
if (signer.status === "CONSENTED") return "otp";
if (signer.status === "DATA_CONFIRMED") return "consent";
const allConfirmed = documents.length > 0 && documents.every((d) => d.confirmedRead);
return allConfirmed ? "data" : "documents";
}
/**
* Public, unauthenticated signer flow — `/assinar/:token` (link the API
* e-mails via `EnvelopeService.sendInvite`). No `AuthenticatedLayout`, no
* sidebar: the signatário may be a lay end customer, not a backoffice
* user. Always re-fetches `GET /public-signature/:token` after every
* action (same "central reload" idiom as the authenticated detail pages
* in `routes/signatures/`) so closing the browser mid-flow and reopening
* the same link resumes at the right step from `signer.status` — eden.md
* §10.
*/
export function PublicSignaturePage() {
const { token } = useParams<{ token: string }>();
const [state, setState] = useState<PublicSignatureState | null>(null);
const [loadError, setLoadError] = useState<"not_found" | "gone" | "generic" | null>(null);
const [loading, setLoading] = useState(true);
const reload = useCallback(() => {
if (!token) return;
setLoading(true);
setLoadError(null);
getPublicSignatureState(token)
.then(setState)
.catch((err) => {
if (err instanceof ApiError && err.status === 404) setLoadError("not_found");
else if (err instanceof ApiError && err.status === 410) setLoadError("gone");
else setLoadError("generic");
})
.finally(() => setLoading(false));
}, [token]);
useEffect(() => reload(), [reload]);
return (
<div className="min-h-screen bg-light">
<header className="border-b border-border-color bg-white px-4 py-4">
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto h-8" />
</header>
<main className="mx-auto max-w-2xl px-4 py-8">
{loading ? (
<p className="text-center text-sm text-default">Carregando…</p>
) : loadError === "not_found" ? (
<Card>
<Alert variant="error" title="Link inválido">
Este link de assinatura não existe ou está incorreto. Confira o e-mail de convite ou solicite um novo
link ao remetente.
</Alert>
</Card>
) : loadError === "gone" ? (
<Card>
<Alert variant="error" title="Link indisponível">
Este link foi revogado, expirou, ou o envelope não está mais disponível para assinatura. Solicite um
novo convite.
</Alert>
</Card>
) : loadError === "generic" ? (
<Card>
<Alert variant="error">Não foi possível carregar os dados desta assinatura. Tente novamente.</Alert>
</Card>
) : state && token ? (
<SignatureWizard state={state} token={token} onChanged={reload} />
) : null}
</main>
</div>
);
}
function SignatureWizard({
state,
token,
onChanged,
}: {
state: PublicSignatureState;
token: string;
onChanged: () => void;
}) {
const step = currentStep(state);
return (
<div className="space-y-4">
<div className="mb-2 text-center">
<p className="text-sm text-default">Envelope {state.envelope.envelopeNumber}</p>
<h1 className="!text-xl font-bold text-title">Assinatura eletrônica</h1>
</div>
<StepProgress current={step} />
{step === "documents" ? <DocumentsStep state={state} token={token} onChanged={onChanged} /> : null}
{step === "data" ? <ConfirmDataStep state={state} token={token} onChanged={onChanged} /> : null}
{step === "consent" ? <ConsentStep token={token} onChanged={onChanged} /> : null}
{step === "otp" ? <OtpStep token={token} onChanged={onChanged} /> : null}
{step === "sign" ? <SignStep token={token} onChanged={onChanged} /> : null}
{step === "done" ? <DoneStep /> : null}
</div>
);
}
function StepProgress({ current }: { current: WizardStep }) {
const currentIndex = STEP_ORDER.indexOf(current);
return (
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-default">
{STEP_ORDER.map((step, index) => (
<span
key={step}
className={
index < currentIndex
? "rounded-full bg-success-transparent px-2 py-1 text-success-900"
: index === currentIndex
? "rounded-full bg-primary-transparent px-2 py-1 font-semibold text-primary-900"
: "rounded-full bg-light px-2 py-1"
}
>
{STEP_LABEL[step]}
</span>
))}
</div>
);
}
function DocumentsStep({
state,
token,
onChanged,
}: {
state: PublicSignatureState;
token: string;
onChanged: () => void;
}) {
const [confirmingId, setConfirmingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const allConfirmed = state.documents.length > 0 && state.documents.every((d) => d.confirmedRead);
async function handleConfirm(documentId: string) {
setError(null);
setConfirmingId(documentId);
try {
await confirmDocumentRead(token, documentId);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível confirmar a leitura.");
} finally {
setConfirmingId(null);
}
}
return (
<Card title="1. Leia os documentos">
<p className="mb-4 text-sm text-default">
Abra e leia cada documento antes de confirmar. Você só pode prosseguir depois de confirmar a leitura de
todos.
</p>
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<div className="space-y-3">
{state.documents.map((doc) => (
<div key={doc.id} className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border-color p-3">
<div>
<p className="text-sm font-semibold text-title">{doc.title}</p>
<p className="text-xs text-default">
Versão {doc.documentVersion}
{doc.pageCount ? ` · ${doc.pageCount} página(s)` : ""}
</p>
</div>
<div className="flex items-center gap-2">
<a
href={publicDocumentPdfUrl(token, doc.id)}
target="_blank"
rel="noopener noreferrer"
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
>
Abrir PDF
</a>
{doc.confirmedRead ? (
<Badge variant="success">Leitura confirmada</Badge>
) : (
<Button type="button" size="sm" loading={confirmingId === doc.id} onClick={() => handleConfirm(doc.id)}>
Confirmar leitura
</Button>
)}
</div>
</div>
))}
</div>
{!allConfirmed ? (
<p className="mt-4 text-xs text-default">Confirme a leitura de todos os documentos para continuar.</p>
) : null}
</Card>
);
}
function ConfirmDataStep({
state,
token,
onChanged,
}: {
state: PublicSignatureState;
token: string;
onChanged: () => void;
}) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConfirm() {
setError(null);
setSubmitting(true);
try {
await confirmSignerData(token);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível confirmar seus dados.");
} finally {
setSubmitting(false);
}
}
return (
<Card title="2. Confirme seus dados">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<p className="mb-4 text-sm text-default">Confira se os dados abaixo estão corretos antes de continuar.</p>
<dl className="space-y-2 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-default">Nome</dt>
<dd className="font-semibold text-title">{state.signer.full_name}</dd>
</div>
{state.signer.cpf ? (
<div className="flex justify-between gap-3">
<dt className="text-default">CPF</dt>
<dd className="font-semibold text-title">{formatDocument(state.signer.cpf)}</dd>
</div>
) : null}
<div className="flex justify-between gap-3">
<dt className="text-default">E-mail</dt>
<dd className="font-semibold text-title">{state.signer.email}</dd>
</div>
</dl>
<Button type="button" className="mt-4 w-full" loading={submitting} onClick={handleConfirm}>
Confirmar meus dados
</Button>
</Card>
);
}
function ConsentStep({ token, onChanged }: { token: string; onChanged: () => void }) {
const [accepted, setAccepted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleAccept() {
setError(null);
setSubmitting(true);
try {
await acceptConsent(token);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível registrar o consentimento.");
} finally {
setSubmitting(false);
}
}
return (
<Card title="3. Termo de assinatura eletrônica">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<p className="mb-4 text-sm text-default">
Ao aceitar, você concorda em assinar este(s) documento(s) eletronicamente, com o mesmo valor jurídico de uma
assinatura manuscrita, mediante verificação por código enviado ao seu e-mail.
</p>
<label className="mb-4 flex items-start gap-2 text-sm">
<input type="checkbox" className="mt-1" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} />
<span>Li e aceito os termos de assinatura eletrônica.</span>
</label>
<Button type="button" className="w-full" disabled={!accepted} loading={submitting} onClick={handleAccept}>
Aceitar
</Button>
</Card>
);
}
function OtpStep({ token, onChanged }: { token: string; onChanged: () => void }) {
const [requested, setRequested] = useState(false);
const [requesting, setRequesting] = useState(false);
const [code, setCode] = useState("");
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
async function handleRequest() {
setError(null);
setInfo(null);
setRequesting(true);
try {
await requestOtp(token);
setRequested(true);
setInfo("Código enviado para seu e-mail. Válido por 5 minutos.");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível enviar o código.");
} finally {
setRequesting(false);
}
}
async function handleVerify() {
setError(null);
setVerifying(true);
try {
await verifyOtp(token, code);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível verificar o código.");
} finally {
setVerifying(false);
}
}
return (
<Card title="4. Verificação por código">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
{info ? (
<Alert variant="success" className="mb-3">
{info}
</Alert>
) : null}
{!requested ? (
<>
<p className="mb-4 text-sm text-default">
Vamos enviar um código de verificação de 6 dígitos para o seu e-mail cadastrado.
</p>
<Button type="button" className="w-full" loading={requesting} onClick={handleRequest}>
Enviar código de verificação
</Button>
</>
) : (
<>
<p className="mb-3 text-sm text-default">Digite o código de 6 dígitos que enviamos para o seu e-mail.</p>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="w-full rounded-md border border-border-color px-3 py-2.5 text-center text-lg tracking-[0.5em] focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<Button type="button" className="mt-3 w-full" loading={verifying} disabled={code.length !== 6} onClick={handleVerify}>
Verificar
</Button>
<button
type="button"
className="mt-3 w-full text-center text-xs text-default hover:text-primary"
onClick={handleRequest}
>
Reenviar código
</button>
</>
)}
</Card>
);
}
function SignStep({ token, onChanged }: { token: string; onChanged: () => void }) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSign() {
setError(null);
setSubmitting(true);
try {
await commitSignature(token);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível registrar a assinatura.");
} finally {
setSubmitting(false);
}
}
return (
<Card title="5. Assinar documento">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<p className="mb-4 text-sm text-default">
Ao clicar em assinar, sua manifestação de vontade será registrada de forma definitiva para este documento.
</p>
<Button type="button" className="w-full" loading={submitting} onClick={handleSign}>
Assinar documento
</Button>
</Card>
);
}
function DoneStep() {
return (
<Card title="Documento assinado">
<Alert variant="success">
Documento assinado! Você receberá o PDF final por e-mail assim que todas as partes concluírem.
</Alert>
</Card>
);
}

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, Badge, Card } from "@eden/ui";
import { ApiError } from "../lib/api/http.js";
import { getPublicVerification } from "../lib/api/public-verify.js";
import type { PublicVerificationResult } from "../lib/api/public-verify.js";
import { formatDateTimeBR } from "../lib/format.js";
/**
* Public, unauthenticated integrity-check page — `/verificar/:verificationId`
* (link printed on the completion certificate and the final e-mail, see
* `EnvelopeFinalizeService.finalize`). No PDF download here on purpose —
* `PublicVerifyService` never exposes one; the final PDF only ever goes
* out by e-mail to the signers (eden.md).
*/
export function PublicVerifyPage() {
const { verificationId } = useParams<{ verificationId: string }>();
const [result, setResult] = useState<PublicVerificationResult | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!verificationId) return;
setLoading(true);
getPublicVerification(verificationId)
.then(setResult)
.catch((err) => {
if (err instanceof ApiError && err.status === 404) setNotFound(true);
else setError(err instanceof ApiError ? err.message : "Não foi possível verificar este documento.");
})
.finally(() => setLoading(false));
}, [verificationId]);
return (
<div className="min-h-screen bg-light">
<header className="border-b border-border-color bg-white px-4 py-4">
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto h-8" />
</header>
<main className="mx-auto max-w-2xl px-4 py-8">
{loading ? (
<p className="text-center text-sm text-default">Verificando…</p>
) : notFound ? (
<Card>
<Alert variant="error" title="Registro não encontrado">
Este código de verificação não corresponde a nenhum documento concluído. Confira o link ou o código
informado.
</Alert>
</Card>
) : error ? (
<Card>
<Alert variant="error">{error}</Alert>
</Card>
) : result ? (
<VerificationResultView result={result} />
) : null}
</main>
</div>
);
}
function VerificationResultView({ result }: { result: PublicVerificationResult }) {
return (
<div className="space-y-4">
<Card>
<div className="text-center">
{result.integrityValid ? (
<>
<p className="text-3xl">✓</p>
<h1 className="!text-xl font-bold text-success-900">Documento verificado</h1>
<p className="mt-1 text-sm text-default">A integridade deste documento foi confirmada.</p>
</>
) : (
<>
<p className="text-3xl">⚠</p>
<h1 className="!text-xl font-bold text-danger-900">Integridade comprometida</h1>
<p className="mt-1 text-sm text-default">
Não foi possível confirmar a integridade deste documento — os dados podem ter sido adulterados.
</p>
</>
)}
</div>
</Card>
<Card title="Envelope">
<dl className="space-y-2 text-sm">
<Row label="Número" value={result.envelopeNumber} />
<Row label="Concluído em" value={formatDateTimeBR(result.completedAt)} />
<Row label="Método" value={result.method} />
<Row label="Eventos de auditoria verificados" value={String(result.eventsChecked)} />
</dl>
</Card>
<Card title="Documentos">
{result.documents.length === 0 ? (
<p className="text-sm text-default">Nenhum documento.</p>
) : (
<ul className="space-y-2 text-sm">
{result.documents.map((doc, index) => (
<li key={index} className="flex items-center justify-between rounded-md border border-border-color p-2">
<span className="font-semibold text-title">{doc.title}</span>
<span className="text-xs text-default">
v{doc.version}
{doc.pageCount ? ` · ${doc.pageCount} página(s)` : ""}
</span>
</li>
))}
</ul>
)}
</Card>
<Card title="Signatários">
{result.signers.length === 0 ? (
<p className="text-sm text-default">Nenhum signatário.</p>
) : (
<ul className="space-y-2 text-sm">
{result.signers.map((signer, index) => (
<li key={index} className="flex items-center justify-between rounded-md border border-border-color p-2">
<span className="font-semibold text-title">{signer.name}</span>
<Badge variant={signer.party === "contratada" ? "primary" : "info"}>
{signer.party === "contratada" ? "Contratada" : "Contratante"}
</Badge>
</li>
))}
</ul>
)}
</Card>
<Card title="Hashes de integridade">
<div className="space-y-3 text-sm">
<HashField label="Hash do envelope" value={result.envelopeHash} />
<HashField label="Hash do PDF final (SHA-256)" value={result.finalPdfSha256} />
<HashField label="Hash do checkpoint de evidências" value={result.evidenceCheckpointHash} />
<HashField label="ID de verificação" value={result.verificationId} />
</div>
</Card>
</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 HashField({ label, value }: { label: string; value: string | null }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
if (!value) {
return (
<div>
<p className="text-xs font-semibold uppercase text-default">{label}</p>
<p className="font-mono text-xs text-default">—</p>
</div>
);
}
const displayValue = expanded ? value : `${value.slice(0, 16)}…${value.slice(-8)}`;
async function handleCopy() {
try {
await navigator.clipboard.writeText(value ?? "");
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
setCopied(false);
}
}
return (
<div>
<p className="text-xs font-semibold uppercase text-default">{label}</p>
<div className="flex items-center gap-2">
<p className="break-all font-mono text-xs text-title">{displayValue}</p>
<button type="button" className="shrink-0 text-xs text-primary underline" onClick={() => setExpanded((v) => !v)}>
{expanded ? "recolher" : "expandir"}
</button>
<button type="button" className="shrink-0 text-xs text-primary underline" onClick={handleCopy}>
{copied ? "copiado!" : "copiar"}
</button>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,398 @@
import { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { createEnvelope } from "../../lib/api/signatures.js";
import type { PersonType, SignerInput } from "../../lib/api/signatures.js";
import { listCompanies } from "../../lib/api/companies.js";
import type { Company } from "../../lib/api/companies.js";
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
import type { ClientRegistration } from "../../lib/api/client-registrations.js";
import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js";
import { listDocumentTemplates, listGenerations } from "../../lib/api/documents.js";
import type { DocumentGeneration, DocumentTemplate } from "../../lib/api/documents.js";
import { formatDateTimeBR } from "../../lib/format.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
type OriginType = "cliente" | "revenda";
interface SignerRow {
fullName: string;
personType: PersonType | "";
cpf: string;
email: string;
phone: string;
companyName: string;
cnpj: string;
roleTitle: string;
}
function emptySignerRow(): SignerRow {
return { fullName: "", personType: "", cpf: "", email: "", phone: "", companyName: "", cnpj: "", roleTitle: "" };
}
function clientRegistrationName(registration: ClientRegistration): string {
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
return name ?? `Cadastro #${registration.client_code}`;
}
/**
* Creation form for `POST /signature-envelopes`. The `contratada` signer
* is never asked here — the API auto-fills it from
* `companies.legal_rep_*` and 400s with a clear message if the selected
* company has none, which this form surfaces with a direct link to fix
* the company record (eden.md §5.3/§5.4). Only `documentGenerationIds`
* with `status: 'final'` are offered, mirroring the API's own validation
* for a better UX (picking a non-final generation would 400 server-side
* anyway).
*/
export function EnvelopeFormPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [companies, setCompanies] = useState<Company[] | null>(null);
const [clientRegistrations, setClientRegistrations] = useState<ClientRegistration[]>([]);
const [resellers, setResellers] = useState<Reseller[]>([]);
const [generations, setGenerations] = useState<DocumentGeneration[]>([]);
const [templates, setTemplates] = useState<DocumentTemplate[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [companyId, setCompanyId] = useState("");
const [originType, setOriginType] = useState<OriginType>("cliente");
const [clientRegistrationId, setClientRegistrationId] = useState("");
const [resellerId, setResellerId] = useState("");
const [selectedGenerationIds, setSelectedGenerationIds] = useState<string[]>([]);
const [signers, setSigners] = useState<SignerRow[]>([emptySignerRow()]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
setLoadError(null);
Promise.all([
listCompanies(accessToken),
listClientRegistrations(accessToken),
listResellers(accessToken),
listGenerations(accessToken),
listDocumentTemplates(accessToken),
])
.then(([companyList, registrations, resellerList, generationList, templateList]) => {
setCompanies(companyList);
setClientRegistrations(registrations);
setResellers(resellerList);
setGenerations(generationList);
setTemplates(templateList);
})
.catch((err) =>
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar os dados do formulário."),
);
}, [accessToken]);
const templateById = useMemo(() => new Map(templates.map((t) => [t.id, t])), [templates]);
const finalGenerations = useMemo(
() => generations.filter((g) => g.status === "final" && g.pdf_s3_key),
[generations],
);
function toggleGeneration(id: string) {
setSelectedGenerationIds((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]));
}
function updateSigner(index: number, patch: Partial<SignerRow>) {
setSigners((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
}
function addSigner() {
setSigners((prev) => [...prev, emptySignerRow()]);
}
function removeSigner(index: number) {
setSigners((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
if (!companyId) {
setError("Selecione a empresa emissora (contratada).");
return;
}
if (originType === "cliente" && !clientRegistrationId) {
setError("Selecione o cadastro de cliente de origem.");
return;
}
if (originType === "revenda" && !resellerId) {
setError("Selecione a revenda de origem.");
return;
}
if (selectedGenerationIds.length === 0) {
setError("Selecione ao menos um documento gerado para incluir no envelope.");
return;
}
const validSigners: SignerInput[] = signers
.filter((row) => row.fullName.trim() && row.email.trim())
.map((row) => ({
fullName: row.fullName.trim(),
email: row.email.trim(),
personType: row.personType || undefined,
cpf: row.cpf || undefined,
phone: row.phone || undefined,
companyName: row.companyName || undefined,
cnpj: row.cnpj || undefined,
roleTitle: row.roleTitle || undefined,
}));
if (validSigners.length === 0) {
setError("Adicione ao menos um signatário (contratante) com nome e e-mail.");
return;
}
setSubmitting(true);
try {
const envelope = await createEnvelope(
{
companyId,
clientRegistrationId: originType === "cliente" ? clientRegistrationId : undefined,
resellerId: originType === "revenda" ? resellerId : undefined,
documentGenerationIds: selectedGenerationIds,
signers: validSigners,
},
accessToken,
);
navigate(`/signatures/${envelope.id}`);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível criar o envelope de assinatura.");
} finally {
setSubmitting(false);
}
}
const showLegalRepHint = error?.toLowerCase().includes("representante legal") ?? false;
return (
<div>
<Breadcrumb items={[{ label: "Assinaturas", href: "/signatures" }, { label: "Novo envelope" }]} />
<PageHeader title="Novo envelope de assinatura" description="Assinatura eletrônica de documentos" />
{loadError ? (
<Alert variant="error" className="mb-4">
{loadError}
</Alert>
) : null}
{error ? (
<Alert variant="error" className="mb-4" title={showLegalRepHint ? "Empresa sem representante legal" : undefined}>
{error}
{showLegalRepHint && companyId ? (
<p className="mt-2">
<Link to={`/companies/${companyId}/edit`} className="font-semibold underline">
Cadastrar representante legal desta empresa
</Link>
</p>
) : null}
</Alert>
) : null}
<Card>
{!companies ? (
<p className="text-sm text-default">Carregando empresas, clientes, revendas e documentos…</p>
) : (
<form className="space-y-8" onSubmit={handleSubmit}>
<div>
<h2 className="mb-4 text-sm font-semibold text-title">Empresa emissora (contratada)</h2>
<Select
label="Empresa"
options={companies.map((c) => ({ value: c.id, label: c.company_name }))}
placeholder="Selecione a empresa"
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
hint="O signatário contratada é preenchido automaticamente a partir do representante legal desta empresa."
required
/>
</div>
<div>
<h2 className="mb-4 text-sm font-semibold text-title">Origem</h2>
<div className="mb-3 flex items-center gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="radio"
name="originType"
checked={originType === "cliente"}
onChange={() => setOriginType("cliente")}
/>
Cadastro de cliente
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="originType"
checked={originType === "revenda"}
onChange={() => setOriginType("revenda")}
/>
Revenda
</label>
</div>
{originType === "cliente" ? (
<Select
label="Cadastro de cliente"
options={clientRegistrations.map((r) => ({ value: r.id, label: clientRegistrationName(r) }))}
placeholder="Selecione o cliente"
value={clientRegistrationId}
onChange={(e) => setClientRegistrationId(e.target.value)}
required
/>
) : (
<Select
label="Revenda"
options={resellers.map((r) => ({ value: r.id, label: r.name }))}
placeholder="Selecione a revenda"
value={resellerId}
onChange={(e) => setResellerId(e.target.value)}
required
/>
)}
</div>
<div>
<h2 className="mb-4 text-sm font-semibold text-title">Documentos</h2>
{finalGenerations.length === 0 ? (
<p className="text-sm text-default">
Nenhum documento gerado com PDF pronto ainda.{" "}
<Link to="/documents/generations/new" className="font-semibold text-primary underline">
Gerar um documento
</Link>{" "}
antes de criar o envelope.
</p>
) : (
<div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border-color p-3">
{finalGenerations.map((generation) => {
const template = templateById.get(generation.template_id);
return (
<label
key={generation.id}
className="flex items-center justify-between gap-3 rounded-md border border-border-color p-2 text-sm hover:bg-light"
>
<span className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedGenerationIds.includes(generation.id)}
onChange={() => toggleGeneration(generation.id)}
/>
<span>
<span className="font-semibold text-title">{template?.name ?? generation.template_id}</span>
<span className="ml-2 text-xs text-default">
gerado em {formatDateTimeBR(generation.created_at)}
</span>
</span>
</span>
</label>
);
})}
</div>
)}
</div>
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-title">Signatários (contratante)</h2>
<button
type="button"
onClick={addSigner}
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
>
+ Adicionar signatário
</button>
</div>
<p className="mb-3 text-xs text-default">
O signatário contratada não é informado aqui — ele é preenchido automaticamente pelo servidor.
</p>
<div className="space-y-4">
{signers.map((row, index) => (
<div key={index} className="rounded-md border border-border-color p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-default">Contratante {index + 1}</span>
{signers.length > 1 ? (
<button
type="button"
onClick={() => removeSigner(index)}
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light"
>
Remover
</button>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Input
label="Nome completo / razão social"
value={row.fullName}
onChange={(e) => updateSigner(index, { fullName: e.target.value })}
required
/>
<Select
label="Tipo de pessoa (opcional)"
options={[
{ value: "pf", label: "Pessoa física" },
{ value: "pj", label: "Pessoa jurídica" },
]}
placeholder="Selecione"
value={row.personType}
onChange={(e) => updateSigner(index, { personType: e.target.value as PersonType | "" })}
/>
<Input
label="CPF (opcional)"
value={row.cpf}
onChange={(e) => updateSigner(index, { cpf: e.target.value })}
/>
<Input
label="E-mail"
type="email"
value={row.email}
onChange={(e) => updateSigner(index, { email: e.target.value })}
required
/>
<Input
label="Telefone (opcional)"
value={row.phone}
onChange={(e) => updateSigner(index, { phone: e.target.value })}
/>
<Input
label="Cargo (opcional)"
value={row.roleTitle}
onChange={(e) => updateSigner(index, { roleTitle: e.target.value })}
/>
<Input
label="Empresa (opcional, se PJ)"
value={row.companyName}
onChange={(e) => updateSigner(index, { companyName: e.target.value })}
/>
<Input
label="CNPJ (opcional, se PJ)"
value={row.cnpj}
onChange={(e) => updateSigner(index, { cnpj: e.target.value })}
/>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-2 border-t border-border-color pt-5">
<Button type="submit" loading={submitting}>
Criar envelope
</Button>
<Button type="button" variant="secondary" onClick={() => navigate("/signatures")}>
Cancelar
</Button>
</div>
</form>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Alert, Badge, 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 { getEnvelope, listEnvelopes } from "../../lib/api/signatures.js";
import type { EnvelopeStatus, EnvelopeWithDetails } from "../../lib/api/signatures.js";
import { listCompanies } from "../../lib/api/companies.js";
import type { Company } from "../../lib/api/companies.js";
import { formatDateBR } from "../../lib/format.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
import {
ENVELOPE_STATUS_LABEL,
ENVELOPE_STATUS_VARIANT,
SIGNER_STATUS_LABEL,
SIGNER_STATUS_VARIANT,
} from "./signatureStatus.js";
/**
* Layout matches `ContractsListPage`/`TicketsListPage` (bordered white
* card, flush table, colored status pills). `GET /signature-envelopes`
* only returns bare envelope rows (no signers — see
* `packages/database/src/repositories/signatures.ts`'s `listEnvelopes`),
* so — same pattern as `TicketsListPage` enriching each row with a
* `GET /support/tickets/:id` call for the SLA timer — this fetches
* `getEnvelope` per row afterwards to show per-signer status here. Fine
* at the expected volume of e-signature envelopes for a backoffice; would
* need a real join if that ever became a bottleneck.
*/
export function EnvelopesListPage() {
const { accessToken, permissions } = useAuth();
const [envelopes, setEnvelopes] = useState<EnvelopeWithDetails[] | null>(null);
const [companies, setCompanies] = useState<Company[]>([]);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<"" | EnvelopeStatus>("");
const canCreate = hasPermission(permissions, "signatures", "edit");
const load = useCallback(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listEnvelopes(accessToken)
.then(async (bare) => {
const detailed = await Promise.all(bare.map((e) => getEnvelope(e.id, accessToken)));
if (cancelled) return;
setEnvelopes(detailed);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar os envelopes.");
});
listCompanies(accessToken)
.then((result) => {
if (!cancelled) setCompanies(result);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [accessToken]);
useEffect(() => load(), [load]);
const companyById = useMemo(() => new Map(companies.map((c) => [c.id, c])), [companies]);
const visibleEnvelopes = useMemo(() => {
if (!envelopes) return null;
if (!statusFilter) return envelopes;
return envelopes.filter((e) => e.status === statusFilter);
}, [envelopes, statusFilter]);
return (
<div>
<PageHeader
title="Assinaturas"
description="Envelopes de assinatura eletrônica"
actions={
canCreate ? (
<Link to="/signatures/new" className={PRIMARY_LINK_BUTTON_CLASS}>
Novo envelope
</Link>
) : undefined
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<div className="mb-4 max-w-xs">
<Select
label="Filtrar por status"
options={ENVELOPE_STATUSES_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as "" | EnvelopeStatus)}
/>
</div>
<Card bodyClassName="p-0">
{envelopes === null && !error ? (
<p className="p-5 text-sm text-default">Carregando envelopes…</p>
) : visibleEnvelopes && visibleEnvelopes.length === 0 ? (
<p className="p-5 text-sm text-default">
{statusFilter ? "Nenhum envelope com este status." : "Nenhum envelope de assinatura criado ainda."}
</p>
) : visibleEnvelopes ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="px-5 py-3 font-semibold text-title">Envelope</th>
<th className="px-5 py-3 font-semibold text-title">Empresa</th>
<th className="px-5 py-3 font-semibold text-title">Status</th>
<th className="px-5 py-3 font-semibold text-title">Signatários</th>
<th className="px-5 py-3 font-semibold text-title">Criado em</th>
</tr>
</thead>
<tbody>
{visibleEnvelopes.map((envelope) => (
<tr key={envelope.id} className="border-b border-border-color last:border-0 hover:bg-light">
<td className="px-5 py-3">
<Link
to={`/signatures/${envelope.id}`}
className="font-semibold text-title hover:text-primary"
>
{envelope.envelope_number}
</Link>
</td>
<td className="px-5 py-3 text-default">
{companyById.get(envelope.company_id)?.company_name ?? envelope.company_id}
</td>
<td className="px-5 py-3">
<Badge variant={ENVELOPE_STATUS_VARIANT[envelope.status]}>
{ENVELOPE_STATUS_LABEL[envelope.status]}
</Badge>
</td>
<td className="px-5 py-3">
<div className="flex flex-col gap-1">
{envelope.signers.map((signer) => (
<div key={signer.id} className="flex items-center gap-2">
<span className="text-default">{signer.full_name}</span>
<Badge variant={SIGNER_STATUS_VARIANT[signer.status]} className="shrink-0">
{SIGNER_STATUS_LABEL[signer.status]}
</Badge>
</div>
))}
</div>
</td>
<td className="px-5 py-3 text-default">{formatDateBR(envelope.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
</div>
);
}
const ENVELOPE_STATUSES_OPTIONS = (
Object.keys(ENVELOPE_STATUS_LABEL) as EnvelopeStatus[]
).map((status) => ({ value: status, label: ENVELOPE_STATUS_LABEL[status] }));

View File

@@ -0,0 +1,136 @@
import type { BadgeVariant } from "@eden/ui";
import type { EnvelopeStatus, SignerStatus } from "../../lib/api/signatures.js";
/**
* pt-BR labels/badge colors for the envelope's 19-state machine (see the
* migration's CHECK constraint + `ALLOWED_TRANSITIONS` in
* apps/api/src/signatures/state-machine.ts). Several of these
* (VIEWED..READY_TO_SIGN/SIGNING) are legacy-fidelity states the envelope
* itself never actually reaches in this leva — per-signer granularity is
* tracked on `signature_signers.status` instead, and SENT jumps straight
* to SIGNED (see `EnvelopeService`'s doc-comment) — but they're kept here
* so the UI never crashes on an unexpected value and stays ready if that
* changes.
*/
export const ENVELOPE_STATUS_LABEL: Record<EnvelopeStatus, string> = {
DRAFT: "Rascunho",
READY: "Pronto para envio",
SENT: "Enviado",
VIEWED: "Visualizado",
IDENTITY_PENDING: "Identidade pendente",
CONSENT_PENDING: "Consentimento pendente",
OTP_PENDING: "Código pendente",
OTP_SENT: "Código enviado",
OTP_VERIFIED: "Código verificado",
READY_TO_SIGN: "Pronto para assinar",
SIGNING: "Assinando",
SIGNED: "Assinado",
FINALIZING: "Finalizando",
COMPLETED: "Concluído",
DECLINED: "Recusado",
EXPIRED: "Expirado",
CANCELLED: "Cancelado",
SUPERSEDED: "Substituído",
ERROR: "Erro",
};
export const ENVELOPE_STATUS_VARIANT: Record<EnvelopeStatus, BadgeVariant> = {
DRAFT: "neutral",
READY: "info",
SENT: "info",
VIEWED: "info",
IDENTITY_PENDING: "warning",
CONSENT_PENDING: "warning",
OTP_PENDING: "warning",
OTP_SENT: "warning",
OTP_VERIFIED: "warning",
READY_TO_SIGN: "warning",
SIGNING: "warning",
SIGNED: "success",
FINALIZING: "warning",
COMPLETED: "success",
DECLINED: "danger",
EXPIRED: "neutral",
CANCELLED: "danger",
SUPERSEDED: "neutral",
ERROR: "danger",
};
export const SIGNER_STATUS_LABEL: Record<SignerStatus, string> = {
PENDING: "Pendente",
LINK_SENT: "Convite enviado",
VIEWED: "Visualizado",
DATA_CONFIRMED: "Dados confirmados",
CONSENTED: "Consentimento aceito",
OTP_VERIFIED: "Código verificado",
SIGNED: "Assinado",
DECLINED: "Recusado",
};
export const SIGNER_STATUS_VARIANT: Record<SignerStatus, BadgeVariant> = {
PENDING: "neutral",
LINK_SENT: "info",
VIEWED: "info",
DATA_CONFIRMED: "warning",
CONSENTED: "warning",
OTP_VERIFIED: "warning",
SIGNED: "success",
DECLINED: "danger",
};
const PARTY_ROLE_LABEL: Record<"contratada" | "contratante", string> = {
contratada: "Contratada",
contratante: "Contratante",
};
export function partyRoleLabel(party: "contratada" | "contratante"): string {
return PARTY_ROLE_LABEL[party] ?? party;
}
/** Best-effort pt-BR label for `signature_audit_events.event_type` — falls back to a humanized version of the raw value for anything not explicitly listed, since the column is free-text (`string`, not a DB enum). */
const AUDIT_EVENT_LABEL: Record<string, string> = {
ENVELOPE_CREATED: "Envelope criado",
DOCUMENT_FROZEN: "Documento congelado",
DOCUMENT_HASHED: "Hash do documento calculado",
ENVELOPE_READY: "Envelope pronto para envio",
INVITE_SENT: "Convite enviado",
LINK_OPENED: "Link aberto pelo signatário",
DOCUMENT_VIEWED: "Documento visualizado",
DOCUMENT_READ_CONFIRMED: "Leitura do documento confirmada",
CUSTOMER_DATA_CONFIRMED: "Dados do signatário confirmados",
ELECTRONIC_SIGNATURE_TERMS_ACCEPTED: "Termos de assinatura eletrônica aceitos",
OTP_REQUESTED: "Código de verificação solicitado",
OTP_SENT: "Código de verificação enviado",
OTP_FAILED: "Código de verificação incorreto",
OTP_BLOCKED: "Código de verificação bloqueado (muitas tentativas)",
OTP_EXPIRED: "Código de verificação expirado",
OTP_VERIFIED: "Código de verificação validado",
SIGN_BUTTON_PRESSED: "Botão de assinar pressionado",
SIGNATURE_COMMITTED: "Assinatura registrada",
ENVELOPE_SIGNED: "Envelope assinado por todas as partes",
EVIDENCE_CHECKPOINT_SEALED: "Checkpoint de evidências selado",
ENVELOPE_HASH_CALCULATED: "Hash do envelope calculado",
CERTIFICATE_GENERATED: "Certificado de conclusão gerado",
CONSOLIDATED_PDF_GENERATED: "PDF final consolidado",
FINAL_PDF_HASH_CALCULATED: "Hash do PDF final calculado",
ENVELOPE_COMPLETED: "Envelope concluído",
FINAL_DOCUMENT_EMAILED: "PDF final enviado por e-mail",
ENVELOPE_CANCELLED: "Envelope cancelado",
};
export function auditEventLabel(eventType: string): string {
return AUDIT_EVENT_LABEL[eventType] ?? eventType.replaceAll("_", " ").toLowerCase();
}
const ACTOR_TYPE_LABEL: Record<string, string> = {
SIGNER: "Signatário",
BACKOFFICE: "Backoffice",
ADMIN: "Administrador",
SUPER_ADMIN: "Super admin",
SYSTEM: "Sistema",
SERVICE: "Serviço",
};
export function actorTypeLabel(actorType: string): string {
return ACTOR_TYPE_LABEL[actorType] ?? actorType;
}