feat(signatures): UI completa do módulo + rate limiting em rotas públicas

Fecha as duas pendências da leva anterior (módulo de Assinatura
Eletrônica): tela de backoffice para gerenciar envelopes (criação,
envio, cancelamento, checkpoint/finalização, auditoria) e as duas
páginas públicas (/assinar/:token, /verificar/:verificationId).

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:51:18 -03:00
parent 20b114a0bf
commit 9c14671e19
30 changed files with 2946 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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