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

@@ -12,6 +12,16 @@ import { ResetPasswordDto } from "./dto/reset-password.dto.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { CurrentUser } from "./decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
import { RateLimitGuard } from "../common/rate-limit/rate-limit.guard.js";
import { RateLimit } from "../common/rate-limit/rate-limit.decorator.js";
import {
AUTH_FORGOT_PASSWORD_RULE,
AUTH_LOGIN_RULE,
AUTH_MFA_CHALLENGE_RULE,
AUTH_LOGOUT_RULE,
AUTH_REFRESH_RULE,
AUTH_RESET_PASSWORD_RULE,
} from "../common/rate-limit/rate-limit-rules.js";
function requestContext(req: Request) {
return {
@@ -22,6 +32,7 @@ function requestContext(req: Request) {
@ApiTags("auth")
@Controller("v1/auth")
@UseGuards(RateLimitGuard)
export class AuthController {
constructor(
private readonly auth: AuthService,
@@ -30,12 +41,14 @@ export class AuthController {
@Post("login")
@HttpCode(200)
@RateLimit(AUTH_LOGIN_RULE)
async login(@Body() dto: LoginDto, @Req() req: Request) {
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
}
@Post("mfa/challenge")
@HttpCode(200)
@RateLimit(AUTH_MFA_CHALLENGE_RULE)
async mfaChallenge(@Body() dto: MfaChallengeDto, @Req() req: Request) {
return this.auth.completeMfaLogin(dto.mfaChallenge, dto.code, requestContext(req));
}
@@ -65,6 +78,7 @@ export class AuthController {
@Post("forgot-password")
@HttpCode(200)
@RateLimit(AUTH_FORGOT_PASSWORD_RULE)
async forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: Request) {
await this.auth.forgotPassword(dto.email, requestContext(req));
// Always the same response regardless of whether the email is
@@ -74,18 +88,21 @@ export class AuthController {
@Post("reset-password")
@HttpCode(204)
@RateLimit(AUTH_RESET_PASSWORD_RULE)
async resetPassword(@Body() dto: ResetPasswordDto, @Req() req: Request) {
await this.auth.resetPassword(dto.token, dto.newPassword, requestContext(req));
}
@Post("refresh")
@HttpCode(200)
@RateLimit(AUTH_REFRESH_RULE)
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
return this.auth.refresh(dto.refreshToken, requestContext(req));
}
@Post("logout")
@HttpCode(204)
@RateLimit(AUTH_LOGOUT_RULE)
async logout(@Body() dto: RefreshDto, @Req() req: Request) {
await this.auth.logout(dto.refreshToken, requestContext(req));
}

View File

@@ -8,8 +8,10 @@ import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { PermissionGuard } from "./permission.guard.js";
import { SuperAdminGuard } from "./super-admin.guard.js";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
import { RateLimitModule } from "../common/rate-limit/rate-limit.module.js";
@Module({
imports: [RateLimitModule],
controllers: [AuthController],
providers: [
AuthService,

View File

@@ -0,0 +1,120 @@
import type { RateLimitRule } from "./rate-limit.decorator.js";
const MINUTE = 60_000;
/**
* Concrete limits for every public (no-session) route in this API.
* Numbers are deliberately round, chosen for two constraints at once:
* - generous enough that this project's own real-app integration test
* suite (apps/api/test/*.spec.ts — real login/refresh/signature flows,
* all from the same loopback IP) never trips them by accident;
* - tight enough to turn an unthrottled endpoint (previously: unlimited
* requests/second) into a hard, auditable ceiling per IP or per link.
* None of these claim to fully stop a determined, IP-rotating attacker —
* that's a documented residual risk in docs/security/threat-model.md,
* not a gap this change pretends to close.
*/
// --- apps/api/src/auth/auth.controller.ts -----------------------------
/** Credential guessing target — argon2 already raises the per-guess cost; this caps guess *rate*. */
export const AUTH_LOGIN_RULE: RateLimitRule = { name: "auth-login", keyBy: "ip", limit: 10, windowMs: MINUTE };
/** Same order of magnitude as login: this is the second half of an MFA login, equally sensitive. */
export const AUTH_MFA_CHALLENGE_RULE: RateLimitRule = {
name: "auth-mfa-challenge",
keyBy: "ip",
limit: 10,
windowMs: MINUTE,
};
/** Doesn't leak account existence (anti-enumeration response either way), but still caps inbox-spam/abuse potential. */
export const AUTH_FORGOT_PASSWORD_RULE: RateLimitRule = {
name: "auth-forgot-password",
keyBy: "ip",
limit: 10,
windowMs: MINUTE,
};
/** Token is 256-bit random (auth.service.ts) — not brute-forceable; this is generic abuse/DoS hygiene. */
export const AUTH_RESET_PASSWORD_RULE: RateLimitRule = {
name: "auth-reset-password",
keyBy: "ip",
limit: 10,
windowMs: MINUTE,
};
/** Refresh tokens are 256-bit random too; higher ceiling because legitimate multi-tab/background refresh is frequent by design. */
export const AUTH_REFRESH_RULE: RateLimitRule = { name: "auth-refresh", keyBy: "ip", limit: 30, windowMs: MINUTE };
/**
* Not in the task's original enumerated list, but POST /v1/auth/logout has
* no JwtAuthGuard either (auth.controller.ts) — genuinely public. No
* guessing target (needs an already-known refresh token), so this is
* generic abuse/DoS hygiene only, same ceiling as refresh.
*/
export const AUTH_LOGOUT_RULE: RateLimitRule = { name: "auth-logout", keyBy: "ip", limit: 30, windowMs: MINUTE };
// --- apps/api/src/signatures/public-signature.controller.ts -----------
/**
* Shared across every /v1/public-signature/:token/* handler, keyed by IP —
* catches one source scanning/hammering many DIFFERENT tokens (link
* enumeration or scraping), which a per-token limit alone can't see.
*/
export const PUBLIC_SIGNATURE_IP_RULE: RateLimitRule = {
name: "pubsig-ip",
keyBy: "ip",
limit: 60,
windowMs: MINUTE,
};
/**
* Per-token ceiling for the ordinary read/ack steps of the flow — a real
* signer hits each of these once or twice (reload, double-click); this
* only bites automated hammering of one leaked link, from any number of
* source IPs (that's the point of keying by token, not IP, here).
*/
export const PUBLIC_SIGNATURE_TOKEN_ACTION_RULE: RateLimitRule = {
name: "pubsig-token-action",
keyBy: "token",
limit: 20,
windowMs: 5 * MINUTE,
};
/** Sends an email — tighter, since abuse here has a real cost (spamming the signer's inbox), not just a wasted read. */
export const PUBLIC_SIGNATURE_OTP_REQUEST_RULE: RateLimitRule = {
name: "pubsig-otp-request",
keyBy: "token",
limit: 5,
windowMs: 15 * MINUTE,
};
/**
* Complementary to the DB-persisted per-challenge lockout already in
* public-signature.service.ts::verifyOtp (blocks after N wrong codes,
* survives across source IPs) — this caps raw request *rate* against the
* endpoint itself, independent of whether attempts are right or wrong.
*/
export const PUBLIC_SIGNATURE_OTP_VERIFY_RULE: RateLimitRule = {
name: "pubsig-otp-verify",
keyBy: "token",
limit: 15,
windowMs: 5 * MINUTE,
};
// --- apps/api/src/signatures/public-verify.controller.ts --------------
export const PUBLIC_VERIFY_IP_RULE: RateLimitRule = {
name: "pubverify-ip",
keyBy: "ip",
limit: 60,
windowMs: MINUTE,
};
export const PUBLIC_VERIFY_ID_RULE: RateLimitRule = {
name: "pubverify-id",
keyBy: "verificationId",
limit: 30,
windowMs: MINUTE,
};

View File

@@ -0,0 +1,16 @@
import { SetMetadata } from "@nestjs/common";
export type RateLimitKeyBy = "ip" | "token" | "verificationId";
export interface RateLimitRule {
/** Bucket namespace — must be unique per logical limit, shared across handlers on purpose (e.g. one IP-wide budget for a whole controller). */
name: string;
keyBy: RateLimitKeyBy;
limit: number;
windowMs: number;
}
export const RATE_LIMIT_KEY = "rateLimit";
/** One or more independent limits checked for a route — ALL must pass. */
export const RateLimit = (...rules: RateLimitRule[]) => SetMetadata(RATE_LIMIT_KEY, rules);

View File

@@ -0,0 +1,75 @@
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { Request, Response } from "express";
import { RATE_LIMIT_KEY, type RateLimitRule } from "./rate-limit.decorator.js";
import { RateLimiterService } from "./rate-limiter.service.js";
/**
* Enforces every `@RateLimit(...)` rule declared on a handler (public
* routes only — see auth.controller.ts / public-signature.controller.ts /
* public-verify.controller.ts). Handlers without the decorator are left
* alone (returns true immediately), so this guard is safe to attach at
* controller level without affecting authenticated routes.
*
* All rules on a handler must pass; the first one that doesn't wins the
* `Retry-After` value and gets logged. A hit here is a signal worth
* operator attention (repeated 429s from one source usually means brute
* force or scraping, not an accident), hence `warn`, not `debug`.
*/
@Injectable()
export class RateLimitGuard implements CanActivate {
private readonly logger = new Logger(RateLimitGuard.name);
constructor(
private readonly reflector: Reflector,
private readonly limiter: RateLimiterService,
) {}
canActivate(context: ExecutionContext): boolean {
const rules = this.reflector.get<RateLimitRule[] | undefined>(RATE_LIMIT_KEY, context.getHandler());
if (!rules || rules.length === 0) return true;
const request = context.switchToHttp().getRequest<Request>();
for (const rule of rules) {
const keyValue = this.resolveKeyValue(rule, request);
const bucketKey = `${rule.name}:${keyValue}`;
const verdict = this.limiter.consume(bucketKey, rule.limit, rule.windowMs);
if (!verdict.allowed) {
this.logger.warn(
`rate limit exceeded: rule=${rule.name} keyBy=${rule.keyBy} key=${keyValue} ip=${request.ip ?? "unknown"} route=${request.method} ${request.originalUrl}`,
);
const response = context.switchToHttp().getResponse<Response>();
response.set("Retry-After", String(verdict.retryAfterSeconds));
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: "Muitas requisições. Tente novamente mais tarde.",
retryAfterSeconds: verdict.retryAfterSeconds,
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
return true;
}
private resolveKeyValue(rule: RateLimitRule, request: Request): string {
switch (rule.keyBy) {
case "ip":
return request.ip ?? "unknown";
case "token":
return this.stringParam(request.params.token);
case "verificationId":
return this.stringParam(request.params.verificationId);
}
}
/** @types/express v5's ParamsDictionary types values as `string | string[]` — every route here declares a single `:param`, so this is always a scalar in practice. */
private stringParam(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? "unknown";
return value ?? "unknown";
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { RateLimiterService } from "./rate-limiter.service.js";
import { RateLimitGuard } from "./rate-limit.guard.js";
/**
* Imported by every module that has public (no-session) routes. Nest
* dedupes module imports within one application graph, so AuthModule and
* SignaturesModule importing this both resolve to the SAME
* RateLimiterService instance — one shared in-memory store, bucket names
* are what keep routes from colliding (see rate-limit.decorator.ts).
*/
@Module({
providers: [RateLimiterService, RateLimitGuard],
exports: [RateLimiterService, RateLimitGuard],
})
export class RateLimitModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable } from "@nestjs/common";
export interface RateLimitVerdict {
allowed: boolean;
/** Whole seconds until the current window resets — for the `Retry-After` header. */
retryAfterSeconds: number;
}
interface Bucket {
count: number;
windowStartedAt: number;
}
/**
* In-memory fixed-window counter — no Redis in this environment yet
* (compose.yaml/docs: "BullMQ quando Redis existir"), and this API runs as
* a single instance (ADR-0002), so a process-local Map is both sufficient
* and correct for this stage. Fixed-window (not sliding-window/token-
* bucket) is a deliberate simplicity choice: it lets a caller burst up to
* 2x the nominal limit right at a window boundary, which is an acceptable
* trade-off for closing "no rate limiting at all" — revisit only if real
* abuse patterns exploit that boundary.
*
* One instance of this service is shared (via RateLimitModule, imported by
* both AuthModule and SignaturesModule) across every route this API
* protects — buckets are namespaced by the caller-supplied `key`, which
* always embeds a rule name so different routes never collide.
*/
@Injectable()
export class RateLimiterService {
private readonly buckets = new Map<string, Bucket>();
consume(key: string, limit: number, windowMs: number): RateLimitVerdict {
const now = Date.now();
const existing = this.buckets.get(key);
if (!existing || now - existing.windowStartedAt >= windowMs) {
this.buckets.set(key, { count: 1, windowStartedAt: now });
return { allowed: true, retryAfterSeconds: 0 };
}
if (existing.count >= limit) {
const retryAfterSeconds = Math.ceil((existing.windowStartedAt + windowMs - now) / 1000);
return { allowed: false, retryAfterSeconds: Math.max(retryAfterSeconds, 1) };
}
existing.count += 1;
return { allowed: true, retryAfterSeconds: 0 };
}
/** Test-only escape hatch — never called from production code paths. */
reset(): void {
this.buckets.clear();
}
}

View File

@@ -16,6 +16,7 @@ import {
type SignatureEnvelope,
} from "@eden/database";
import { getObjectStream, getPdfPageCount, sendMail, uploadObject } from "@eden/integrations";
import type { Readable } from "node:stream";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
@@ -231,4 +232,23 @@ export class EnvelopeService {
await this.get(id, user);
return signaturesRepo.listAuditEvents(id);
}
/** Streaming autenticado do PDF congelado de um documento do envelope — mesmo padrão de acesso a S3 do resto do projeto (eden.md linha ~4442: nunca URL assinada, sempre proxy pelo backend com a rota já validando permissão). */
async getDocumentPdf(id: string, documentId: string, user: EdenAuthenticatedUser): Promise<{ documentTitle: string; stream: Readable }> {
const envelope = await this.get(id, user);
const document = envelope.documents.find((d) => d.id === documentId);
if (!document) throw new NotFoundException("Documento não encontrado neste envelope");
const stream = await getObjectStream(document.source_s3_key);
return { documentTitle: document.title, stream };
}
/** Streaming autenticado do PDF final consolidado (documentos + certificado) — só existe a partir de COMPLETED. */
async getFinalPdf(id: string, user: EdenAuthenticatedUser): Promise<{ envelope: EnvelopeWithDetails; stream: Readable }> {
const envelope = await this.get(id, user);
if (!envelope.final_s3_key) {
throw new NotFoundException("Este envelope ainda não tem um PDF final consolidado");
}
const stream = await getObjectStream(envelope.final_s3_key);
return { envelope, stream };
}
}

View File

@@ -1,8 +1,16 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import { Body, Controller, Get, Header, Param, Post, Req, Res, StreamableFile, UseGuards } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import type { Request } from "express";
import type { Request, Response } from "express";
import { PublicSignatureService } from "./public-signature.service.js";
import { VerifyOtpDto } from "./dto/verify-otp.dto.js";
import { RateLimitGuard } from "../common/rate-limit/rate-limit.guard.js";
import { RateLimit } from "../common/rate-limit/rate-limit.decorator.js";
import {
PUBLIC_SIGNATURE_IP_RULE,
PUBLIC_SIGNATURE_OTP_REQUEST_RULE,
PUBLIC_SIGNATURE_OTP_VERIFY_RULE,
PUBLIC_SIGNATURE_TOKEN_ACTION_RULE,
} from "../common/rate-limit/rate-limit-rules.js";
function requestContext(req: Request) {
return {
@@ -14,47 +22,70 @@ function requestContext(req: Request) {
/**
* Fluxo público de assinatura — eden.md §10. Sem autenticação de sessão:
* autorização é inteiramente pelo token do link (`:token`), nunca por
* `envelopeId`/`signerId` de corpo de requisição. Rate limiting (por IP e
* por token, eden.md §10.3/§6.8) é uma lacuna conhecida — ver
* docs/progress.md, mesmo padrão de outros endpoints públicos deste
* projeto (ex. forgot-password) que também não têm rate limit ainda.
* `envelopeId`/`signerId` de corpo de requisição. Rate limiting is by IP
* (PUBLIC_SIGNATURE_IP_RULE, shared across every handler below — catches
* one source hammering many different tokens) AND by token
* (PUBLIC_SIGNATURE_TOKEN_ACTION_RULE and friends — catches many sources
* hammering one leaked link), per eden.md §10.3/§6.8. See
* rate-limit-rules.ts for the concrete numbers and reasoning.
*/
@ApiTags("public-signature")
@Controller("v1/public-signature")
@UseGuards(RateLimitGuard)
export class PublicSignatureController {
constructor(private readonly publicSignature: PublicSignatureService) {}
@Get(":token")
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
getState(@Param("token") token: string) {
return this.publicSignature.getPublicState(token);
}
@Get(":token/documents/:documentId/pdf")
@Header("Content-Type", "application/pdf")
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
async getDocumentPdf(
@Param("token") token: string,
@Param("documentId") documentId: string,
@Res({ passthrough: true }) res: Response,
) {
const { documentTitle, stream } = await this.publicSignature.getDocumentPdf(token, documentId);
res.set({ "Content-Disposition": `inline; filename="${documentTitle}.pdf"` });
return new StreamableFile(stream);
}
@Post(":token/documents/:documentId/confirm-read")
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
confirmDocumentRead(@Param("token") token: string, @Param("documentId") documentId: string, @Req() req: Request) {
return this.publicSignature.confirmDocumentRead(token, documentId, requestContext(req));
}
@Post(":token/confirm-data")
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
confirmData(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.confirmData(token, requestContext(req));
}
@Post(":token/consent")
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
consent(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.acceptConsent(token, requestContext(req));
}
@Post(":token/otp/request")
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_OTP_REQUEST_RULE)
requestOtp(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.requestOtp(token, requestContext(req));
}
@Post(":token/otp/verify")
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_OTP_VERIFY_RULE)
verifyOtp(@Param("token") token: string, @Body() dto: VerifyOtpDto, @Req() req: Request) {
return this.publicSignature.verifyOtp(token, dto.code, requestContext(req));
}
@Post(":token/sign")
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
sign(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.commitSignature(token, requestContext(req));
}

View File

@@ -1,6 +1,7 @@
import { BadRequestException, ConflictException, GoneException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { signaturesRepo, type EnvelopeWithDetails, type SignatureSigner } from "@eden/database";
import { sendMail } from "@eden/integrations";
import { getObjectStream, sendMail } from "@eden/integrations";
import type { Readable } from "node:stream";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import { hashSignerToken } from "./token.js";
@@ -80,6 +81,15 @@ export class PublicSignatureService {
};
}
/** Mesma checagem de posse do documento que `confirmDocumentRead` — o signatário só pode baixar bytes de um documento do próprio envelope, nunca de outro (autorização inteiramente pelo token, eden.md §10.1). Não registra evento por si só: a confirmação de leitura em si é uma ação explícita separada (`confirmDocumentRead`). */
async getDocumentPdf(token: string, documentId: string): Promise<{ documentTitle: string; stream: Readable }> {
const { envelope } = await this.resolveSigner(token);
const document = envelope.documents.find((d) => d.id === documentId);
if (!document) throw new BadRequestException("Documento não pertence a este envelope.");
const stream = await getObjectStream(document.source_s3_key);
return { documentTitle: document.title, stream };
}
async confirmDocumentRead(token: string, documentId: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
if (!envelope.documents.some((d) => d.id === documentId)) {

View File

@@ -1,13 +1,18 @@
import { Controller, Get, Param } from "@nestjs/common";
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { PublicVerifyService } from "./public-verify.service.js";
import { RateLimitGuard } from "../common/rate-limit/rate-limit.guard.js";
import { RateLimit } from "../common/rate-limit/rate-limit.decorator.js";
import { PUBLIC_VERIFY_IP_RULE, PUBLIC_VERIFY_ID_RULE } from "../common/rate-limit/rate-limit-rules.js";
@ApiTags("public-verify")
@Controller("v1/public-verify")
@UseGuards(RateLimitGuard)
export class PublicVerifyController {
constructor(private readonly publicVerify: PublicVerifyService) {}
@Get(":verificationId")
@RateLimit(PUBLIC_VERIFY_IP_RULE, PUBLIC_VERIFY_ID_RULE)
verify(@Param("verificationId") verificationId: string) {
return this.publicVerify.verify(verificationId);
}

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
import { Body, Controller, Delete, Get, Header, Param, Post, Res, StreamableFile, UseGuards } from "@nestjs/common";
import type { Response } from "express";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
@@ -75,6 +76,29 @@ export class SignatureEnvelopesController {
return this.envelopes.listAuditEvents(id, user);
}
@Get(":id/documents/:documentId/pdf")
@RequirePermission("signatures", "view")
@Header("Content-Type", "application/pdf")
async downloadDocumentPdf(
@Param("id") id: string,
@Param("documentId") documentId: string,
@CurrentUser() user: EdenAuthenticatedUser,
@Res({ passthrough: true }) res: Response,
) {
const { documentTitle, stream } = await this.envelopes.getDocumentPdf(id, documentId, user);
res.set({ "Content-Disposition": `inline; filename="${documentTitle}.pdf"` });
return new StreamableFile(stream);
}
@Get(":id/final-pdf")
@RequirePermission("signatures", "view")
@Header("Content-Type", "application/pdf")
async downloadFinalPdf(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser, @Res({ passthrough: true }) res: Response) {
const { envelope, stream } = await this.envelopes.getFinalPdf(id, user);
res.set({ "Content-Disposition": `inline; filename="${envelope.envelope_number}.pdf"` });
return new StreamableFile(stream);
}
/** Passo 1 da finalização — pré-condição status SIGNED (eden.md §8.2). */
@Post(":id/seal-checkpoint")
@RequirePermission("signatures", "edit")

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { RateLimitModule } from "../common/rate-limit/rate-limit.module.js";
import { SignatureEnvelopesController } from "./signatures.controller.js";
import { PublicSignatureController } from "./public-signature.controller.js";
import { PublicVerifyController } from "./public-verify.controller.js";
@@ -9,7 +10,7 @@ import { PublicSignatureService } from "./public-signature.service.js";
import { PublicVerifyService } from "./public-verify.service.js";
@Module({
imports: [AuthModule],
imports: [AuthModule, RateLimitModule],
controllers: [SignatureEnvelopesController, PublicSignatureController, PublicVerifyController],
providers: [EnvelopeService, EnvelopeFinalizeService, PublicSignatureService, PublicVerifyService],
exports: [EnvelopeService, EnvelopeFinalizeService],

View File

@@ -0,0 +1,121 @@
import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { API, agent, closeTestApp, createTestApp, qaEmail } from "./setup.js";
import { RateLimiterService } from "../src/common/rate-limit/rate-limiter.service.js";
/**
* Protects the rate-limiting layer added across auth.controller.ts /
* public-signature.controller.ts / public-verify.controller.ts (see
* apps/api/src/common/rate-limit/) — previously a documented, real gap
* (docs/security/threat-model.md): every public route was unthrottled.
*
* Split in two halves deliberately:
* - RateLimiterService itself, tested directly with no DB/HTTP at all
* (same "pure function" style as signature-hash-chain.spec.ts) — this
* is the only place that can honestly prove per-key isolation (e.g.
* two different source IPs, or two different signed-link tokens, never
* sharing a budget), since supertest's requests all originate from the
* same loopback socket and this API doesn't set Express's `trust
* proxy` (deliberately — trusting an X-Forwarded-For header without a
* known, single reverse-proxy hop in front would let a client spoof
* its own rate-limit key).
* - The real HTTP surface (real app, real Postgres, same pattern as
* every other spec here) proving an actual limit trips end-to-end and
* that hitting one route's bucket dry doesn't affect a different
* route's bucket from the same IP.
*/
describe("RateLimiterService — pure in-memory fixed window, no DB/HTTP", () => {
it("allows exactly `limit` consumes per key, then blocks", () => {
const limiter = new RateLimiterService();
for (let i = 0; i < 5; i++) {
expect(limiter.consume("rule:key", 5, 60_000).allowed).toBe(true);
}
const blocked = limiter.consume("rule:key", 5, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
});
it("different keys never share a bucket (e.g. two source IPs, or two signed-link tokens, under the same rule name)", () => {
const limiter = new RateLimiterService();
for (let i = 0; i < 5; i++) {
expect(limiter.consume("auth-login:203.0.113.1", 5, 60_000).allowed).toBe(true);
}
// Exhausted for this key...
expect(limiter.consume("auth-login:203.0.113.1", 5, 60_000).allowed).toBe(false);
// ...but a different key under the SAME rule starts with its own fresh budget.
expect(limiter.consume("auth-login:198.51.100.7", 5, 60_000).allowed).toBe(true);
// Same reasoning applies to token-scoped rules (pubsig-otp-verify:<token>) —
// the mechanism is identical, only the string plugged into the key differs.
expect(limiter.consume("pubsig-otp-verify:tokenA", 3, 60_000).allowed).toBe(true);
expect(limiter.consume("pubsig-otp-verify:tokenB", 3, 60_000).allowed).toBe(true);
});
it("resets once the window elapses", () => {
vi.useFakeTimers();
try {
const limiter = new RateLimiterService();
expect(limiter.consume("k", 1, 1000).allowed).toBe(true);
expect(limiter.consume("k", 1, 1000).allowed).toBe(false);
vi.advanceTimersByTime(1001);
expect(limiter.consume("k", 1, 1000).allowed).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
/**
* Real app, real Postgres (createTestApp/closeTestApp, same as every other
* spec in this suite) — no fixtures needed at all: wrong-credential login
* attempts against a non-existent account already return a real 401
* (auth.spec.ts's anti-enumeration invariant), which is exactly what a
* credential-stuffing attempt looks like from this endpoint's point of
* view. One app instance per file, isolated from every other spec file
* (each gets a fresh Nest DI container -> a fresh RateLimiterService), and
* vitest.config.ts runs spec files sequentially (fileParallelism: false)
* so this file's own bucket state can't race with another file's.
*/
describe("rate limiting — real HTTP surface", () => {
let app: INestApplication;
beforeAll(async () => {
app = await createTestApp();
});
afterAll(async () => {
await closeTestApp(app);
});
it("POST /v1/auth/login: the request after the configured limit is rejected with 429, not 401, and carries Retry-After", async () => {
// AUTH_LOGIN_RULE = 10/min per IP (rate-limit-rules.ts) — exhaust it first.
for (let i = 0; i < 10; i++) {
await agent(app)
.post(`${API}/v1/auth/login`)
.send({ email: qaEmail("ratelimit-login"), password: "definitely-wrong", applicationKey: "core" })
.expect(401);
}
const blocked = await agent(app)
.post(`${API}/v1/auth/login`)
.send({ email: qaEmail("ratelimit-login"), password: "definitely-wrong", applicationKey: "core" })
.expect(429);
expect(blocked.body.message).toBe("Muitas requisições. Tente novamente mais tarde.");
expect(Number(blocked.headers["retry-after"])).toBeGreaterThan(0);
});
it("the exhausted login bucket does not block a different public route from the same IP (buckets are namespaced per rule name)", async () => {
// Still within the same 60s window as the previous test — login stays blocked...
await agent(app)
.post(`${API}/v1/auth/login`)
.send({ email: qaEmail("ratelimit-login-2"), password: "definitely-wrong", applicationKey: "core" })
.expect(429);
// ...but forgot-password (a completely separate rule: "auth-forgot-password")
// from the exact same IP is untouched.
await agent(app)
.post(`${API}/v1/auth/forgot-password`)
.send({ email: qaEmail("ratelimit-forgot") })
.expect(200);
});
});

View File

@@ -167,6 +167,15 @@ describe("signature envelopes — full lifecycle", () => {
documentId = res.body.documents[0].id;
});
it("downloads the frozen document PDF via the authenticated backoffice route", async () => {
const res = await agent(app)
.get(`/api/v1/signature-envelopes/${envelopeId}/documents/${documentId}/pdf`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(res.headers["content-type"]).toBe("application/pdf");
expect(res.body.length).toBeGreaterThan(0);
});
it("rejects creation for a company with no legal representative", async () => {
const bareCompany = await agent(app)
.post("/api/v1/companies")
@@ -207,6 +216,8 @@ describe("signature envelopes — full lifecycle", () => {
expect(state.body.signer.status).toBe("VIEWED");
expect(state.body.documents).toHaveLength(1);
const pdf = await agent(app).get(`/api/v1/public-signature/${token}/documents/${documentId}/pdf`).expect(200);
expect(pdf.headers["content-type"]).toBe("application/pdf");
await agent(app).post(`/api/v1/public-signature/${token}/documents/${documentId}/confirm-read`).expect(201);
await agent(app).post(`/api/v1/public-signature/${token}/confirm-data`).expect(201);
await agent(app).post(`/api/v1/public-signature/${token}/consent`).expect(201);
@@ -285,6 +296,15 @@ describe("signature envelopes — full lifecycle", () => {
expect(sendMailMock.mock.calls[0]![0].to).toBe(contratanteEmail);
});
it("downloads the final consolidated PDF via the authenticated backoffice route", async () => {
const res = await agent(app)
.get(`/api/v1/signature-envelopes/${envelopeId}/final-pdf`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(res.headers["content-type"]).toBe("application/pdf");
expect(res.body.length).toBeGreaterThan(0);
});
it("public verify endpoint confirms integrity for the completed envelope", async () => {
const res = await agent(app).get(`/api/v1/public-verify/${verificationId}`).expect(200);
expect(res.body.integrityValid).toBe(true);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -569,3 +569,51 @@ Suíte de testes nova e grande: `signature-hash-chain.spec.ts` (15 testes, puros
### Pendências
Rate limiting nas rotas públicas (por IP e por token, eden.md §6.8/§10.3) — lacuna conhecida, mesmo padrão de outros endpoints públicos deste projeto que também não têm ainda (ex. forgot-password). UI de frontend para todo o módulo (criação/envio/acompanhamento de envelope no backoffice, tela pública de assinatura do signatário, página pública de verificação) — não construída nesta leva, só o backend. `DECLINED` (recusa de assinatura) é um estado reservado no schema mas sem rota que o produza — mesma lacuna do legado, nunca foi implementado lá também.
---
## Rate limiting em todas as rotas públicas — lacuna fechada (eden-security)
Data: 2026-09-17. Essa lacuna estava registrada em pelo menos três lugares diferentes (`docs/security/threat-model.md` §2.1/§2.2, e as pendências de MFA/forgot-password/assinatura acima): nenhuma rota sem sessão tinha qualquer limite de requisições. Escopo: `POST /v1/auth/{login,mfa/challenge,forgot-password,reset-password,refresh,logout}` (`logout` não estava na lista original do pedido, mas também não tem `JwtAuthGuard` — incluído por completude), `GET /v1/public-signature/:token`, `GET /v1/public-signature/:token/documents/:documentId/pdf`, `POST /v1/public-signature/:token/{documents/:documentId/confirm-read,confirm-data,consent,otp/request,otp/verify,sign}` e `GET /v1/public-verify/:verificationId`.
### Decisão: guard/decorator próprio, não `@nestjs/throttler`
Sem Redis no ambiente (`compose.yaml`: "eden-redis (quando houver job real)" — ainda não existe) e API roda como instância única (ADR-0002), então em memória é a escolha certa nos dois casos — `@nestjs/throttler` teria servido igual de bem nesse ponto. A razão real de ter escrito um guard próprio (`apps/api/src/common/rate-limit/`) foi precisar de **chaves compostas por rota**: `public-signature/:token/*` precisa de um balde por IP (pega uma fonte varrendo/testando muitos tokens diferentes) *e* um balde por token (pega um link vazado sendo atacado por qualquer número de IPs — é exatamente o caso que `@nestjs/throttler` não cobre bem sem sobrepor múltiplos throttlers nomeados com trackers customizados por controller, o que na prática vira quase tanto código próprio quanto escrever o guard direto). `RateLimiterService` é um Map com janela fixa (não sliding window/token bucket — trade-off deliberado de simplicidade, documentado no próprio arquivo), `RateLimitGuard` lê metadata de `@RateLimit(...rules)` e aplica todas; sem decorator no handler, o guard é um no-op (por isso pôde ser plugado a nível de controller sem afetar rotas autenticadas do mesmo controller, ex. `mfa/enroll`).
### Limites
Todos em `apps/api/src/common/rate-limit/rate-limit-rules.ts`, cada um com a razão numa linha de comentário — resumo: `login`/`mfa/challenge`/`forgot-password`/`reset-password` a 10/min por IP (alvo de adivinhação de credencial — argon2/token de 256 bits já encarece o palpite, isso limita a *taxa*), `refresh`/`logout` a 30/min por IP (token de 256 bits, não é alvo de força bruta — limite mais alto porque sessão ativa com múltiplas abas atualiza sozinha com frequência), `public-signature/*` com um balde de IP compartilhado do controller inteiro (60/min) somado a um balde por token específico da ação (20/5min nas ações comuns, 5/15min em `otp/request` porque dispara e-mail de verdade, 15/5min em `otp/verify` — complementar ao lockout por tentativas erradas que já existe em `public-signature.service.ts::verifyOtp`, persistido no banco, esse sim já sobrevive a troca de IP), `public-verify` a 60/min por IP e 30/min por `verificationId`.
### O que NÃO foi resolvido (residual, registrado em `docs/security/threat-model.md`)
Um atacante trocando de IP lentamente (abaixo do limite por IP, em baixo volume, por muito tempo) não é pego por isto — não existe lockout de conta a nível de aplicação para login hoje, só o rate limit por IP. E `app.set('trust proxy', ...)` nunca foi configurado nesta API; se algum dia um reverse proxy/LB externo ficar na frente do `eden-api` em produção, `req.ip` vai reportar o IP do proxy para todo mundo e essa mitigação (e todo log de IP do projeto) fica cega — não configurei isso adivinhando a topologia futura, porque confiar em `X-Forwarded-For` sem saber exatamente quantos hops confiáveis existem abre uma forma de o próprio cliente spoofar seu IP.
### Verificação
`apps/api/test/rate-limit.spec.ts`: metade pura (sem banco/HTTP, no estilo de `signature-hash-chain.spec.ts`) provando que uma chave estoura exatamente no limite configurado, que chaves diferentes (duas IPs, ou dois tokens de assinatura) nunca competem pelo mesmo balde, e que a janela reseta (`vi.useFakeTimers`); metade HTTP real (app real, Postgres real) provando estouro de verdade em `POST /v1/auth/login` (11ª tentativa em 1 minuto vira 429 com `Retry-After`, não mais 401) e que esgotar o balde do login não afeta o balde de `forgot-password` na mesma requisição/IP. Suíte completa: 77/77 verdes (70→77). `pnpm turbo run typecheck build` 28/28. Testado ao vivo contra o container real `eden-api` (rebuild + restart): 10× 401 seguidos de 429 com corpo `{"statusCode":429,"message":"Muitas requisições. Tente novamente mais tarde.","retryAfterSeconds":60}` e header `Retry-After: 60`, log estruturado `WARN [RateLimitGuard] rate limit exceeded: rule=auth-login ...` no `docker logs eden-api`, e confirmado que `forgot-password` seguiu respondendo 200 no mesmo IP enquanto `login` estava bloqueado.
---
## UI de Assinatura Eletrônica (backoffice + páginas públicas) — segunda pendência do módulo fechada
Data: 2026-09-17. Continuação direta, sem parar para autorização — retomando as duas pendências deixadas pela leva anterior (rate limiting, ver entrada acima, e a UI completa do módulo). Antes de delegar o frontend, um gap real de backend foi encontrado e corrigido diretamente (não delegado, por ser rota nova/sensível): `PublicSignatureService.getPublicState` só devolvia metadados dos documentos (título, versão, contagem de páginas) — não existia NENHUMA forma de o signatário efetivamente ver o conteúdo do PDF antes de "confirmar leitura", nem do backoffice baixar o documento congelado ou o PDF final consolidado. Endpoints novos, mesmo padrão de streaming já usado em `documents.controller.ts` (`StreamableFile`, nunca URL assinada — eden.md linha ~4442):
- `GET /v1/signature-envelopes/:id/documents/:documentId/pdf` e `GET /v1/signature-envelopes/:id/final-pdf` (autenticados, escopo por revenda como o resto do módulo).
- `GET /v1/public-signature/:token/documents/:documentId/pdf` (público, autorização inteiramente pelo token — mesma checagem de posse do documento que `confirmDocumentRead` já usava).
Cobertos em `test/signature-envelope-flow.spec.ts` (72/72 antes da leva de rate limiting subir para 77/77). `pnpm turbo run typecheck build` 28/28.
### Frontend (agente `eden-frontend`, uma leva só — public+autenticado não foram paralelizados desta vez, para evitar a reconciliação manual de `App.tsx` que as levas anteriores precisaram fazer a cada par de agentes)
**Backoffice** (`apps/core-web/src/routes/signatures/`): lista de envelopes (enriquecida por chamada individual `GET /:id`, já que a listagem não traz signatários — mesmo idioma já usado em `TicketsListPage` para o SLA), criação (origem cliente XOR revenda, seleção de `document_generations` já `final`, form-array de signatários contratante — contratada nunca é pedida no formulário), detalhe com documentos/signatários/timeline de auditoria e todas as ações de transição de estado (enviar convite, cancelar, excluir, selar checkpoint, finalizar, baixar PDF final, verificar cadeia), gates de UI espelhando `ENVELOPE_TRANSITIONS` do repositório.
**Público** (`apps/core-web/src/routes/`, fora do `AuthenticatedLayout`, mesmo padrão de `/login`/`/forgot-password`): `PublicSignaturePage.tsx` em `/assinar/:token` (path literal que o backend já usa no e-mail de convite) — wizard guiado inteiramente por `signer.status`, sempre re-buscando o estado a cada ação para permitir retomar de onde parou se o signatário fechar o navegador no meio; `PublicVerifyPage.tsx` em `/verificar/:verificationId` (idem, path literal do e-mail de conclusão).
**Achado de infraestrutura do próprio agente**: nenhuma tela existente no projeto tinha um padrão pronto de "esconder ação por permissão" (grep não encontrou nenhum uso de `permissions` em rotas) — o agente adicionou `permissions` ao `AuthContext` (buscado de `/auth/me`) e um `hasPermission` mínimo, usado só para UX (esconder/desabilitar botão), nunca como controle de acesso — a decisão real continua inteiramente no backend.
**Divergências reais encontradas contra o brief, corrigidas antes de entregar**: `GET /signature-envelopes` não inclui signatários/documentos (resolvido enriquecendo cada linha); `verify-audit` pode retornar `failedAt`/`reason` além de `{valid, eventsChecked}` quando a cadeia está corrompida (UI trata); o contador de tentativas do OTP não é exposto pela API (UI usa mensagem genérica); `sendInvite` não valida o status do envelope no backend (UI esconde o botão em estados terminais por UX, sem depender disso para segurança).
### Verificação
`pnpm turbo run typecheck build` 28/28. Teste ao vivo de ponta a ponta contra `eden-api`/`eden-postgres`/`eden-minio` reais (o agente subiu um mail-catcher SMTP efêmero na rede docker para extrair tokens/OTP reais dos e-mails, revertido ao final — `.env` original confirmado intacto via `docker inspect`): empresa+representante legal → revenda → template→geração final → criar envelope → enviar convite → os dois signatários completando o fluxo público inteiro (ver PDF do documento, confirmar leitura, dados, consentimento, OTP errado então certo, assinar) → `SENT`→`SIGNED` automático no último signatário → seal-checkpoint → finalize (Chromium + merge de PDF reais) → `COMPLETED` → download do PDF final autenticado confirmado como PDF válido → `verify-audit` válido (38 eventos) → `public-verify` sem auth retornando o formato esperado com `integrityValid: true`. Depois containers `eden-api`/`eden-core` reconstruídos com o estado final combinado (endpoints de streaming + rate limiting + UI) e testados ao vivo: `/signatures`, `/signatures/new`, `/assinar/:token`, `/verificar/:verificationId` respondendo 200 via proxy same-origin (porta 3001), e o 11º login errado seguido confirmando 429 (rate limiting da leva anterior intacto após o rebuild combinado).
### Pendências
`DECLINED` (recusa de assinatura) segue sem rota que o produza — reservado no schema, mesma lacuna do legado. `contract_versions`/`contract_amendments` (ADR-0006, adiados desde a leva de trava de edição de contrato) agora estão desbloqueados — o fluxo de assinatura já existe — mas ainda não iniciados. `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela própria, incluindo nada do módulo de assinatura (fora de escopo desta leva).

View File

@@ -21,6 +21,10 @@ Existem por necessidade de negócio (cadastro de cliente/revenda, assinatura ele
- Revalidação total de regra de negócio no backend — o cliente é sempre não confiável.
- 404/409 sem vazar existência de recurso quando aplicável (mensagens genéricas para "não existe" vs. "já usado").
**Implementado (2026-09-17):** todas as rotas públicas hoje existentes têm rate limiting — `apps/api/src/common/rate-limit/` (`RateLimiterService` em memória, janela fixa; `RateLimitGuard` + `@RateLimit(...)` por rota). `POST /v1/auth/{login,mfa/challenge,forgot-password,reset-password,refresh,logout}` por IP; `GET/POST /v1/public-signature/:token/*` por IP (balde compartilhado do controller, pega scraping de vários tokens de uma fonte) **e** por token (pega força bruta de um link vazado vindo de qualquer IP); `GET /v1/public-verify/:verificationId` por IP e por id. Limites concretos e a razão de cada um em `apps/api/src/common/rate-limit/rate-limit-rules.ts`. Sem Redis (ainda não existe, ver compose.yaml) — armazenamento local ao processo, correto para a instância única atual (ADR-0002); não sobrevive a múltiplas réplicas do `eden-api`, revisar quando isso mudar. Testes: `apps/api/test/rate-limit.spec.ts`.
**Risco residual aceito, não coberto por este trabalho:** (1) um atacante que gire IPs lentamente ainda opera abaixo do limite por IP indefinidamente — não há lockout por conta a nível de aplicação além do que já existe no OTP de assinatura; (2) `app.set('trust proxy', ...)` nunca foi configurado — se um reverse proxy/LB externo vier a ficar na frente do `eden-api` em produção, `req.ip` (chave do rate limit e de todo log de IP) passará a ver sempre o IP do proxy, não o do cliente real, cegando esta mitigação — configurar isso corretamente (e só isso, um hop confiável) é pré-requisito de qualquer topologia com proxy na frente da API.
### 2.2 Autenticação e sessão
- Herdar do legado: `token_version`-like mecanismo de invalidação global, MAS evoluir para refresh token rotativo hashado (ADR-0003) — não repetir JWT de 30 dias sem revogação.
- Argon2id (não bcrypt) para novo hash de senha — custo a definir em ADR de segurança/config, revisável sem quebrar hashes existentes (versionamento de parâmetro).