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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user