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