From 9c14671e1981a9c69973047b128e82cbbda7b69c Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Thu, 17 Sep 2026 21:51:18 -0300 Subject: [PATCH] =?UTF-8?q?feat(signatures):=20UI=20completa=20do=20m?= =?UTF-8?q?=C3=B3dulo=20+=20rate=20limiting=20em=20rotas=20p=C3=BAblicas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/auth/auth.controller.ts | 17 + apps/api/src/auth/auth.module.ts | 2 + .../src/common/rate-limit/rate-limit-rules.ts | 120 ++++ .../common/rate-limit/rate-limit.decorator.ts | 16 + .../src/common/rate-limit/rate-limit.guard.ts | 75 +++ .../common/rate-limit/rate-limit.module.ts | 16 + .../common/rate-limit/rate-limiter.service.ts | 55 ++ apps/api/src/signatures/envelope.service.ts | 20 + .../signatures/public-signature.controller.ts | 43 +- .../signatures/public-signature.service.ts | 12 +- .../signatures/public-verify.controller.ts | 7 +- .../src/signatures/signatures.controller.ts | 26 +- apps/api/src/signatures/signatures.module.ts | 3 +- apps/api/test/rate-limit.spec.ts | 121 ++++ apps/api/test/signature-envelope-flow.spec.ts | 20 + apps/core-web/src/App.tsx | 11 + apps/core-web/src/lib/api/public-signature.ts | 76 +++ apps/core-web/src/lib/api/public-verify.ts | 39 ++ apps/core-web/src/lib/api/signatures.ts | 255 ++++++++ apps/core-web/src/lib/auth/AuthContext.tsx | 20 +- apps/core-web/src/lib/auth/permissions.ts | 19 + .../src/routes/AuthenticatedLayout.tsx | 1 + .../src/routes/PublicSignaturePage.tsx | 456 ++++++++++++++ apps/core-web/src/routes/PublicVerifyPage.tsx | 189 ++++++ .../routes/signatures/EnvelopeDetailPage.tsx | 587 ++++++++++++++++++ .../routes/signatures/EnvelopeFormPage.tsx | 398 ++++++++++++ .../routes/signatures/EnvelopesListPage.tsx | 167 +++++ .../src/routes/signatures/signatureStatus.ts | 136 ++++ docs/progress.md | 48 ++ docs/security/threat-model.md | 4 + 30 files changed, 2946 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/common/rate-limit/rate-limit-rules.ts create mode 100644 apps/api/src/common/rate-limit/rate-limit.decorator.ts create mode 100644 apps/api/src/common/rate-limit/rate-limit.guard.ts create mode 100644 apps/api/src/common/rate-limit/rate-limit.module.ts create mode 100644 apps/api/src/common/rate-limit/rate-limiter.service.ts create mode 100644 apps/api/test/rate-limit.spec.ts create mode 100644 apps/core-web/src/lib/api/public-signature.ts create mode 100644 apps/core-web/src/lib/api/public-verify.ts create mode 100644 apps/core-web/src/lib/api/signatures.ts create mode 100644 apps/core-web/src/lib/auth/permissions.ts create mode 100644 apps/core-web/src/routes/PublicSignaturePage.tsx create mode 100644 apps/core-web/src/routes/PublicVerifyPage.tsx create mode 100644 apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx create mode 100644 apps/core-web/src/routes/signatures/EnvelopeFormPage.tsx create mode 100644 apps/core-web/src/routes/signatures/EnvelopesListPage.tsx create mode 100644 apps/core-web/src/routes/signatures/signatureStatus.ts diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index f52c5fd..5fec9eb 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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)); } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index c5b4be9..033bcbf 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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, diff --git a/apps/api/src/common/rate-limit/rate-limit-rules.ts b/apps/api/src/common/rate-limit/rate-limit-rules.ts new file mode 100644 index 0000000..79b7291 --- /dev/null +++ b/apps/api/src/common/rate-limit/rate-limit-rules.ts @@ -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, +}; diff --git a/apps/api/src/common/rate-limit/rate-limit.decorator.ts b/apps/api/src/common/rate-limit/rate-limit.decorator.ts new file mode 100644 index 0000000..dd96817 --- /dev/null +++ b/apps/api/src/common/rate-limit/rate-limit.decorator.ts @@ -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); diff --git a/apps/api/src/common/rate-limit/rate-limit.guard.ts b/apps/api/src/common/rate-limit/rate-limit.guard.ts new file mode 100644 index 0000000..cec7f53 --- /dev/null +++ b/apps/api/src/common/rate-limit/rate-limit.guard.ts @@ -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(RATE_LIMIT_KEY, context.getHandler()); + if (!rules || rules.length === 0) return true; + + const request = context.switchToHttp().getRequest(); + + 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.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"; + } +} diff --git a/apps/api/src/common/rate-limit/rate-limit.module.ts b/apps/api/src/common/rate-limit/rate-limit.module.ts new file mode 100644 index 0000000..0a36480 --- /dev/null +++ b/apps/api/src/common/rate-limit/rate-limit.module.ts @@ -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 {} diff --git a/apps/api/src/common/rate-limit/rate-limiter.service.ts b/apps/api/src/common/rate-limit/rate-limiter.service.ts new file mode 100644 index 0000000..4d4e6bf --- /dev/null +++ b/apps/api/src/common/rate-limit/rate-limiter.service.ts @@ -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(); + + 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(); + } +} diff --git a/apps/api/src/signatures/envelope.service.ts b/apps/api/src/signatures/envelope.service.ts index fae8315..3af7d7f 100644 --- a/apps/api/src/signatures/envelope.service.ts +++ b/apps/api/src/signatures/envelope.service.ts @@ -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 }; + } } diff --git a/apps/api/src/signatures/public-signature.controller.ts b/apps/api/src/signatures/public-signature.controller.ts index b61e96e..c47cab1 100644 --- a/apps/api/src/signatures/public-signature.controller.ts +++ b/apps/api/src/signatures/public-signature.controller.ts @@ -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)); } diff --git a/apps/api/src/signatures/public-signature.service.ts b/apps/api/src/signatures/public-signature.service.ts index 015474c..0d79fbf 100644 --- a/apps/api/src/signatures/public-signature.service.ts +++ b/apps/api/src/signatures/public-signature.service.ts @@ -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 { const { signer, envelope } = await this.resolveSigner(token); if (!envelope.documents.some((d) => d.id === documentId)) { diff --git a/apps/api/src/signatures/public-verify.controller.ts b/apps/api/src/signatures/public-verify.controller.ts index 5793485..2292a0f 100644 --- a/apps/api/src/signatures/public-verify.controller.ts +++ b/apps/api/src/signatures/public-verify.controller.ts @@ -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); } diff --git a/apps/api/src/signatures/signatures.controller.ts b/apps/api/src/signatures/signatures.controller.ts index e142ed6..1fbcf57 100644 --- a/apps/api/src/signatures/signatures.controller.ts +++ b/apps/api/src/signatures/signatures.controller.ts @@ -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") diff --git a/apps/api/src/signatures/signatures.module.ts b/apps/api/src/signatures/signatures.module.ts index ba3dfc9..7e693fc 100644 --- a/apps/api/src/signatures/signatures.module.ts +++ b/apps/api/src/signatures/signatures.module.ts @@ -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], diff --git a/apps/api/test/rate-limit.spec.ts b/apps/api/test/rate-limit.spec.ts new file mode 100644 index 0000000..e6dcb59 --- /dev/null +++ b/apps/api/test/rate-limit.spec.ts @@ -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:) — + // 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); + }); +}); diff --git a/apps/api/test/signature-envelope-flow.spec.ts b/apps/api/test/signature-envelope-flow.spec.ts index 2bf303e..f192e3c 100644 --- a/apps/api/test/signature-envelope-flow.spec.ts +++ b/apps/api/test/signature-envelope-flow.spec.ts @@ -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); diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index a486a08..d6d53e7 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> }> }> } /> @@ -132,6 +139,10 @@ export function App() { } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/core-web/src/lib/api/public-signature.ts b/apps/core-web/src/lib/api/public-signature.ts new file mode 100644 index 0000000..8a39b2e --- /dev/null +++ b/apps/core-web/src/lib/api/public-signature.ts @@ -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 { + return apiRequest(`/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 { + return apiRequest(`/public-signature/${token}/documents/${documentId}/confirm-read`, { method: "POST" }); +} + +export function confirmSignerData(token: string): Promise { + return apiRequest(`/public-signature/${token}/confirm-data`, { method: "POST" }); +} + +export function acceptConsent(token: string): Promise { + return apiRequest(`/public-signature/${token}/consent`, { method: "POST" }); +} + +export function requestOtp(token: string): Promise { + return apiRequest(`/public-signature/${token}/otp/request`, { method: "POST" }); +} + +export function verifyOtp(token: string, code: string): Promise { + return apiRequest(`/public-signature/${token}/otp/verify`, { + method: "POST", + body: JSON.stringify({ code }), + }); +} + +export function commitSignature(token: string): Promise { + return apiRequest(`/public-signature/${token}/sign`, { method: "POST" }); +} diff --git a/apps/core-web/src/lib/api/public-verify.ts b/apps/core-web/src/lib/api/public-verify.ts new file mode 100644 index 0000000..b24118c --- /dev/null +++ b/apps/core-web/src/lib/api/public-verify.ts @@ -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 { + return apiRequest(`/public-verify/${verificationId}`, { method: "GET" }); +} diff --git a/apps/core-web/src/lib/api/signatures.ts b/apps/core-web/src/lib/api/signatures.ts new file mode 100644 index 0000000..a21ab5e --- /dev/null +++ b/apps/core-web/src/lib/api/signatures.ts @@ -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; + 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 { + return apiRequest("/signature-envelopes", { method: "GET" }, { accessToken }); +} + +export function getEnvelope(id: string, accessToken: string): Promise { + return apiRequest(`/signature-envelopes/${id}`, { method: "GET" }, { accessToken }); +} + +export function createEnvelope(input: CreateEnvelopeInput, accessToken: string): Promise { + return apiRequest( + "/signature-envelopes", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function sendInvite( + id: string, + targets: SendInviteTargets, + accessToken: string, +): Promise { + return apiRequest( + `/signature-envelopes/${id}/send`, + { method: "POST", body: JSON.stringify({ targets }) }, + { accessToken }, + ); +} + +export function cancelEnvelope( + id: string, + reason: string | undefined, + accessToken: string, +): Promise { + return apiRequest( + `/signature-envelopes/${id}/cancel`, + { method: "POST", body: JSON.stringify({ reason }) }, + { accessToken }, + ); +} + +export function deleteEnvelope(id: string, accessToken: string): Promise { + return apiRequest(`/signature-envelopes/${id}`, { method: "DELETE" }, { accessToken }); +} + +export function verifyAudit(id: string, accessToken: string): Promise { + return apiRequest(`/signature-envelopes/${id}/verify-audit`, { method: "GET" }, { accessToken }); +} + +export function listAuditEvents(id: string, accessToken: string): Promise { + return apiRequest(`/signature-envelopes/${id}/audit-events`, { method: "GET" }, { accessToken }); +} + +export function sealCheckpoint(id: string, accessToken: string): Promise { + return apiRequest(`/signature-envelopes/${id}/seal-checkpoint`, { method: "POST" }, { accessToken }); +} + +export function finalizeEnvelope(id: string, accessToken: string): Promise { + return apiRequest(`/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 `` can't carry the bearer token). */ +export async function openDocumentPdf(envelopeId: string, documentId: string, accessToken: string): Promise { + 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 { + 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); +} diff --git a/apps/core-web/src/lib/auth/AuthContext.tsx b/apps/core-web/src/lib/auth/AuthContext.tsx index fc41fbe..e5b5e9b 100644 --- a/apps/core-web/src/lib/auth/AuthContext.tsx +++ b/apps/core-web/src/lib/auth/AuthContext.tsx @@ -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; submitMfa: (mfaChallenge: string, code: string) => Promise; @@ -35,6 +37,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [accessToken, setAccessToken] = useState(null); const [refreshToken, setRefreshToken] = useState(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( - () => ({ 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 {children}; diff --git a/apps/core-web/src/lib/auth/permissions.ts b/apps/core-web/src/lib/auth/permissions.ts new file mode 100644 index 0000000..a3963f2 --- /dev/null +++ b/apps/core-web/src/lib/auth/permissions.ts @@ -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); +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index 08ad72f..999df57 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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" }, diff --git a/apps/core-web/src/routes/PublicSignaturePage.tsx b/apps/core-web/src/routes/PublicSignaturePage.tsx new file mode 100644 index 0000000..41f01ca --- /dev/null +++ b/apps/core-web/src/routes/PublicSignaturePage.tsx @@ -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 = { + 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(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 ( +
+
+ EDEN +
+
+ {loading ? ( +

Carregando…

+ ) : loadError === "not_found" ? ( + + + Este link de assinatura não existe ou está incorreto. Confira o e-mail de convite ou solicite um novo + link ao remetente. + + + ) : loadError === "gone" ? ( + + + Este link foi revogado, expirou, ou o envelope não está mais disponível para assinatura. Solicite um + novo convite. + + + ) : loadError === "generic" ? ( + + Não foi possível carregar os dados desta assinatura. Tente novamente. + + ) : state && token ? ( + + ) : null} +
+
+ ); +} + +function SignatureWizard({ + state, + token, + onChanged, +}: { + state: PublicSignatureState; + token: string; + onChanged: () => void; +}) { + const step = currentStep(state); + + return ( +
+
+

Envelope {state.envelope.envelopeNumber}

+

Assinatura eletrônica

+
+ + + + {step === "documents" ? : null} + {step === "data" ? : null} + {step === "consent" ? : null} + {step === "otp" ? : null} + {step === "sign" ? : null} + {step === "done" ? : null} +
+ ); +} + +function StepProgress({ current }: { current: WizardStep }) { + const currentIndex = STEP_ORDER.indexOf(current); + return ( +
+ {STEP_ORDER.map((step, index) => ( + + {STEP_LABEL[step]} + + ))} +
+ ); +} + +function DocumentsStep({ + state, + token, + onChanged, +}: { + state: PublicSignatureState; + token: string; + onChanged: () => void; +}) { + const [confirmingId, setConfirmingId] = useState(null); + const [error, setError] = useState(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 ( + +

+ Abra e leia cada documento antes de confirmar. Você só pode prosseguir depois de confirmar a leitura de + todos. +

+ {error ? ( + + {error} + + ) : null} +
+ {state.documents.map((doc) => ( +
+
+

{doc.title}

+

+ Versão {doc.documentVersion} + {doc.pageCount ? ` · ${doc.pageCount} página(s)` : ""} +

+
+
+ + Abrir PDF + + {doc.confirmedRead ? ( + Leitura confirmada + ) : ( + + )} +
+
+ ))} +
+ {!allConfirmed ? ( +

Confirme a leitura de todos os documentos para continuar.

+ ) : null} + + ); +} + +function ConfirmDataStep({ + state, + token, + onChanged, +}: { + state: PublicSignatureState; + token: string; + onChanged: () => void; +}) { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + {error ? ( + + {error} + + ) : null} +

Confira se os dados abaixo estão corretos antes de continuar.

+
+
+
Nome
+
{state.signer.full_name}
+
+ {state.signer.cpf ? ( +
+
CPF
+
{formatDocument(state.signer.cpf)}
+
+ ) : null} +
+
E-mail
+
{state.signer.email}
+
+
+ +
+ ); +} + +function ConsentStep({ token, onChanged }: { token: string; onChanged: () => void }) { + const [accepted, setAccepted] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + {error ? ( + + {error} + + ) : null} +

+ 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. +

+ + +
+ ); +} + +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(null); + const [info, setInfo] = useState(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 ( + + {error ? ( + + {error} + + ) : null} + {info ? ( + + {info} + + ) : null} + {!requested ? ( + <> +

+ Vamos enviar um código de verificação de 6 dígitos para o seu e-mail cadastrado. +

+ + + ) : ( + <> +

Digite o código de 6 dígitos que enviamos para o seu e-mail.

+ 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" + /> + + + + )} +
+ ); +} + +function SignStep({ token, onChanged }: { token: string; onChanged: () => void }) { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + {error ? ( + + {error} + + ) : null} +

+ Ao clicar em assinar, sua manifestação de vontade será registrada de forma definitiva para este documento. +

+ +
+ ); +} + +function DoneStep() { + return ( + + + Documento assinado! Você receberá o PDF final por e-mail assim que todas as partes concluírem. + + + ); +} diff --git a/apps/core-web/src/routes/PublicVerifyPage.tsx b/apps/core-web/src/routes/PublicVerifyPage.tsx new file mode 100644 index 0000000..2bb1a6d --- /dev/null +++ b/apps/core-web/src/routes/PublicVerifyPage.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(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 ( +
+
+ EDEN +
+
+ {loading ? ( +

Verificando…

+ ) : notFound ? ( + + + Este código de verificação não corresponde a nenhum documento concluído. Confira o link ou o código + informado. + + + ) : error ? ( + + {error} + + ) : result ? ( + + ) : null} +
+
+ ); +} + +function VerificationResultView({ result }: { result: PublicVerificationResult }) { + return ( +
+ +
+ {result.integrityValid ? ( + <> +

✓

+

Documento verificado

+

A integridade deste documento foi confirmada.

+ + ) : ( + <> +

⚠

+

Integridade comprometida

+

+ Não foi possível confirmar a integridade deste documento — os dados podem ter sido adulterados. +

+ + )} +
+
+ + +
+ + + + +
+
+ + + {result.documents.length === 0 ? ( +

Nenhum documento.

+ ) : ( +
    + {result.documents.map((doc, index) => ( +
  • + {doc.title} + + v{doc.version} + {doc.pageCount ? ` · ${doc.pageCount} página(s)` : ""} + +
  • + ))} +
+ )} +
+ + + {result.signers.length === 0 ? ( +

Nenhum signatário.

+ ) : ( +
    + {result.signers.map((signer, index) => ( +
  • + {signer.name} + + {signer.party === "contratada" ? "Contratada" : "Contratante"} + +
  • + ))} +
+ )} +
+ + +
+ + + + +
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function HashField({ label, value }: { label: string; value: string | null }) { + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + if (!value) { + return ( +
+

{label}

+

—

+
+ ); + } + + 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 ( +
+

{label}

+
+

{displayValue}

+ + +
+
+ ); +} diff --git a/apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx b/apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx new file mode 100644 index 0000000..f79388b --- /dev/null +++ b/apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx @@ -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(null); + const [company, setCompany] = useState(null); + const [auditEvents, setAuditEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +

Carregando envelope…

+
+ ); + } + + if (error || !envelope) { + return ( +
+ + {error ?? "Envelope não encontrado."} +
+ ); + } + + const verificationUrl = envelope.verification_id ? `${window.location.origin}/verificar/${envelope.verification_id}` : null; + + return ( +
+ + {ENVELOPE_STATUS_LABEL[envelope.status]}} + /> + +
+
+ +
+ + + + + + + + +
+
+ + {envelope.status === "COMPLETED" ? ( + +

+ Qualquer pessoa com este link pode conferir a integridade do documento (sem autenticação): +

+ {verificationUrl ? ( + + {verificationUrl} + + ) : null} +
+ ) : null} + + +
+ +
+ + {envelope.documents.length === 0 ? ( +

Nenhum documento neste envelope.

+ ) : ( +
+ + + + + + + + + + {envelope.documents + .slice() + .sort((a, b) => a.sort_order - b.sort_order) + .map((doc) => ( + + ))} + +
DocumentoVersãoPáginas +
+
+ )} +
+ + + {envelope.signers.length === 0 ? ( +

Nenhum signatário cadastrado.

+ ) : ( +
+ + + + + + + + + + + + {envelope.signers + .slice() + .sort((a, b) => a.sort_order - b.sort_order) + .map((signer) => ( + + + + + + + + ))} + +
PapelNomeContatoCargoStatus
+ + {partyRoleLabel(signer.party)} + + + {signer.full_name} + {signer.cpf ? {signer.cpf} : null} + + {signer.email} + {signer.phone ? {signer.phone} : null} + {signer.role_title ?? "—"} + {SIGNER_STATUS_LABEL[signer.status]} +
+
+ )} +
+ + +
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function DocumentRow({ + envelopeId, + document, + accessToken, +}: { + envelopeId: string; + document: EnvelopeWithDetails["documents"][number]; + accessToken: string | null; +}) { + const [opening, setOpening] = useState(false); + const [error, setError] = useState(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 ( + + {document.title} + v{document.document_version} + {document.page_count ?? "—"} + + + {error ?

{error}

: null} + + + ); +} + +function EnvelopeActionsCard({ + envelope, + accessToken, + canEdit, + canDelete, + onChanged, +}: { + envelope: EnvelopeWithDetails; + accessToken: string | null; + canEdit: boolean; + canDelete: boolean; + onChanged: () => void; +}) { + const [targets, setTargets] = useState("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(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(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 ( + + {actionError ? ( + + {actionError} + + ) : null} + + {!hasAnyAction ?

Nenhuma ação disponível para este status.

: null} + +
+ {canSendInvite ? ( +
+

Enviar convite

+
+ setCancelReason(e.target.value)} + className="w-full rounded-md border border-border-color px-3 py-2 text-sm" + /> +
+ + +
+
+ )} +
+ ) : null} + + {canDeleteEnvelope ? ( +
+ {!confirmDelete ? ( + + ) : ( +
+

+ Excluir permanentemente este envelope? Esta ação não pode ser desfeita. +

+
+ + +
+
+ )} +
+ ) : null} +
+
+ ); +} + +function AuditTrailCard({ events }: { events: SignatureAuditEvent[] }) { + const sorted = useMemo(() => events.slice().sort((a, b) => Number(a.seq) - Number(b.seq)), [events]); + + return ( + + {sorted.length === 0 ? ( +

Nenhum evento registrado ainda.

+ ) : ( +
    + {sorted.map((event) => ( +
  1. +
  2. + ))} +
+ )} +
+ ); +} diff --git a/apps/core-web/src/routes/signatures/EnvelopeFormPage.tsx b/apps/core-web/src/routes/signatures/EnvelopeFormPage.tsx new file mode 100644 index 0000000..ab39f1e --- /dev/null +++ b/apps/core-web/src/routes/signatures/EnvelopeFormPage.tsx @@ -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(null); + const [clientRegistrations, setClientRegistrations] = useState([]); + const [resellers, setResellers] = useState([]); + const [generations, setGenerations] = useState([]); + const [templates, setTemplates] = useState([]); + const [loadError, setLoadError] = useState(null); + + const [companyId, setCompanyId] = useState(""); + const [originType, setOriginType] = useState("cliente"); + const [clientRegistrationId, setClientRegistrationId] = useState(""); + const [resellerId, setResellerId] = useState(""); + const [selectedGenerationIds, setSelectedGenerationIds] = useState([]); + const [signers, setSigners] = useState([emptySignerRow()]); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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) { + 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 ( +
+ + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + {showLegalRepHint && companyId ? ( +

+ + Cadastrar representante legal desta empresa + +

+ ) : null} +
+ ) : null} + + + {!companies ? ( +

Carregando empresas, clientes, revendas e documentos…

+ ) : ( +
+
+

Empresa emissora (contratada)

+ setOriginType("cliente")} + /> + Cadastro de cliente + + +
+ {originType === "cliente" ? ( + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + )} +
+ +
+

Documentos

+ {finalGenerations.length === 0 ? ( +

+ Nenhum documento gerado com PDF pronto ainda.{" "} + + Gerar um documento + {" "} + antes de criar o envelope. +

+ ) : ( +
+ {finalGenerations.map((generation) => { + const template = templateById.get(generation.template_id); + return ( + + ); + })} +
+ )} +
+ +
+
+

Signatários (contratante)

+ +
+

+ O signatário contratada não é informado aqui — ele é preenchido automaticamente pelo servidor. +

+
+ {signers.map((row, index) => ( +
+
+ Contratante {index + 1} + {signers.length > 1 ? ( + + ) : null} +
+
+ updateSigner(index, { fullName: e.target.value })} + required + /> + updateSigner(index, { cpf: e.target.value })} + /> + updateSigner(index, { email: e.target.value })} + required + /> + updateSigner(index, { phone: e.target.value })} + /> + updateSigner(index, { roleTitle: e.target.value })} + /> + updateSigner(index, { companyName: e.target.value })} + /> + updateSigner(index, { cnpj: e.target.value })} + /> +
+
+ ))} +
+
+ +
+ + +
+ + )} + + + ); +} diff --git a/apps/core-web/src/routes/signatures/EnvelopesListPage.tsx b/apps/core-web/src/routes/signatures/EnvelopesListPage.tsx new file mode 100644 index 0000000..49c89ca --- /dev/null +++ b/apps/core-web/src/routes/signatures/EnvelopesListPage.tsx @@ -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(null); + const [companies, setCompanies] = useState([]); + const [error, setError] = useState(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 ( +
+ + Novo envelope + + ) : undefined + } + /> + + {error ? ( + + {error} + + ) : null} + +
+