feat(signatures): UI completa do módulo + rate limiting em rotas públicas
Fecha as duas pendências da leva anterior (módulo de Assinatura Eletrônica): tela de backoffice para gerenciar envelopes (criação, envio, cancelamento, checkpoint/finalização, auditoria) e as duas páginas públicas (/assinar/:token, /verificar/:verificationId). Backend: novos endpoints de streaming de PDF (documento congelado e PDF final consolidado, autenticados e via token público) que faltavam para a UI funcionar — o signatário não tinha como ver o próprio documento antes de confirmar leitura. Rate limiting (guard próprio, em memória, chaves por IP e por token) em todas as rotas sem sessão: auth (login/mfa/forgot-password/reset-password/refresh/logout) e todo o fluxo público de assinatura/verificação. Suíte: 77/77 verdes. pnpm turbo run typecheck build: 28/28. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
120
apps/api/src/common/rate-limit/rate-limit-rules.ts
Normal file
120
apps/api/src/common/rate-limit/rate-limit-rules.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { RateLimitRule } from "./rate-limit.decorator.js";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
/**
|
||||
* Concrete limits for every public (no-session) route in this API.
|
||||
* Numbers are deliberately round, chosen for two constraints at once:
|
||||
* - generous enough that this project's own real-app integration test
|
||||
* suite (apps/api/test/*.spec.ts — real login/refresh/signature flows,
|
||||
* all from the same loopback IP) never trips them by accident;
|
||||
* - tight enough to turn an unthrottled endpoint (previously: unlimited
|
||||
* requests/second) into a hard, auditable ceiling per IP or per link.
|
||||
* None of these claim to fully stop a determined, IP-rotating attacker —
|
||||
* that's a documented residual risk in docs/security/threat-model.md,
|
||||
* not a gap this change pretends to close.
|
||||
*/
|
||||
|
||||
// --- apps/api/src/auth/auth.controller.ts -----------------------------
|
||||
|
||||
/** Credential guessing target — argon2 already raises the per-guess cost; this caps guess *rate*. */
|
||||
export const AUTH_LOGIN_RULE: RateLimitRule = { name: "auth-login", keyBy: "ip", limit: 10, windowMs: MINUTE };
|
||||
|
||||
/** Same order of magnitude as login: this is the second half of an MFA login, equally sensitive. */
|
||||
export const AUTH_MFA_CHALLENGE_RULE: RateLimitRule = {
|
||||
name: "auth-mfa-challenge",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Doesn't leak account existence (anti-enumeration response either way), but still caps inbox-spam/abuse potential. */
|
||||
export const AUTH_FORGOT_PASSWORD_RULE: RateLimitRule = {
|
||||
name: "auth-forgot-password",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Token is 256-bit random (auth.service.ts) — not brute-forceable; this is generic abuse/DoS hygiene. */
|
||||
export const AUTH_RESET_PASSWORD_RULE: RateLimitRule = {
|
||||
name: "auth-reset-password",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Refresh tokens are 256-bit random too; higher ceiling because legitimate multi-tab/background refresh is frequent by design. */
|
||||
export const AUTH_REFRESH_RULE: RateLimitRule = { name: "auth-refresh", keyBy: "ip", limit: 30, windowMs: MINUTE };
|
||||
|
||||
/**
|
||||
* Not in the task's original enumerated list, but POST /v1/auth/logout has
|
||||
* no JwtAuthGuard either (auth.controller.ts) — genuinely public. No
|
||||
* guessing target (needs an already-known refresh token), so this is
|
||||
* generic abuse/DoS hygiene only, same ceiling as refresh.
|
||||
*/
|
||||
export const AUTH_LOGOUT_RULE: RateLimitRule = { name: "auth-logout", keyBy: "ip", limit: 30, windowMs: MINUTE };
|
||||
|
||||
// --- apps/api/src/signatures/public-signature.controller.ts -----------
|
||||
|
||||
/**
|
||||
* Shared across every /v1/public-signature/:token/* handler, keyed by IP —
|
||||
* catches one source scanning/hammering many DIFFERENT tokens (link
|
||||
* enumeration or scraping), which a per-token limit alone can't see.
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_IP_RULE: RateLimitRule = {
|
||||
name: "pubsig-ip",
|
||||
keyBy: "ip",
|
||||
limit: 60,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-token ceiling for the ordinary read/ack steps of the flow — a real
|
||||
* signer hits each of these once or twice (reload, double-click); this
|
||||
* only bites automated hammering of one leaked link, from any number of
|
||||
* source IPs (that's the point of keying by token, not IP, here).
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_TOKEN_ACTION_RULE: RateLimitRule = {
|
||||
name: "pubsig-token-action",
|
||||
keyBy: "token",
|
||||
limit: 20,
|
||||
windowMs: 5 * MINUTE,
|
||||
};
|
||||
|
||||
/** Sends an email — tighter, since abuse here has a real cost (spamming the signer's inbox), not just a wasted read. */
|
||||
export const PUBLIC_SIGNATURE_OTP_REQUEST_RULE: RateLimitRule = {
|
||||
name: "pubsig-otp-request",
|
||||
keyBy: "token",
|
||||
limit: 5,
|
||||
windowMs: 15 * MINUTE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Complementary to the DB-persisted per-challenge lockout already in
|
||||
* public-signature.service.ts::verifyOtp (blocks after N wrong codes,
|
||||
* survives across source IPs) — this caps raw request *rate* against the
|
||||
* endpoint itself, independent of whether attempts are right or wrong.
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_OTP_VERIFY_RULE: RateLimitRule = {
|
||||
name: "pubsig-otp-verify",
|
||||
keyBy: "token",
|
||||
limit: 15,
|
||||
windowMs: 5 * MINUTE,
|
||||
};
|
||||
|
||||
// --- apps/api/src/signatures/public-verify.controller.ts --------------
|
||||
|
||||
export const PUBLIC_VERIFY_IP_RULE: RateLimitRule = {
|
||||
name: "pubverify-ip",
|
||||
keyBy: "ip",
|
||||
limit: 60,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
export const PUBLIC_VERIFY_ID_RULE: RateLimitRule = {
|
||||
name: "pubverify-id",
|
||||
keyBy: "verificationId",
|
||||
limit: 30,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
16
apps/api/src/common/rate-limit/rate-limit.decorator.ts
Normal file
16
apps/api/src/common/rate-limit/rate-limit.decorator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export type RateLimitKeyBy = "ip" | "token" | "verificationId";
|
||||
|
||||
export interface RateLimitRule {
|
||||
/** Bucket namespace — must be unique per logical limit, shared across handlers on purpose (e.g. one IP-wide budget for a whole controller). */
|
||||
name: string;
|
||||
keyBy: RateLimitKeyBy;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export const RATE_LIMIT_KEY = "rateLimit";
|
||||
|
||||
/** One or more independent limits checked for a route — ALL must pass. */
|
||||
export const RateLimit = (...rules: RateLimitRule[]) => SetMetadata(RATE_LIMIT_KEY, rules);
|
||||
75
apps/api/src/common/rate-limit/rate-limit.guard.ts
Normal file
75
apps/api/src/common/rate-limit/rate-limit.guard.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { Request, Response } from "express";
|
||||
import { RATE_LIMIT_KEY, type RateLimitRule } from "./rate-limit.decorator.js";
|
||||
import { RateLimiterService } from "./rate-limiter.service.js";
|
||||
|
||||
/**
|
||||
* Enforces every `@RateLimit(...)` rule declared on a handler (public
|
||||
* routes only — see auth.controller.ts / public-signature.controller.ts /
|
||||
* public-verify.controller.ts). Handlers without the decorator are left
|
||||
* alone (returns true immediately), so this guard is safe to attach at
|
||||
* controller level without affecting authenticated routes.
|
||||
*
|
||||
* All rules on a handler must pass; the first one that doesn't wins the
|
||||
* `Retry-After` value and gets logged. A hit here is a signal worth
|
||||
* operator attention (repeated 429s from one source usually means brute
|
||||
* force or scraping, not an accident), hence `warn`, not `debug`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimitGuard implements CanActivate {
|
||||
private readonly logger = new Logger(RateLimitGuard.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly limiter: RateLimiterService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const rules = this.reflector.get<RateLimitRule[] | undefined>(RATE_LIMIT_KEY, context.getHandler());
|
||||
if (!rules || rules.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const keyValue = this.resolveKeyValue(rule, request);
|
||||
const bucketKey = `${rule.name}:${keyValue}`;
|
||||
const verdict = this.limiter.consume(bucketKey, rule.limit, rule.windowMs);
|
||||
|
||||
if (!verdict.allowed) {
|
||||
this.logger.warn(
|
||||
`rate limit exceeded: rule=${rule.name} keyBy=${rule.keyBy} key=${keyValue} ip=${request.ip ?? "unknown"} route=${request.method} ${request.originalUrl}`,
|
||||
);
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
response.set("Retry-After", String(verdict.retryAfterSeconds));
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: "Muitas requisições. Tente novamente mais tarde.",
|
||||
retryAfterSeconds: verdict.retryAfterSeconds,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveKeyValue(rule: RateLimitRule, request: Request): string {
|
||||
switch (rule.keyBy) {
|
||||
case "ip":
|
||||
return request.ip ?? "unknown";
|
||||
case "token":
|
||||
return this.stringParam(request.params.token);
|
||||
case "verificationId":
|
||||
return this.stringParam(request.params.verificationId);
|
||||
}
|
||||
}
|
||||
|
||||
/** @types/express v5's ParamsDictionary types values as `string | string[]` — every route here declares a single `:param`, so this is always a scalar in practice. */
|
||||
private stringParam(value: string | string[] | undefined): string {
|
||||
if (Array.isArray(value)) return value[0] ?? "unknown";
|
||||
return value ?? "unknown";
|
||||
}
|
||||
}
|
||||
16
apps/api/src/common/rate-limit/rate-limit.module.ts
Normal file
16
apps/api/src/common/rate-limit/rate-limit.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RateLimiterService } from "./rate-limiter.service.js";
|
||||
import { RateLimitGuard } from "./rate-limit.guard.js";
|
||||
|
||||
/**
|
||||
* Imported by every module that has public (no-session) routes. Nest
|
||||
* dedupes module imports within one application graph, so AuthModule and
|
||||
* SignaturesModule importing this both resolve to the SAME
|
||||
* RateLimiterService instance — one shared in-memory store, bucket names
|
||||
* are what keep routes from colliding (see rate-limit.decorator.ts).
|
||||
*/
|
||||
@Module({
|
||||
providers: [RateLimiterService, RateLimitGuard],
|
||||
exports: [RateLimiterService, RateLimitGuard],
|
||||
})
|
||||
export class RateLimitModule {}
|
||||
55
apps/api/src/common/rate-limit/rate-limiter.service.ts
Normal file
55
apps/api/src/common/rate-limit/rate-limiter.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
export interface RateLimitVerdict {
|
||||
allowed: boolean;
|
||||
/** Whole seconds until the current window resets — for the `Retry-After` header. */
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
windowStartedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fixed-window counter — no Redis in this environment yet
|
||||
* (compose.yaml/docs: "BullMQ quando Redis existir"), and this API runs as
|
||||
* a single instance (ADR-0002), so a process-local Map is both sufficient
|
||||
* and correct for this stage. Fixed-window (not sliding-window/token-
|
||||
* bucket) is a deliberate simplicity choice: it lets a caller burst up to
|
||||
* 2x the nominal limit right at a window boundary, which is an acceptable
|
||||
* trade-off for closing "no rate limiting at all" — revisit only if real
|
||||
* abuse patterns exploit that boundary.
|
||||
*
|
||||
* One instance of this service is shared (via RateLimitModule, imported by
|
||||
* both AuthModule and SignaturesModule) across every route this API
|
||||
* protects — buckets are namespaced by the caller-supplied `key`, which
|
||||
* always embeds a rule name so different routes never collide.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimiterService {
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
consume(key: string, limit: number, windowMs: number): RateLimitVerdict {
|
||||
const now = Date.now();
|
||||
const existing = this.buckets.get(key);
|
||||
|
||||
if (!existing || now - existing.windowStartedAt >= windowMs) {
|
||||
this.buckets.set(key, { count: 1, windowStartedAt: now });
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
if (existing.count >= limit) {
|
||||
const retryAfterSeconds = Math.ceil((existing.windowStartedAt + windowMs - now) / 1000);
|
||||
return { allowed: false, retryAfterSeconds: Math.max(retryAfterSeconds, 1) };
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
/** Test-only escape hatch — never called from production code paths. */
|
||||
reset(): void {
|
||||
this.buckets.clear();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, ConflictException, GoneException, Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { signaturesRepo, type EnvelopeWithDetails, type SignatureSigner } from "@eden/database";
|
||||
import { sendMail } from "@eden/integrations";
|
||||
import { getObjectStream, sendMail } from "@eden/integrations";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
import { hashSignerToken } from "./token.js";
|
||||
@@ -80,6 +81,15 @@ export class PublicSignatureService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Mesma checagem de posse do documento que `confirmDocumentRead` — o signatário só pode baixar bytes de um documento do próprio envelope, nunca de outro (autorização inteiramente pelo token, eden.md §10.1). Não registra evento por si só: a confirmação de leitura em si é uma ação explícita separada (`confirmDocumentRead`). */
|
||||
async getDocumentPdf(token: string, documentId: string): Promise<{ documentTitle: string; stream: Readable }> {
|
||||
const { envelope } = await this.resolveSigner(token);
|
||||
const document = envelope.documents.find((d) => d.id === documentId);
|
||||
if (!document) throw new BadRequestException("Documento não pertence a este envelope.");
|
||||
const stream = await getObjectStream(document.source_s3_key);
|
||||
return { documentTitle: document.title, stream };
|
||||
}
|
||||
|
||||
async confirmDocumentRead(token: string, documentId: string, ctx: PublicRequestContext): Promise<void> {
|
||||
const { signer, envelope } = await this.resolveSigner(token);
|
||||
if (!envelope.documents.some((d) => d.id === documentId)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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],
|
||||
|
||||
121
apps/api/test/rate-limit.spec.ts
Normal file
121
apps/api/test/rate-limit.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { API, agent, closeTestApp, createTestApp, qaEmail } from "./setup.js";
|
||||
import { RateLimiterService } from "../src/common/rate-limit/rate-limiter.service.js";
|
||||
|
||||
/**
|
||||
* Protects the rate-limiting layer added across auth.controller.ts /
|
||||
* public-signature.controller.ts / public-verify.controller.ts (see
|
||||
* apps/api/src/common/rate-limit/) — previously a documented, real gap
|
||||
* (docs/security/threat-model.md): every public route was unthrottled.
|
||||
*
|
||||
* Split in two halves deliberately:
|
||||
* - RateLimiterService itself, tested directly with no DB/HTTP at all
|
||||
* (same "pure function" style as signature-hash-chain.spec.ts) — this
|
||||
* is the only place that can honestly prove per-key isolation (e.g.
|
||||
* two different source IPs, or two different signed-link tokens, never
|
||||
* sharing a budget), since supertest's requests all originate from the
|
||||
* same loopback socket and this API doesn't set Express's `trust
|
||||
* proxy` (deliberately — trusting an X-Forwarded-For header without a
|
||||
* known, single reverse-proxy hop in front would let a client spoof
|
||||
* its own rate-limit key).
|
||||
* - The real HTTP surface (real app, real Postgres, same pattern as
|
||||
* every other spec here) proving an actual limit trips end-to-end and
|
||||
* that hitting one route's bucket dry doesn't affect a different
|
||||
* route's bucket from the same IP.
|
||||
*/
|
||||
describe("RateLimiterService — pure in-memory fixed window, no DB/HTTP", () => {
|
||||
it("allows exactly `limit` consumes per key, then blocks", () => {
|
||||
const limiter = new RateLimiterService();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(limiter.consume("rule:key", 5, 60_000).allowed).toBe(true);
|
||||
}
|
||||
const blocked = limiter.consume("rule:key", 5, 60_000);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("different keys never share a bucket (e.g. two source IPs, or two signed-link tokens, under the same rule name)", () => {
|
||||
const limiter = new RateLimiterService();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(limiter.consume("auth-login:203.0.113.1", 5, 60_000).allowed).toBe(true);
|
||||
}
|
||||
// Exhausted for this key...
|
||||
expect(limiter.consume("auth-login:203.0.113.1", 5, 60_000).allowed).toBe(false);
|
||||
// ...but a different key under the SAME rule starts with its own fresh budget.
|
||||
expect(limiter.consume("auth-login:198.51.100.7", 5, 60_000).allowed).toBe(true);
|
||||
// Same reasoning applies to token-scoped rules (pubsig-otp-verify:<token>) —
|
||||
// the mechanism is identical, only the string plugged into the key differs.
|
||||
expect(limiter.consume("pubsig-otp-verify:tokenA", 3, 60_000).allowed).toBe(true);
|
||||
expect(limiter.consume("pubsig-otp-verify:tokenB", 3, 60_000).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("resets once the window elapses", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const limiter = new RateLimiterService();
|
||||
expect(limiter.consume("k", 1, 1000).allowed).toBe(true);
|
||||
expect(limiter.consume("k", 1, 1000).allowed).toBe(false);
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(limiter.consume("k", 1, 1000).allowed).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Real app, real Postgres (createTestApp/closeTestApp, same as every other
|
||||
* spec in this suite) — no fixtures needed at all: wrong-credential login
|
||||
* attempts against a non-existent account already return a real 401
|
||||
* (auth.spec.ts's anti-enumeration invariant), which is exactly what a
|
||||
* credential-stuffing attempt looks like from this endpoint's point of
|
||||
* view. One app instance per file, isolated from every other spec file
|
||||
* (each gets a fresh Nest DI container -> a fresh RateLimiterService), and
|
||||
* vitest.config.ts runs spec files sequentially (fileParallelism: false)
|
||||
* so this file's own bucket state can't race with another file's.
|
||||
*/
|
||||
describe("rate limiting — real HTTP surface", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
it("POST /v1/auth/login: the request after the configured limit is rejected with 429, not 401, and carries Retry-After", async () => {
|
||||
// AUTH_LOGIN_RULE = 10/min per IP (rate-limit-rules.ts) — exhaust it first.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await agent(app)
|
||||
.post(`${API}/v1/auth/login`)
|
||||
.send({ email: qaEmail("ratelimit-login"), password: "definitely-wrong", applicationKey: "core" })
|
||||
.expect(401);
|
||||
}
|
||||
|
||||
const blocked = await agent(app)
|
||||
.post(`${API}/v1/auth/login`)
|
||||
.send({ email: qaEmail("ratelimit-login"), password: "definitely-wrong", applicationKey: "core" })
|
||||
.expect(429);
|
||||
|
||||
expect(blocked.body.message).toBe("Muitas requisições. Tente novamente mais tarde.");
|
||||
expect(Number(blocked.headers["retry-after"])).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("the exhausted login bucket does not block a different public route from the same IP (buckets are namespaced per rule name)", async () => {
|
||||
// Still within the same 60s window as the previous test — login stays blocked...
|
||||
await agent(app)
|
||||
.post(`${API}/v1/auth/login`)
|
||||
.send({ email: qaEmail("ratelimit-login-2"), password: "definitely-wrong", applicationKey: "core" })
|
||||
.expect(429);
|
||||
|
||||
// ...but forgot-password (a completely separate rule: "auth-forgot-password")
|
||||
// from the exact same IP is untouched.
|
||||
await agent(app)
|
||||
.post(`${API}/v1/auth/forgot-password`)
|
||||
.send({ email: qaEmail("ratelimit-forgot") })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -55,6 +55,11 @@ import { AfdImportPage } from "./routes/hr-timeclock/AfdImportPage.js";
|
||||
import { AfdRecordsListPage } from "./routes/hr-timeclock/AfdRecordsListPage.js";
|
||||
import { ForgotPasswordPage } from "./routes/ForgotPasswordPage.js";
|
||||
import { ResetPasswordPage } from "./routes/ResetPasswordPage.js";
|
||||
import { EnvelopesListPage } from "./routes/signatures/EnvelopesListPage.js";
|
||||
import { EnvelopeFormPage } from "./routes/signatures/EnvelopeFormPage.js";
|
||||
import { EnvelopeDetailPage } from "./routes/signatures/EnvelopeDetailPage.js";
|
||||
import { PublicSignaturePage } from "./routes/PublicSignaturePage.js";
|
||||
import { PublicVerifyPage } from "./routes/PublicVerifyPage.js";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -64,6 +69,8 @@ export function App() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/assinar/:token" element={<PublicSignaturePage />} />
|
||||
<Route path="/verificar/:verificationId" element={<PublicVerifyPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AuthenticatedLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
@@ -132,6 +139,10 @@ export function App() {
|
||||
<Route path="/documents/generations" element={<GenerationsListPage />} />
|
||||
<Route path="/documents/generations/new" element={<GenerationFormPage />} />
|
||||
|
||||
<Route path="/signatures" element={<EnvelopesListPage />} />
|
||||
<Route path="/signatures/new" element={<EnvelopeFormPage />} />
|
||||
<Route path="/signatures/:id" element={<EnvelopeDetailPage />} />
|
||||
|
||||
<Route path="/hr-timeclock/devices" element={<DevicesListPage />} />
|
||||
<Route path="/hr-timeclock/devices/new" element={<DeviceFormPage />} />
|
||||
<Route path="/hr-timeclock/devices/:id/edit" element={<DeviceFormPage />} />
|
||||
|
||||
76
apps/core-web/src/lib/api/public-signature.ts
Normal file
76
apps/core-web/src/lib/api/public-signature.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Client for `apps/api` `/v1/public-signature/:token/*` — the signer-facing
|
||||
* flow reached via the emailed link `/assinar/:token` (see
|
||||
* apps/api/src/signatures/public-signature.controller.ts +
|
||||
* public-signature.service.ts for the source of truth). Deliberately NOT
|
||||
* authenticated: the token in the URL is the entire authorization,
|
||||
* there's never an `accessToken` header here — mirrored by never taking
|
||||
* one as a parameter in this file, unlike `./signatures.ts`.
|
||||
*/
|
||||
import { API_BASE_URL, apiRequest } from "./http.js";
|
||||
import type { PartyRole, PersonType, SignerStatus } from "./signatures.js";
|
||||
|
||||
export interface PublicSignerState {
|
||||
id: string;
|
||||
party: PartyRole;
|
||||
person_type: PersonType | null;
|
||||
full_name: string;
|
||||
cpf: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
company_name: string | null;
|
||||
cnpj: string | null;
|
||||
role_title: string | null;
|
||||
status: SignerStatus;
|
||||
documentsRead: string[];
|
||||
}
|
||||
|
||||
export interface PublicDocumentState {
|
||||
id: string;
|
||||
title: string;
|
||||
documentVersion: number;
|
||||
pageCount: number | null;
|
||||
confirmedRead: boolean;
|
||||
}
|
||||
|
||||
export interface PublicSignatureState {
|
||||
envelope: { id: string; envelopeNumber: string; status: string };
|
||||
signer: PublicSignerState;
|
||||
documents: PublicDocumentState[];
|
||||
}
|
||||
|
||||
export function getPublicSignatureState(token: string): Promise<PublicSignatureState> {
|
||||
return apiRequest<PublicSignatureState>(`/public-signature/${token}`, { method: "GET" });
|
||||
}
|
||||
|
||||
/** Opens the document's PDF in a new tab — no bearer token needed, the token is already in the path, so a plain `window.open` works (unlike the authenticated backoffice equivalent in `./signatures.ts`). */
|
||||
export function publicDocumentPdfUrl(token: string, documentId: string): string {
|
||||
return `${API_BASE_URL}/public-signature/${token}/documents/${documentId}/pdf`;
|
||||
}
|
||||
|
||||
export function confirmDocumentRead(token: string, documentId: string): Promise<void> {
|
||||
return apiRequest<void>(`/public-signature/${token}/documents/${documentId}/confirm-read`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function confirmSignerData(token: string): Promise<void> {
|
||||
return apiRequest<void>(`/public-signature/${token}/confirm-data`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function acceptConsent(token: string): Promise<void> {
|
||||
return apiRequest<void>(`/public-signature/${token}/consent`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function requestOtp(token: string): Promise<void> {
|
||||
return apiRequest<void>(`/public-signature/${token}/otp/request`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function verifyOtp(token: string, code: string): Promise<void> {
|
||||
return apiRequest<void>(`/public-signature/${token}/otp/verify`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
export function commitSignature(token: string): Promise<void> {
|
||||
return apiRequest<void>(`/public-signature/${token}/sign`, { method: "POST" });
|
||||
}
|
||||
39
apps/core-web/src/lib/api/public-verify.ts
Normal file
39
apps/core-web/src/lib/api/public-verify.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Client for `apps/api` `/v1/public-verify/:verificationId` — the public
|
||||
* integrity-check page linked from the final certificate/e-mail
|
||||
* (`/verificar/:verificationId`). See
|
||||
* apps/api/src/signatures/public-verify.service.ts for the source of
|
||||
* truth. No authentication, no PDF download here (deliberate — the final
|
||||
* PDF only ever goes out by e-mail, see eden.md).
|
||||
*/
|
||||
import { apiRequest } from "./http.js";
|
||||
|
||||
export interface PublicVerificationDocument {
|
||||
title: string;
|
||||
version: number;
|
||||
pageCount: number | null;
|
||||
}
|
||||
|
||||
export interface PublicVerificationSigner {
|
||||
/** Already masked server-side (e.g. "M***** S*****") — never the full name. */
|
||||
name: string;
|
||||
party: "contratada" | "contratante";
|
||||
}
|
||||
|
||||
export interface PublicVerificationResult {
|
||||
envelopeNumber: string;
|
||||
completedAt: string;
|
||||
documents: PublicVerificationDocument[];
|
||||
signers: PublicVerificationSigner[];
|
||||
integrityValid: boolean;
|
||||
eventsChecked: number;
|
||||
envelopeHash: string | null;
|
||||
finalPdfSha256: string | null;
|
||||
evidenceCheckpointHash: string | null;
|
||||
verificationId: string | null;
|
||||
method: string;
|
||||
}
|
||||
|
||||
export function getPublicVerification(verificationId: string): Promise<PublicVerificationResult> {
|
||||
return apiRequest<PublicVerificationResult>(`/public-verify/${verificationId}`, { method: "GET" });
|
||||
}
|
||||
255
apps/core-web/src/lib/api/signatures.ts
Normal file
255
apps/core-web/src/lib/api/signatures.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Client for `apps/api` `/v1/signature-envelopes` (backoffice, authenticated)
|
||||
* — see apps/api/src/signatures/*.controller.ts,
|
||||
* packages/database/src/repositories/signatures.ts, and the migration
|
||||
* `1788536022387_signatures.cjs` for the source of truth. Shapes below are
|
||||
* mirrored, not invented. The public (token-based) and public-verify
|
||||
* surfaces live in `./public-signature.ts` and `./public-verify.ts` —
|
||||
* different auth model, kept in separate files on purpose.
|
||||
*/
|
||||
import { API_BASE_URL, apiRequest } from "./http.js";
|
||||
|
||||
export const ENVELOPE_STATUSES = [
|
||||
"DRAFT",
|
||||
"READY",
|
||||
"SENT",
|
||||
"VIEWED",
|
||||
"IDENTITY_PENDING",
|
||||
"CONSENT_PENDING",
|
||||
"OTP_PENDING",
|
||||
"OTP_SENT",
|
||||
"OTP_VERIFIED",
|
||||
"READY_TO_SIGN",
|
||||
"SIGNING",
|
||||
"SIGNED",
|
||||
"FINALIZING",
|
||||
"COMPLETED",
|
||||
"DECLINED",
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
"SUPERSEDED",
|
||||
"ERROR",
|
||||
] as const;
|
||||
export type EnvelopeStatus = (typeof ENVELOPE_STATUSES)[number];
|
||||
|
||||
export const SIGNER_STATUSES = [
|
||||
"PENDING",
|
||||
"LINK_SENT",
|
||||
"VIEWED",
|
||||
"DATA_CONFIRMED",
|
||||
"CONSENTED",
|
||||
"OTP_VERIFIED",
|
||||
"SIGNED",
|
||||
"DECLINED",
|
||||
] as const;
|
||||
export type SignerStatus = (typeof SIGNER_STATUSES)[number];
|
||||
|
||||
export type PartyRole = "contratada" | "contratante";
|
||||
export type PersonType = "pf" | "pj";
|
||||
export type ActorType = "SIGNER" | "BACKOFFICE" | "ADMIN" | "SUPER_ADMIN" | "SYSTEM" | "SERVICE";
|
||||
|
||||
/** Response shape — snake_case, exactly as returned by the `signature_envelopes` table. */
|
||||
export interface SignatureEnvelope {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
envelope_number: string;
|
||||
company_id: string;
|
||||
client_registration_id: string | null;
|
||||
reseller_id: string | null;
|
||||
status: EnvelopeStatus;
|
||||
verification_id: string | null;
|
||||
final_s3_key: string | null;
|
||||
final_pdf_sha256: string | null;
|
||||
certificate_s3_key: string | null;
|
||||
evidence_checkpoint_hash: string | null;
|
||||
envelope_hash: string | null;
|
||||
sent_at: string | null;
|
||||
viewed_at: string | null;
|
||||
completed_at: string | null;
|
||||
declined_at: string | null;
|
||||
cancelled_at: string | null;
|
||||
expired_at: string | null;
|
||||
signed_at: string | null;
|
||||
}
|
||||
|
||||
export interface SignatureSigner {
|
||||
id: string;
|
||||
created_at: string;
|
||||
envelope_id: string;
|
||||
sort_order: number;
|
||||
party: PartyRole;
|
||||
person_type: PersonType | null;
|
||||
full_name: string;
|
||||
cpf: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
company_name: string | null;
|
||||
cnpj: string | null;
|
||||
role_title: string | null;
|
||||
status: SignerStatus;
|
||||
signed_at: string | null;
|
||||
secure_token_expires_at: string | null;
|
||||
secure_token_revoked_at: string | null;
|
||||
secure_token_first_used_at: string | null;
|
||||
secure_token_last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface SignatureDocument {
|
||||
id: string;
|
||||
envelope_id: string;
|
||||
source_generation_id: string | null;
|
||||
document_type: string | null;
|
||||
title: string;
|
||||
sort_order: number;
|
||||
document_version: number;
|
||||
source_filename: string | null;
|
||||
source_mime_type: string | null;
|
||||
original_sha256: string;
|
||||
page_count: number | null;
|
||||
status: "FROZEN" | "SUPERSEDED";
|
||||
superseded_by_id: string | null;
|
||||
frozen_at: string;
|
||||
}
|
||||
|
||||
export interface SignatureAuditEvent {
|
||||
id: string;
|
||||
seq: string;
|
||||
envelope_id: string;
|
||||
document_id: string | null;
|
||||
signer_id: string | null;
|
||||
event_type: string;
|
||||
actor_type: ActorType;
|
||||
actor_id: string | null;
|
||||
actor_role: string | null;
|
||||
timestamp_utc: string;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
browser: string | null;
|
||||
operating_system: string | null;
|
||||
device_type: string | null;
|
||||
metadata_json: Record<string, unknown>;
|
||||
previous_event_hash: string | null;
|
||||
event_hash: string;
|
||||
hash_algorithm_version: number;
|
||||
}
|
||||
|
||||
export interface EnvelopeWithDetails extends SignatureEnvelope {
|
||||
signers: SignatureSigner[];
|
||||
documents: SignatureDocument[];
|
||||
}
|
||||
|
||||
/** Body of `POST /signature-envelopes` — matches `CreateEnvelopeDto`. The `contratada` signer is never sent here, it's auto-filled server-side from `companies.legal_rep_*`. */
|
||||
export interface SignerInput {
|
||||
personType?: PersonType;
|
||||
fullName: string;
|
||||
cpf?: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
companyName?: string;
|
||||
cnpj?: string;
|
||||
roleTitle?: string;
|
||||
}
|
||||
|
||||
export interface CreateEnvelopeInput {
|
||||
companyId: string;
|
||||
clientRegistrationId?: string;
|
||||
resellerId?: string;
|
||||
documentGenerationIds: string[];
|
||||
signers: SignerInput[];
|
||||
}
|
||||
|
||||
export type SendInviteTargets = "contratada" | "contratante" | "both";
|
||||
|
||||
export interface VerifyAuditResult {
|
||||
valid: boolean;
|
||||
eventsChecked: number;
|
||||
failedAt?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function listEnvelopes(accessToken: string): Promise<SignatureEnvelope[]> {
|
||||
return apiRequest<SignatureEnvelope[]>("/signature-envelopes", { method: "GET" }, { accessToken });
|
||||
}
|
||||
|
||||
export function getEnvelope(id: string, accessToken: string): Promise<EnvelopeWithDetails> {
|
||||
return apiRequest<EnvelopeWithDetails>(`/signature-envelopes/${id}`, { method: "GET" }, { accessToken });
|
||||
}
|
||||
|
||||
export function createEnvelope(input: CreateEnvelopeInput, accessToken: string): Promise<EnvelopeWithDetails> {
|
||||
return apiRequest<EnvelopeWithDetails>(
|
||||
"/signature-envelopes",
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
{ accessToken },
|
||||
);
|
||||
}
|
||||
|
||||
export function sendInvite(
|
||||
id: string,
|
||||
targets: SendInviteTargets,
|
||||
accessToken: string,
|
||||
): Promise<EnvelopeWithDetails> {
|
||||
return apiRequest<EnvelopeWithDetails>(
|
||||
`/signature-envelopes/${id}/send`,
|
||||
{ method: "POST", body: JSON.stringify({ targets }) },
|
||||
{ accessToken },
|
||||
);
|
||||
}
|
||||
|
||||
export function cancelEnvelope(
|
||||
id: string,
|
||||
reason: string | undefined,
|
||||
accessToken: string,
|
||||
): Promise<SignatureEnvelope> {
|
||||
return apiRequest<SignatureEnvelope>(
|
||||
`/signature-envelopes/${id}/cancel`,
|
||||
{ method: "POST", body: JSON.stringify({ reason }) },
|
||||
{ accessToken },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteEnvelope(id: string, accessToken: string): Promise<void> {
|
||||
return apiRequest<void>(`/signature-envelopes/${id}`, { method: "DELETE" }, { accessToken });
|
||||
}
|
||||
|
||||
export function verifyAudit(id: string, accessToken: string): Promise<VerifyAuditResult> {
|
||||
return apiRequest<VerifyAuditResult>(`/signature-envelopes/${id}/verify-audit`, { method: "GET" }, { accessToken });
|
||||
}
|
||||
|
||||
export function listAuditEvents(id: string, accessToken: string): Promise<SignatureAuditEvent[]> {
|
||||
return apiRequest<SignatureAuditEvent[]>(`/signature-envelopes/${id}/audit-events`, { method: "GET" }, { accessToken });
|
||||
}
|
||||
|
||||
export function sealCheckpoint(id: string, accessToken: string): Promise<SignatureEnvelope> {
|
||||
return apiRequest<SignatureEnvelope>(`/signature-envelopes/${id}/seal-checkpoint`, { method: "POST" }, { accessToken });
|
||||
}
|
||||
|
||||
export function finalizeEnvelope(id: string, accessToken: string): Promise<SignatureEnvelope> {
|
||||
return apiRequest<SignatureEnvelope>(`/signature-envelopes/${id}/finalize`, { method: "POST" }, { accessToken });
|
||||
}
|
||||
|
||||
/** Fetches a frozen document's PDF and opens it in a new tab — same "Blob + object URL" pattern as `documents.ts`'s `downloadGenerationPdf` (the route is behind `JwtAuthGuard`, a plain `<a href>` can't carry the bearer token). */
|
||||
export async function openDocumentPdf(envelopeId: string, documentId: string, accessToken: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/signature-envelopes/${envelopeId}/documents/${documentId}/pdf`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) throw new Error("Não foi possível abrir o PDF do documento.");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
export async function downloadFinalPdf(envelopeId: string, envelopeNumber: string, accessToken: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/signature-envelopes/${envelopeId}/final-pdf`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) throw new Error("Não foi possível baixar o PDF final.");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${envelopeNumber}.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
} from "../api/auth.js";
|
||||
import type { PublicUser } from "../api/auth.js";
|
||||
import type { PermissionGrant, PublicUser } from "../api/auth.js";
|
||||
import { clearSession, readSession, writeSession } from "./session.js";
|
||||
|
||||
export type AuthStatus = "loading" | "authenticated" | "anonymous";
|
||||
@@ -22,6 +22,8 @@ interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: PublicUser | null;
|
||||
accessToken: string | null;
|
||||
/** `null` while still loading; `"ALL"` for super-admin-style grants — see `hasPermission` in `./permissions.js`. Never used for actual access control (that's server-side only), only to hide/disable actions the user can't perform. */
|
||||
permissions: "ALL" | PermissionGrant[] | null;
|
||||
/** Resolves to `{ mfaRequired: true, mfaChallenge }` when a TOTP code is needed next, otherwise logs in fully. */
|
||||
login: (email: string, password: string) => Promise<MfaPending | { mfaRequired: false }>;
|
||||
submitMfa: (mfaChallenge: string, code: string) => Promise<void>;
|
||||
@@ -35,6 +37,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||
const [permissions, setPermissions] = useState<"ALL" | PermissionGrant[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readSession();
|
||||
@@ -51,6 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(me.user);
|
||||
setAccessToken(stored.accessToken);
|
||||
setRefreshToken(stored.refreshToken);
|
||||
setPermissions(me.permissions);
|
||||
setStatus("authenticated");
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -69,6 +73,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setAccessToken(result.accessToken);
|
||||
setRefreshToken(result.refreshToken);
|
||||
setStatus("authenticated");
|
||||
// `/auth/login` doesn't return permission grants (only `/auth/me` does)
|
||||
// — fetch them right away so nav/action visibility is correct from the
|
||||
// first authenticated render, not just after a manual refresh.
|
||||
fetchMe(result.accessToken)
|
||||
.then((me) => setPermissions(me.permissions))
|
||||
.catch(() => setPermissions(null));
|
||||
return { mfaRequired: false as const };
|
||||
}, []);
|
||||
|
||||
@@ -79,6 +89,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setAccessToken(result.accessToken);
|
||||
setRefreshToken(result.refreshToken);
|
||||
setStatus("authenticated");
|
||||
fetchMe(result.accessToken)
|
||||
.then((me) => setPermissions(me.permissions))
|
||||
.catch(() => setPermissions(null));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
@@ -91,12 +104,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
setRefreshToken(null);
|
||||
setPermissions(null);
|
||||
setStatus("anonymous");
|
||||
}, [refreshToken]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ status, user, accessToken, login, submitMfa, logout }),
|
||||
[status, user, accessToken, login, submitMfa, logout],
|
||||
() => ({ status, user, accessToken, permissions, login, submitMfa, logout }),
|
||||
[status, user, accessToken, permissions, login, submitMfa, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
19
apps/core-web/src/lib/auth/permissions.ts
Normal file
19
apps/core-web/src/lib/auth/permissions.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { PermissionGrant } from "../api/auth.js";
|
||||
|
||||
/**
|
||||
* Mirrors (loosely — this is UX only, not authorization) the shape of
|
||||
* `maxGrantedScope` in `apps/api/src/auth/permissions.ts`: the backend is
|
||||
* always the one deciding whether an action is allowed (every mutating
|
||||
* route re-checks via `RequirePermission`/`maxGrantedScope` itself). This
|
||||
* only answers "should the button even be shown/enabled" — hiding a
|
||||
* button a user can't use is UX, not access control.
|
||||
*/
|
||||
export function hasPermission(
|
||||
permissions: "ALL" | PermissionGrant[] | null,
|
||||
resource: string,
|
||||
action: string,
|
||||
): boolean {
|
||||
if (permissions === "ALL") return true;
|
||||
if (!permissions) return false;
|
||||
return permissions.some((grant) => grant.resource === resource && grant.action === action);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function AuthenticatedLayout() {
|
||||
{ key: "client-registrations", label: "Clientes", href: "/client-registrations" },
|
||||
{ key: "contracts", label: "Contratos", href: "/contracts" },
|
||||
{ key: "documents", label: "Documentos", href: "/documents/templates" },
|
||||
{ key: "signatures", label: "Assinaturas", href: "/signatures" },
|
||||
{ key: "inventory", label: "Estoque", href: "/inventory/assets" },
|
||||
{ key: "billing", label: "Faturamento", href: "/billing/subscriptions" },
|
||||
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },
|
||||
|
||||
456
apps/core-web/src/routes/PublicSignaturePage.tsx
Normal file
456
apps/core-web/src/routes/PublicSignaturePage.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card } from "@eden/ui";
|
||||
import { ApiError } from "../lib/api/http.js";
|
||||
import {
|
||||
acceptConsent,
|
||||
commitSignature,
|
||||
confirmDocumentRead,
|
||||
confirmSignerData,
|
||||
getPublicSignatureState,
|
||||
publicDocumentPdfUrl,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
} from "../lib/api/public-signature.js";
|
||||
import type { PublicSignatureState } from "../lib/api/public-signature.js";
|
||||
import { formatDocument } from "../lib/format.js";
|
||||
|
||||
type WizardStep = "documents" | "data" | "consent" | "otp" | "sign" | "done";
|
||||
|
||||
const STEP_LABEL: Record<WizardStep, string> = {
|
||||
documents: "Documentos",
|
||||
data: "Confirmar dados",
|
||||
consent: "Consentimento",
|
||||
otp: "Verificação",
|
||||
sign: "Assinatura",
|
||||
done: "Concluído",
|
||||
};
|
||||
|
||||
const STEP_ORDER: WizardStep[] = ["documents", "data", "consent", "otp", "sign", "done"];
|
||||
|
||||
function currentStep(state: PublicSignatureState): WizardStep {
|
||||
const { signer, documents } = state;
|
||||
if (signer.status === "SIGNED") return "done";
|
||||
if (signer.status === "OTP_VERIFIED") return "sign";
|
||||
if (signer.status === "CONSENTED") return "otp";
|
||||
if (signer.status === "DATA_CONFIRMED") return "consent";
|
||||
const allConfirmed = documents.length > 0 && documents.every((d) => d.confirmedRead);
|
||||
return allConfirmed ? "data" : "documents";
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, unauthenticated signer flow — `/assinar/:token` (link the API
|
||||
* e-mails via `EnvelopeService.sendInvite`). No `AuthenticatedLayout`, no
|
||||
* sidebar: the signatário may be a lay end customer, not a backoffice
|
||||
* user. Always re-fetches `GET /public-signature/:token` after every
|
||||
* action (same "central reload" idiom as the authenticated detail pages
|
||||
* in `routes/signatures/`) so closing the browser mid-flow and reopening
|
||||
* the same link resumes at the right step from `signer.status` — eden.md
|
||||
* §10.
|
||||
*/
|
||||
export function PublicSignaturePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [state, setState] = useState<PublicSignatureState | null>(null);
|
||||
const [loadError, setLoadError] = useState<"not_found" | "gone" | "generic" | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
getPublicSignatureState(token)
|
||||
.then(setState)
|
||||
.catch((err) => {
|
||||
if (err instanceof ApiError && err.status === 404) setLoadError("not_found");
|
||||
else if (err instanceof ApiError && err.status === 410) setLoadError("gone");
|
||||
else setLoadError("generic");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => reload(), [reload]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light">
|
||||
<header className="border-b border-border-color bg-white px-4 py-4">
|
||||
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto h-8" />
|
||||
</header>
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
{loading ? (
|
||||
<p className="text-center text-sm text-default">Carregando…</p>
|
||||
) : loadError === "not_found" ? (
|
||||
<Card>
|
||||
<Alert variant="error" title="Link inválido">
|
||||
Este link de assinatura não existe ou está incorreto. Confira o e-mail de convite ou solicite um novo
|
||||
link ao remetente.
|
||||
</Alert>
|
||||
</Card>
|
||||
) : loadError === "gone" ? (
|
||||
<Card>
|
||||
<Alert variant="error" title="Link indisponível">
|
||||
Este link foi revogado, expirou, ou o envelope não está mais disponível para assinatura. Solicite um
|
||||
novo convite.
|
||||
</Alert>
|
||||
</Card>
|
||||
) : loadError === "generic" ? (
|
||||
<Card>
|
||||
<Alert variant="error">Não foi possível carregar os dados desta assinatura. Tente novamente.</Alert>
|
||||
</Card>
|
||||
) : state && token ? (
|
||||
<SignatureWizard state={state} token={token} onChanged={reload} />
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureWizard({
|
||||
state,
|
||||
token,
|
||||
onChanged,
|
||||
}: {
|
||||
state: PublicSignatureState;
|
||||
token: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const step = currentStep(state);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-2 text-center">
|
||||
<p className="text-sm text-default">Envelope {state.envelope.envelopeNumber}</p>
|
||||
<h1 className="!text-xl font-bold text-title">Assinatura eletrônica</h1>
|
||||
</div>
|
||||
|
||||
<StepProgress current={step} />
|
||||
|
||||
{step === "documents" ? <DocumentsStep state={state} token={token} onChanged={onChanged} /> : null}
|
||||
{step === "data" ? <ConfirmDataStep state={state} token={token} onChanged={onChanged} /> : null}
|
||||
{step === "consent" ? <ConsentStep token={token} onChanged={onChanged} /> : null}
|
||||
{step === "otp" ? <OtpStep token={token} onChanged={onChanged} /> : null}
|
||||
{step === "sign" ? <SignStep token={token} onChanged={onChanged} /> : null}
|
||||
{step === "done" ? <DoneStep /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepProgress({ current }: { current: WizardStep }) {
|
||||
const currentIndex = STEP_ORDER.indexOf(current);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-default">
|
||||
{STEP_ORDER.map((step, index) => (
|
||||
<span
|
||||
key={step}
|
||||
className={
|
||||
index < currentIndex
|
||||
? "rounded-full bg-success-transparent px-2 py-1 text-success-900"
|
||||
: index === currentIndex
|
||||
? "rounded-full bg-primary-transparent px-2 py-1 font-semibold text-primary-900"
|
||||
: "rounded-full bg-light px-2 py-1"
|
||||
}
|
||||
>
|
||||
{STEP_LABEL[step]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentsStep({
|
||||
state,
|
||||
token,
|
||||
onChanged,
|
||||
}: {
|
||||
state: PublicSignatureState;
|
||||
token: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [confirmingId, setConfirmingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const allConfirmed = state.documents.length > 0 && state.documents.every((d) => d.confirmedRead);
|
||||
|
||||
async function handleConfirm(documentId: string) {
|
||||
setError(null);
|
||||
setConfirmingId(documentId);
|
||||
try {
|
||||
await confirmDocumentRead(token, documentId);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível confirmar a leitura.");
|
||||
} finally {
|
||||
setConfirmingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="1. Leia os documentos">
|
||||
<p className="mb-4 text-sm text-default">
|
||||
Abra e leia cada documento antes de confirmar. Você só pode prosseguir depois de confirmar a leitura de
|
||||
todos.
|
||||
</p>
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{state.documents.map((doc) => (
|
||||
<div key={doc.id} className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border-color p-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-title">{doc.title}</p>
|
||||
<p className="text-xs text-default">
|
||||
Versão {doc.documentVersion}
|
||||
{doc.pageCount ? ` · ${doc.pageCount} página(s)` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={publicDocumentPdfUrl(token, doc.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
|
||||
>
|
||||
Abrir PDF
|
||||
</a>
|
||||
{doc.confirmedRead ? (
|
||||
<Badge variant="success">Leitura confirmada</Badge>
|
||||
) : (
|
||||
<Button type="button" size="sm" loading={confirmingId === doc.id} onClick={() => handleConfirm(doc.id)}>
|
||||
Confirmar leitura
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!allConfirmed ? (
|
||||
<p className="mt-4 text-xs text-default">Confirme a leitura de todos os documentos para continuar.</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDataStep({
|
||||
state,
|
||||
token,
|
||||
onChanged,
|
||||
}: {
|
||||
state: PublicSignatureState;
|
||||
token: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await confirmSignerData(token);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível confirmar seus dados.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="2. Confirme seus dados">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
<p className="mb-4 text-sm text-default">Confira se os dados abaixo estão corretos antes de continuar.</p>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-default">Nome</dt>
|
||||
<dd className="font-semibold text-title">{state.signer.full_name}</dd>
|
||||
</div>
|
||||
{state.signer.cpf ? (
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-default">CPF</dt>
|
||||
<dd className="font-semibold text-title">{formatDocument(state.signer.cpf)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-default">E-mail</dt>
|
||||
<dd className="font-semibold text-title">{state.signer.email}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Button type="button" className="mt-4 w-full" loading={submitting} onClick={handleConfirm}>
|
||||
Confirmar meus dados
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsentStep({ token, onChanged }: { token: string; onChanged: () => void }) {
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleAccept() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await acceptConsent(token);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível registrar o consentimento.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="3. Termo de assinatura eletrônica">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
<p className="mb-4 text-sm text-default">
|
||||
Ao aceitar, você concorda em assinar este(s) documento(s) eletronicamente, com o mesmo valor jurídico de uma
|
||||
assinatura manuscrita, mediante verificação por código enviado ao seu e-mail.
|
||||
</p>
|
||||
<label className="mb-4 flex items-start gap-2 text-sm">
|
||||
<input type="checkbox" className="mt-1" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} />
|
||||
<span>Li e aceito os termos de assinatura eletrônica.</span>
|
||||
</label>
|
||||
<Button type="button" className="w-full" disabled={!accepted} loading={submitting} onClick={handleAccept}>
|
||||
Aceitar
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OtpStep({ token, onChanged }: { token: string; onChanged: () => void }) {
|
||||
const [requested, setRequested] = useState(false);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
async function handleRequest() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setRequesting(true);
|
||||
try {
|
||||
await requestOtp(token);
|
||||
setRequested(true);
|
||||
setInfo("Código enviado para seu e-mail. Válido por 5 minutos.");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível enviar o código.");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
setError(null);
|
||||
setVerifying(true);
|
||||
try {
|
||||
await verifyOtp(token, code);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível verificar o código.");
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="4. Verificação por código">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
{info ? (
|
||||
<Alert variant="success" className="mb-3">
|
||||
{info}
|
||||
</Alert>
|
||||
) : null}
|
||||
{!requested ? (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-default">
|
||||
Vamos enviar um código de verificação de 6 dígitos para o seu e-mail cadastrado.
|
||||
</p>
|
||||
<Button type="button" className="w-full" loading={requesting} onClick={handleRequest}>
|
||||
Enviar código de verificação
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-sm text-default">Digite o código de 6 dígitos que enviamos para o seu e-mail.</p>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="000000"
|
||||
className="w-full rounded-md border border-border-color px-3 py-2.5 text-center text-lg tracking-[0.5em] focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
<Button type="button" className="mt-3 w-full" loading={verifying} disabled={code.length !== 6} onClick={handleVerify}>
|
||||
Verificar
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full text-center text-xs text-default hover:text-primary"
|
||||
onClick={handleRequest}
|
||||
>
|
||||
Reenviar código
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SignStep({ token, onChanged }: { token: string; onChanged: () => void }) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSign() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await commitSignature(token);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível registrar a assinatura.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="5. Assinar documento">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
<p className="mb-4 text-sm text-default">
|
||||
Ao clicar em assinar, sua manifestação de vontade será registrada de forma definitiva para este documento.
|
||||
</p>
|
||||
<Button type="button" className="w-full" loading={submitting} onClick={handleSign}>
|
||||
Assinar documento
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DoneStep() {
|
||||
return (
|
||||
<Card title="Documento assinado">
|
||||
<Alert variant="success">
|
||||
Documento assinado! Você receberá o PDF final por e-mail assim que todas as partes concluírem.
|
||||
</Alert>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
189
apps/core-web/src/routes/PublicVerifyPage.tsx
Normal file
189
apps/core-web/src/routes/PublicVerifyPage.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Badge, Card } from "@eden/ui";
|
||||
import { ApiError } from "../lib/api/http.js";
|
||||
import { getPublicVerification } from "../lib/api/public-verify.js";
|
||||
import type { PublicVerificationResult } from "../lib/api/public-verify.js";
|
||||
import { formatDateTimeBR } from "../lib/format.js";
|
||||
|
||||
/**
|
||||
* Public, unauthenticated integrity-check page — `/verificar/:verificationId`
|
||||
* (link printed on the completion certificate and the final e-mail, see
|
||||
* `EnvelopeFinalizeService.finalize`). No PDF download here on purpose —
|
||||
* `PublicVerifyService` never exposes one; the final PDF only ever goes
|
||||
* out by e-mail to the signers (eden.md).
|
||||
*/
|
||||
export function PublicVerifyPage() {
|
||||
const { verificationId } = useParams<{ verificationId: string }>();
|
||||
const [result, setResult] = useState<PublicVerificationResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!verificationId) return;
|
||||
setLoading(true);
|
||||
getPublicVerification(verificationId)
|
||||
.then(setResult)
|
||||
.catch((err) => {
|
||||
if (err instanceof ApiError && err.status === 404) setNotFound(true);
|
||||
else setError(err instanceof ApiError ? err.message : "Não foi possível verificar este documento.");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [verificationId]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light">
|
||||
<header className="border-b border-border-color bg-white px-4 py-4">
|
||||
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto h-8" />
|
||||
</header>
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
{loading ? (
|
||||
<p className="text-center text-sm text-default">Verificando…</p>
|
||||
) : notFound ? (
|
||||
<Card>
|
||||
<Alert variant="error" title="Registro não encontrado">
|
||||
Este código de verificação não corresponde a nenhum documento concluído. Confira o link ou o código
|
||||
informado.
|
||||
</Alert>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<Alert variant="error">{error}</Alert>
|
||||
</Card>
|
||||
) : result ? (
|
||||
<VerificationResultView result={result} />
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationResultView({ result }: { result: PublicVerificationResult }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
{result.integrityValid ? (
|
||||
<>
|
||||
<p className="text-3xl">✓</p>
|
||||
<h1 className="!text-xl font-bold text-success-900">Documento verificado</h1>
|
||||
<p className="mt-1 text-sm text-default">A integridade deste documento foi confirmada.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-3xl">⚠</p>
|
||||
<h1 className="!text-xl font-bold text-danger-900">Integridade comprometida</h1>
|
||||
<p className="mt-1 text-sm text-default">
|
||||
Não foi possível confirmar a integridade deste documento — os dados podem ter sido adulterados.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Envelope">
|
||||
<dl className="space-y-2 text-sm">
|
||||
<Row label="Número" value={result.envelopeNumber} />
|
||||
<Row label="Concluído em" value={formatDateTimeBR(result.completedAt)} />
|
||||
<Row label="Método" value={result.method} />
|
||||
<Row label="Eventos de auditoria verificados" value={String(result.eventsChecked)} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Documentos">
|
||||
{result.documents.length === 0 ? (
|
||||
<p className="text-sm text-default">Nenhum documento.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{result.documents.map((doc, index) => (
|
||||
<li key={index} className="flex items-center justify-between rounded-md border border-border-color p-2">
|
||||
<span className="font-semibold text-title">{doc.title}</span>
|
||||
<span className="text-xs text-default">
|
||||
v{doc.version}
|
||||
{doc.pageCount ? ` · ${doc.pageCount} página(s)` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Signatários">
|
||||
{result.signers.length === 0 ? (
|
||||
<p className="text-sm text-default">Nenhum signatário.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{result.signers.map((signer, index) => (
|
||||
<li key={index} className="flex items-center justify-between rounded-md border border-border-color p-2">
|
||||
<span className="font-semibold text-title">{signer.name}</span>
|
||||
<Badge variant={signer.party === "contratada" ? "primary" : "info"}>
|
||||
{signer.party === "contratada" ? "Contratada" : "Contratante"}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Hashes de integridade">
|
||||
<div className="space-y-3 text-sm">
|
||||
<HashField label="Hash do envelope" value={result.envelopeHash} />
|
||||
<HashField label="Hash do PDF final (SHA-256)" value={result.finalPdfSha256} />
|
||||
<HashField label="Hash do checkpoint de evidências" value={result.evidenceCheckpointHash} />
|
||||
<HashField label="ID de verificação" value={result.verificationId} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<dt className="text-default">{label}</dt>
|
||||
<dd className="text-right font-semibold text-title">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HashField({ label, value }: { label: string; value: string | null }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase text-default">{label}</p>
|
||||
<p className="font-mono text-xs text-default">—</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = expanded ? value : `${value.slice(0, 16)}…${value.slice(-8)}`;
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value ?? "");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase text-default">{label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="break-all font-mono text-xs text-title">{displayValue}</p>
|
||||
<button type="button" className="shrink-0 text-xs text-primary underline" onClick={() => setExpanded((v) => !v)}>
|
||||
{expanded ? "recolher" : "expandir"}
|
||||
</button>
|
||||
<button type="button" className="shrink-0 text-xs text-primary underline" onClick={handleCopy}>
|
||||
{copied ? "copiado!" : "copiar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
587
apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx
Normal file
587
apps/core-web/src/routes/signatures/EnvelopeDetailPage.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { hasPermission } from "../../lib/auth/permissions.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
cancelEnvelope,
|
||||
deleteEnvelope,
|
||||
downloadFinalPdf,
|
||||
finalizeEnvelope,
|
||||
getEnvelope,
|
||||
listAuditEvents,
|
||||
openDocumentPdf,
|
||||
sealCheckpoint,
|
||||
sendInvite,
|
||||
verifyAudit,
|
||||
} from "../../lib/api/signatures.js";
|
||||
import type {
|
||||
EnvelopeWithDetails,
|
||||
SendInviteTargets,
|
||||
SignatureAuditEvent,
|
||||
VerifyAuditResult,
|
||||
} from "../../lib/api/signatures.js";
|
||||
import { getCompany } from "../../lib/api/companies.js";
|
||||
import type { Company } from "../../lib/api/companies.js";
|
||||
import { formatDateTimeBR } from "../../lib/format.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
import {
|
||||
ENVELOPE_STATUS_LABEL,
|
||||
ENVELOPE_STATUS_VARIANT,
|
||||
SIGNER_STATUS_LABEL,
|
||||
SIGNER_STATUS_VARIANT,
|
||||
actorTypeLabel,
|
||||
auditEventLabel,
|
||||
partyRoleLabel,
|
||||
} from "./signatureStatus.js";
|
||||
|
||||
const DELETE_BLOCKED_STATUSES = new Set(["SIGNED", "FINALIZING", "COMPLETED"]);
|
||||
const SEND_INVITE_BLOCKED_STATUSES = new Set(["COMPLETED", "CANCELLED", "DECLINED", "EXPIRED", "SUPERSEDED", "ERROR"]);
|
||||
/** Mirrors `ENVELOPE_TRANSITIONS` in `packages/database/src/repositories/signatures.ts` — `CANCELLED` is only a valid target from these pre-signing states; anything past `SIGNING` (inclusive) 409s server-side. UX-only gating, the backend is still the one enforcing it. */
|
||||
const CANCEL_BLOCKED_STATUSES = new Set([
|
||||
"SIGNING",
|
||||
"SIGNED",
|
||||
"FINALIZING",
|
||||
"COMPLETED",
|
||||
"DECLINED",
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
"SUPERSEDED",
|
||||
"ERROR",
|
||||
]);
|
||||
|
||||
export function EnvelopeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { accessToken, permissions } = useAuth();
|
||||
|
||||
const [envelope, setEnvelope] = useState<EnvelopeWithDetails | null>(null);
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [auditEvents, setAuditEvents] = useState<SignatureAuditEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canEdit = hasPermission(permissions, "signatures", "edit");
|
||||
const canDelete = hasPermission(permissions, "signatures", "delete");
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getEnvelope(id, accessToken)
|
||||
.then(async (result) => {
|
||||
setEnvelope(result);
|
||||
const [companyResult, events] = await Promise.all([
|
||||
getCompany(result.company_id, accessToken).catch(() => null),
|
||||
listAuditEvents(id, accessToken).catch(() => []),
|
||||
]);
|
||||
setCompany(companyResult);
|
||||
setAuditEvents(events);
|
||||
})
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o envelope."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Envelope de assinatura" />
|
||||
<p className="text-sm text-default">Carregando envelope…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !envelope) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Envelope de assinatura" />
|
||||
<Alert variant="error">{error ?? "Envelope não encontrado."}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const verificationUrl = envelope.verification_id ? `${window.location.origin}/verificar/${envelope.verification_id}` : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Assinaturas", href: "/signatures" }, { label: envelope.envelope_number }]} />
|
||||
<PageHeader
|
||||
title={`Envelope ${envelope.envelope_number}`}
|
||||
actions={<Badge variant={ENVELOPE_STATUS_VARIANT[envelope.status]}>{ENVELOPE_STATUS_LABEL[envelope.status]}</Badge>}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
<Card title="Dados do envelope">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label="Empresa (contratada)" value={company?.company_name ?? envelope.company_id} />
|
||||
<Row label="Cadastro de cliente" value={envelope.client_registration_id ?? "—"} />
|
||||
<Row label="Revenda" value={envelope.reseller_id ?? "—"} />
|
||||
<Row label="Criado em" value={formatDateTimeBR(envelope.created_at)} />
|
||||
<Row label="Enviado em" value={formatDateTimeBR(envelope.sent_at)} />
|
||||
<Row label="Assinado em" value={formatDateTimeBR(envelope.signed_at)} />
|
||||
<Row label="Concluído em" value={formatDateTimeBR(envelope.completed_at)} />
|
||||
<Row label="Cancelado em" value={formatDateTimeBR(envelope.cancelled_at)} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{envelope.status === "COMPLETED" ? (
|
||||
<Card title="Verificação pública">
|
||||
<p className="text-sm text-default">
|
||||
Qualquer pessoa com este link pode conferir a integridade do documento (sem autenticação):
|
||||
</p>
|
||||
{verificationUrl ? (
|
||||
<a
|
||||
href={`/verificar/${envelope.verification_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 block break-all font-mono text-xs text-primary underline"
|
||||
>
|
||||
{verificationUrl}
|
||||
</a>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<EnvelopeActionsCard
|
||||
envelope={envelope}
|
||||
accessToken={accessToken}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
onChanged={load}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<Card title="Documentos">
|
||||
{envelope.documents.length === 0 ? (
|
||||
<p className="text-sm text-default">Nenhum documento neste envelope.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="py-2 pe-3 font-semibold text-title">Documento</th>
|
||||
<th className="py-2 pe-3 font-semibold text-title">Versão</th>
|
||||
<th className="py-2 pe-3 font-semibold text-title">Páginas</th>
|
||||
<th className="py-2 font-semibold text-title" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{envelope.documents
|
||||
.slice()
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((doc) => (
|
||||
<DocumentRow key={doc.id} envelopeId={envelope.id} document={doc} accessToken={accessToken} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Signatários">
|
||||
{envelope.signers.length === 0 ? (
|
||||
<p className="text-sm text-default">Nenhum signatário cadastrado.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="py-2 pe-3 font-semibold text-title">Papel</th>
|
||||
<th className="py-2 pe-3 font-semibold text-title">Nome</th>
|
||||
<th className="py-2 pe-3 font-semibold text-title">Contato</th>
|
||||
<th className="py-2 pe-3 font-semibold text-title">Cargo</th>
|
||||
<th className="py-2 font-semibold text-title">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{envelope.signers
|
||||
.slice()
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((signer) => (
|
||||
<tr key={signer.id} className="border-b border-border-color last:border-0">
|
||||
<td className="py-2 pe-3">
|
||||
<Badge variant={signer.party === "contratada" ? "primary" : "info"}>
|
||||
{partyRoleLabel(signer.party)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pe-3 font-semibold text-title">
|
||||
{signer.full_name}
|
||||
{signer.cpf ? <span className="block text-xs font-normal text-default">{signer.cpf}</span> : null}
|
||||
</td>
|
||||
<td className="py-2 pe-3 text-default">
|
||||
{signer.email}
|
||||
{signer.phone ? <span className="block text-xs">{signer.phone}</span> : null}
|
||||
</td>
|
||||
<td className="py-2 pe-3 text-default">{signer.role_title ?? "—"}</td>
|
||||
<td className="py-2">
|
||||
<Badge variant={SIGNER_STATUS_VARIANT[signer.status]}>{SIGNER_STATUS_LABEL[signer.status]}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AuditTrailCard events={auditEvents} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<dt className="text-default">{label}</dt>
|
||||
<dd className="text-right font-semibold text-title">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
envelopeId,
|
||||
document,
|
||||
accessToken,
|
||||
}: {
|
||||
envelopeId: string;
|
||||
document: EnvelopeWithDetails["documents"][number];
|
||||
accessToken: string | null;
|
||||
}) {
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleOpen() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setOpening(true);
|
||||
try {
|
||||
await openDocumentPdf(envelopeId, document.id, accessToken);
|
||||
} catch {
|
||||
setError("Não foi possível abrir o PDF.");
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border-color last:border-0">
|
||||
<td className="py-2 pe-3 font-semibold text-title">{document.title}</td>
|
||||
<td className="py-2 pe-3 text-default">v{document.document_version}</td>
|
||||
<td className="py-2 pe-3 text-default">{document.page_count ?? "—"}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button type="button" variant="secondary" size="sm" loading={opening} onClick={handleOpen}>
|
||||
Baixar PDF
|
||||
</Button>
|
||||
{error ? <p className="mt-1 text-xs text-danger">{error}</p> : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvelopeActionsCard({
|
||||
envelope,
|
||||
accessToken,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onChanged,
|
||||
}: {
|
||||
envelope: EnvelopeWithDetails;
|
||||
accessToken: string | null;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [targets, setTargets] = useState<SendInviteTargets>("both");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sealing, setSealing] = useState(false);
|
||||
const [finalizing, setFinalizing] = useState(false);
|
||||
const [downloadingFinal, setDownloadingFinal] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState<VerifyAuditResult | null>(null);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const canSendInvite = canEdit && !SEND_INVITE_BLOCKED_STATUSES.has(envelope.status);
|
||||
const canCancel = canEdit && !CANCEL_BLOCKED_STATUSES.has(envelope.status);
|
||||
const canDeleteEnvelope = canDelete && !DELETE_BLOCKED_STATUSES.has(envelope.status);
|
||||
const canSeal = canEdit && envelope.status === "SIGNED";
|
||||
const canFinalize = canEdit && envelope.status === "FINALIZING";
|
||||
const canDownloadFinal = envelope.status === "COMPLETED";
|
||||
|
||||
async function handleSend() {
|
||||
if (!accessToken) return;
|
||||
setActionError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendInvite(envelope.id, targets, accessToken);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "Não foi possível enviar o convite.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelConfirm() {
|
||||
if (!accessToken) return;
|
||||
setActionError(null);
|
||||
setCancelling(true);
|
||||
try {
|
||||
await cancelEnvelope(envelope.id, cancelReason || undefined, accessToken);
|
||||
setConfirmCancel(false);
|
||||
setCancelReason("");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "Não foi possível cancelar o envelope.");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!accessToken) return;
|
||||
setActionError(null);
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteEnvelope(envelope.id, accessToken);
|
||||
window.location.assign("/signatures");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "Não foi possível excluir o envelope.");
|
||||
setDeleting(false);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSeal() {
|
||||
if (!accessToken) return;
|
||||
setActionError(null);
|
||||
setSealing(true);
|
||||
try {
|
||||
await sealCheckpoint(envelope.id, accessToken);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "Não foi possível selar o checkpoint.");
|
||||
} finally {
|
||||
setSealing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFinalize() {
|
||||
if (!accessToken) return;
|
||||
setActionError(null);
|
||||
setFinalizing(true);
|
||||
try {
|
||||
await finalizeEnvelope(envelope.id, accessToken);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "Não foi possível finalizar o envelope.");
|
||||
} finally {
|
||||
setFinalizing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadFinal() {
|
||||
if (!accessToken) return;
|
||||
setActionError(null);
|
||||
setDownloadingFinal(true);
|
||||
try {
|
||||
await downloadFinalPdf(envelope.id, envelope.envelope_number, accessToken);
|
||||
} catch {
|
||||
setActionError("Não foi possível baixar o PDF final.");
|
||||
} finally {
|
||||
setDownloadingFinal(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
if (!accessToken) return;
|
||||
setActionError(null);
|
||||
setVerifying(true);
|
||||
setVerifyResult(null);
|
||||
try {
|
||||
const result = await verifyAudit(envelope.id, accessToken);
|
||||
setVerifyResult(result);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "Não foi possível verificar a integridade.");
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnyAction =
|
||||
canSendInvite || canCancel || canDeleteEnvelope || canSeal || canFinalize || canDownloadFinal;
|
||||
|
||||
return (
|
||||
<Card title="Ações">
|
||||
{actionError ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{actionError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!hasAnyAction ? <p className="text-sm text-default">Nenhuma ação disponível para este status.</p> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
{canSendInvite ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase text-default">Enviar convite</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Select
|
||||
options={[
|
||||
{ value: "both", label: "Ambas as partes" },
|
||||
{ value: "contratada", label: "Só contratada" },
|
||||
{ value: "contratante", label: "Só contratante" },
|
||||
]}
|
||||
value={targets}
|
||||
onChange={(e) => setTargets(e.target.value as SendInviteTargets)}
|
||||
aria-label="Destinatários do convite"
|
||||
/>
|
||||
<Button type="button" loading={sending} onClick={handleSend}>
|
||||
Enviar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-default">Reenvia apenas para quem ainda não assinou.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canSeal ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase text-default">Finalização — passo 1</p>
|
||||
<Button type="button" loading={sealing} onClick={handleSeal}>
|
||||
Selar checkpoint
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canFinalize ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase text-default">Finalização — passo 2</p>
|
||||
<Button type="button" loading={finalizing} onClick={handleFinalize}>
|
||||
Finalizar
|
||||
</Button>
|
||||
<p className="mt-1 text-xs text-default">
|
||||
Pode levar alguns segundos — renderiza o certificado e consolida o PDF final no servidor.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canDownloadFinal ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase text-default">PDF final</p>
|
||||
<Button type="button" variant="secondary" loading={downloadingFinal} onClick={handleDownloadFinal}>
|
||||
Baixar PDF final
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase text-default">Auditoria</p>
|
||||
<Button type="button" variant="secondary" loading={verifying} onClick={handleVerify}>
|
||||
Verificar integridade da cadeia
|
||||
</Button>
|
||||
{verifyResult ? (
|
||||
<Alert variant={verifyResult.valid ? "success" : "error"} className="mt-2">
|
||||
{verifyResult.valid
|
||||
? `Cadeia íntegra — ${verifyResult.eventsChecked} evento(s) verificado(s).`
|
||||
: `Integridade comprometida (${verifyResult.eventsChecked} evento(s) verificados antes da falha) — ${verifyResult.reason ?? "motivo não informado"}.`}
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canCancel ? (
|
||||
<div className="border-t border-border-color pt-4">
|
||||
{!confirmCancel ? (
|
||||
<Button type="button" variant="danger" size="sm" onClick={() => setConfirmCancel(true)}>
|
||||
Cancelar envelope
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-title">Confirma o cancelamento deste envelope?</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Motivo (opcional)"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
className="w-full rounded-md border border-border-color px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="danger" size="sm" loading={cancelling} onClick={handleCancelConfirm}>
|
||||
Confirmar cancelamento
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setConfirmCancel(false)}>
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canDeleteEnvelope ? (
|
||||
<div className="border-t border-border-color pt-4">
|
||||
{!confirmDelete ? (
|
||||
<Button type="button" variant="danger" size="sm" onClick={() => setConfirmDelete(true)}>
|
||||
Excluir envelope
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-title">
|
||||
Excluir permanentemente este envelope? Esta ação não pode ser desfeita.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="danger" size="sm" loading={deleting} onClick={handleDeleteConfirm}>
|
||||
Confirmar exclusão
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setConfirmDelete(false)}>
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditTrailCard({ events }: { events: SignatureAuditEvent[] }) {
|
||||
const sorted = useMemo(() => events.slice().sort((a, b) => Number(a.seq) - Number(b.seq)), [events]);
|
||||
|
||||
return (
|
||||
<Card title="Linha do tempo de auditoria">
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-sm text-default">Nenhum evento registrado ainda.</p>
|
||||
) : (
|
||||
<ol className="max-h-[32rem] space-y-4 overflow-y-auto">
|
||||
{sorted.map((event) => (
|
||||
<li key={event.id} className="relative border-s-2 border-border-color ps-4">
|
||||
<span className="absolute -start-[5px] top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||
<p className="text-sm font-semibold text-title">{auditEventLabel(event.event_type)}</p>
|
||||
<p className="text-xs text-default">
|
||||
{formatDateTimeBR(event.timestamp_utc)} · {actorTypeLabel(event.actor_type)}
|
||||
{event.actor_role ? ` (${event.actor_role})` : ""}
|
||||
{event.ip_address ? ` · ${event.ip_address}` : ""}
|
||||
</p>
|
||||
{Object.keys(event.metadata_json ?? {}).length > 0 ? (
|
||||
<p className="mt-1 font-mono text-xs text-default">{JSON.stringify(event.metadata_json)}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
398
apps/core-web/src/routes/signatures/EnvelopeFormPage.tsx
Normal file
398
apps/core-web/src/routes/signatures/EnvelopeFormPage.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { createEnvelope } from "../../lib/api/signatures.js";
|
||||
import type { PersonType, SignerInput } from "../../lib/api/signatures.js";
|
||||
import { listCompanies } from "../../lib/api/companies.js";
|
||||
import type { Company } from "../../lib/api/companies.js";
|
||||
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
|
||||
import type { ClientRegistration } from "../../lib/api/client-registrations.js";
|
||||
import { listResellers } from "../../lib/api/resellers.js";
|
||||
import type { Reseller } from "../../lib/api/resellers.js";
|
||||
import { listDocumentTemplates, listGenerations } from "../../lib/api/documents.js";
|
||||
import type { DocumentGeneration, DocumentTemplate } from "../../lib/api/documents.js";
|
||||
import { formatDateTimeBR } from "../../lib/format.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
|
||||
type OriginType = "cliente" | "revenda";
|
||||
|
||||
interface SignerRow {
|
||||
fullName: string;
|
||||
personType: PersonType | "";
|
||||
cpf: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
cnpj: string;
|
||||
roleTitle: string;
|
||||
}
|
||||
|
||||
function emptySignerRow(): SignerRow {
|
||||
return { fullName: "", personType: "", cpf: "", email: "", phone: "", companyName: "", cnpj: "", roleTitle: "" };
|
||||
}
|
||||
|
||||
function clientRegistrationName(registration: ClientRegistration): string {
|
||||
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
|
||||
return name ?? `Cadastro #${registration.client_code}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation form for `POST /signature-envelopes`. The `contratada` signer
|
||||
* is never asked here — the API auto-fills it from
|
||||
* `companies.legal_rep_*` and 400s with a clear message if the selected
|
||||
* company has none, which this form surfaces with a direct link to fix
|
||||
* the company record (eden.md §5.3/§5.4). Only `documentGenerationIds`
|
||||
* with `status: 'final'` are offered, mirroring the API's own validation
|
||||
* for a better UX (picking a non-final generation would 400 server-side
|
||||
* anyway).
|
||||
*/
|
||||
export function EnvelopeFormPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [companies, setCompanies] = useState<Company[] | null>(null);
|
||||
const [clientRegistrations, setClientRegistrations] = useState<ClientRegistration[]>([]);
|
||||
const [resellers, setResellers] = useState<Reseller[]>([]);
|
||||
const [generations, setGenerations] = useState<DocumentGeneration[]>([]);
|
||||
const [templates, setTemplates] = useState<DocumentTemplate[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [companyId, setCompanyId] = useState("");
|
||||
const [originType, setOriginType] = useState<OriginType>("cliente");
|
||||
const [clientRegistrationId, setClientRegistrationId] = useState("");
|
||||
const [resellerId, setResellerId] = useState("");
|
||||
const [selectedGenerationIds, setSelectedGenerationIds] = useState<string[]>([]);
|
||||
const [signers, setSigners] = useState<SignerRow[]>([emptySignerRow()]);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
setLoadError(null);
|
||||
Promise.all([
|
||||
listCompanies(accessToken),
|
||||
listClientRegistrations(accessToken),
|
||||
listResellers(accessToken),
|
||||
listGenerations(accessToken),
|
||||
listDocumentTemplates(accessToken),
|
||||
])
|
||||
.then(([companyList, registrations, resellerList, generationList, templateList]) => {
|
||||
setCompanies(companyList);
|
||||
setClientRegistrations(registrations);
|
||||
setResellers(resellerList);
|
||||
setGenerations(generationList);
|
||||
setTemplates(templateList);
|
||||
})
|
||||
.catch((err) =>
|
||||
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar os dados do formulário."),
|
||||
);
|
||||
}, [accessToken]);
|
||||
|
||||
const templateById = useMemo(() => new Map(templates.map((t) => [t.id, t])), [templates]);
|
||||
|
||||
const finalGenerations = useMemo(
|
||||
() => generations.filter((g) => g.status === "final" && g.pdf_s3_key),
|
||||
[generations],
|
||||
);
|
||||
|
||||
function toggleGeneration(id: string) {
|
||||
setSelectedGenerationIds((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
function updateSigner(index: number, patch: Partial<SignerRow>) {
|
||||
setSigners((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||
}
|
||||
|
||||
function addSigner() {
|
||||
setSigners((prev) => [...prev, emptySignerRow()]);
|
||||
}
|
||||
|
||||
function removeSigner(index: number) {
|
||||
setSigners((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
|
||||
if (!companyId) {
|
||||
setError("Selecione a empresa emissora (contratada).");
|
||||
return;
|
||||
}
|
||||
if (originType === "cliente" && !clientRegistrationId) {
|
||||
setError("Selecione o cadastro de cliente de origem.");
|
||||
return;
|
||||
}
|
||||
if (originType === "revenda" && !resellerId) {
|
||||
setError("Selecione a revenda de origem.");
|
||||
return;
|
||||
}
|
||||
if (selectedGenerationIds.length === 0) {
|
||||
setError("Selecione ao menos um documento gerado para incluir no envelope.");
|
||||
return;
|
||||
}
|
||||
const validSigners: SignerInput[] = signers
|
||||
.filter((row) => row.fullName.trim() && row.email.trim())
|
||||
.map((row) => ({
|
||||
fullName: row.fullName.trim(),
|
||||
email: row.email.trim(),
|
||||
personType: row.personType || undefined,
|
||||
cpf: row.cpf || undefined,
|
||||
phone: row.phone || undefined,
|
||||
companyName: row.companyName || undefined,
|
||||
cnpj: row.cnpj || undefined,
|
||||
roleTitle: row.roleTitle || undefined,
|
||||
}));
|
||||
if (validSigners.length === 0) {
|
||||
setError("Adicione ao menos um signatário (contratante) com nome e e-mail.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const envelope = await createEnvelope(
|
||||
{
|
||||
companyId,
|
||||
clientRegistrationId: originType === "cliente" ? clientRegistrationId : undefined,
|
||||
resellerId: originType === "revenda" ? resellerId : undefined,
|
||||
documentGenerationIds: selectedGenerationIds,
|
||||
signers: validSigners,
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
navigate(`/signatures/${envelope.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível criar o envelope de assinatura.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const showLegalRepHint = error?.toLowerCase().includes("representante legal") ?? false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Assinaturas", href: "/signatures" }, { label: "Novo envelope" }]} />
|
||||
<PageHeader title="Novo envelope de assinatura" description="Assinatura eletrônica de documentos" />
|
||||
|
||||
{loadError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{loadError}
|
||||
</Alert>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4" title={showLegalRepHint ? "Empresa sem representante legal" : undefined}>
|
||||
{error}
|
||||
{showLegalRepHint && companyId ? (
|
||||
<p className="mt-2">
|
||||
<Link to={`/companies/${companyId}/edit`} className="font-semibold underline">
|
||||
Cadastrar representante legal desta empresa
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{!companies ? (
|
||||
<p className="text-sm text-default">Carregando empresas, clientes, revendas e documentos…</p>
|
||||
) : (
|
||||
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Empresa emissora (contratada)</h2>
|
||||
<Select
|
||||
label="Empresa"
|
||||
options={companies.map((c) => ({ value: c.id, label: c.company_name }))}
|
||||
placeholder="Selecione a empresa"
|
||||
value={companyId}
|
||||
onChange={(e) => setCompanyId(e.target.value)}
|
||||
hint="O signatário contratada é preenchido automaticamente a partir do representante legal desta empresa."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Origem</h2>
|
||||
<div className="mb-3 flex items-center gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="originType"
|
||||
checked={originType === "cliente"}
|
||||
onChange={() => setOriginType("cliente")}
|
||||
/>
|
||||
Cadastro de cliente
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="originType"
|
||||
checked={originType === "revenda"}
|
||||
onChange={() => setOriginType("revenda")}
|
||||
/>
|
||||
Revenda
|
||||
</label>
|
||||
</div>
|
||||
{originType === "cliente" ? (
|
||||
<Select
|
||||
label="Cadastro de cliente"
|
||||
options={clientRegistrations.map((r) => ({ value: r.id, label: clientRegistrationName(r) }))}
|
||||
placeholder="Selecione o cliente"
|
||||
value={clientRegistrationId}
|
||||
onChange={(e) => setClientRegistrationId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="Revenda"
|
||||
options={resellers.map((r) => ({ value: r.id, label: r.name }))}
|
||||
placeholder="Selecione a revenda"
|
||||
value={resellerId}
|
||||
onChange={(e) => setResellerId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Documentos</h2>
|
||||
{finalGenerations.length === 0 ? (
|
||||
<p className="text-sm text-default">
|
||||
Nenhum documento gerado com PDF pronto ainda.{" "}
|
||||
<Link to="/documents/generations/new" className="font-semibold text-primary underline">
|
||||
Gerar um documento
|
||||
</Link>{" "}
|
||||
antes de criar o envelope.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border-color p-3">
|
||||
{finalGenerations.map((generation) => {
|
||||
const template = templateById.get(generation.template_id);
|
||||
return (
|
||||
<label
|
||||
key={generation.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border-color p-2 text-sm hover:bg-light"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGenerationIds.includes(generation.id)}
|
||||
onChange={() => toggleGeneration(generation.id)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold text-title">{template?.name ?? generation.template_id}</span>
|
||||
<span className="ml-2 text-xs text-default">
|
||||
gerado em {formatDateTimeBR(generation.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-title">Signatários (contratante)</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSigner}
|
||||
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
|
||||
>
|
||||
+ Adicionar signatário
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-default">
|
||||
O signatário contratada não é informado aqui — ele é preenchido automaticamente pelo servidor.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{signers.map((row, index) => (
|
||||
<div key={index} className="rounded-md border border-border-color p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-default">Contratante {index + 1}</span>
|
||||
{signers.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSigner(index)}
|
||||
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light"
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Input
|
||||
label="Nome completo / razão social"
|
||||
value={row.fullName}
|
||||
onChange={(e) => updateSigner(index, { fullName: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Tipo de pessoa (opcional)"
|
||||
options={[
|
||||
{ value: "pf", label: "Pessoa física" },
|
||||
{ value: "pj", label: "Pessoa jurídica" },
|
||||
]}
|
||||
placeholder="Selecione"
|
||||
value={row.personType}
|
||||
onChange={(e) => updateSigner(index, { personType: e.target.value as PersonType | "" })}
|
||||
/>
|
||||
<Input
|
||||
label="CPF (opcional)"
|
||||
value={row.cpf}
|
||||
onChange={(e) => updateSigner(index, { cpf: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
value={row.email}
|
||||
onChange={(e) => updateSigner(index, { email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Telefone (opcional)"
|
||||
value={row.phone}
|
||||
onChange={(e) => updateSigner(index, { phone: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Cargo (opcional)"
|
||||
value={row.roleTitle}
|
||||
onChange={(e) => updateSigner(index, { roleTitle: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Empresa (opcional, se PJ)"
|
||||
value={row.companyName}
|
||||
onChange={(e) => updateSigner(index, { companyName: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="CNPJ (opcional, se PJ)"
|
||||
value={row.cnpj}
|
||||
onChange={(e) => updateSigner(index, { cnpj: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border-color pt-5">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Criar envelope
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/signatures")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
apps/core-web/src/routes/signatures/EnvelopesListPage.tsx
Normal file
167
apps/core-web/src/routes/signatures/EnvelopesListPage.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Card, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { hasPermission } from "../../lib/auth/permissions.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { getEnvelope, listEnvelopes } from "../../lib/api/signatures.js";
|
||||
import type { EnvelopeStatus, EnvelopeWithDetails } from "../../lib/api/signatures.js";
|
||||
import { listCompanies } from "../../lib/api/companies.js";
|
||||
import type { Company } from "../../lib/api/companies.js";
|
||||
import { formatDateBR } from "../../lib/format.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
import {
|
||||
ENVELOPE_STATUS_LABEL,
|
||||
ENVELOPE_STATUS_VARIANT,
|
||||
SIGNER_STATUS_LABEL,
|
||||
SIGNER_STATUS_VARIANT,
|
||||
} from "./signatureStatus.js";
|
||||
|
||||
/**
|
||||
* Layout matches `ContractsListPage`/`TicketsListPage` (bordered white
|
||||
* card, flush table, colored status pills). `GET /signature-envelopes`
|
||||
* only returns bare envelope rows (no signers — see
|
||||
* `packages/database/src/repositories/signatures.ts`'s `listEnvelopes`),
|
||||
* so — same pattern as `TicketsListPage` enriching each row with a
|
||||
* `GET /support/tickets/:id` call for the SLA timer — this fetches
|
||||
* `getEnvelope` per row afterwards to show per-signer status here. Fine
|
||||
* at the expected volume of e-signature envelopes for a backoffice; would
|
||||
* need a real join if that ever became a bottleneck.
|
||||
*/
|
||||
export function EnvelopesListPage() {
|
||||
const { accessToken, permissions } = useAuth();
|
||||
const [envelopes, setEnvelopes] = useState<EnvelopeWithDetails[] | null>(null);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<"" | EnvelopeStatus>("");
|
||||
|
||||
const canCreate = hasPermission(permissions, "signatures", "edit");
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
listEnvelopes(accessToken)
|
||||
.then(async (bare) => {
|
||||
const detailed = await Promise.all(bare.map((e) => getEnvelope(e.id, accessToken)));
|
||||
if (cancelled) return;
|
||||
setEnvelopes(detailed);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar os envelopes.");
|
||||
});
|
||||
listCompanies(accessToken)
|
||||
.then((result) => {
|
||||
if (!cancelled) setCompanies(result);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => load(), [load]);
|
||||
|
||||
const companyById = useMemo(() => new Map(companies.map((c) => [c.id, c])), [companies]);
|
||||
|
||||
const visibleEnvelopes = useMemo(() => {
|
||||
if (!envelopes) return null;
|
||||
if (!statusFilter) return envelopes;
|
||||
return envelopes.filter((e) => e.status === statusFilter);
|
||||
}, [envelopes, statusFilter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Assinaturas"
|
||||
description="Envelopes de assinatura eletrônica"
|
||||
actions={
|
||||
canCreate ? (
|
||||
<Link to="/signatures/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||
Novo envelope
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 max-w-xs">
|
||||
<Select
|
||||
label="Filtrar por status"
|
||||
options={ENVELOPE_STATUSES_OPTIONS}
|
||||
placeholder="Todos os status"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as "" | EnvelopeStatus)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{envelopes === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando envelopes…</p>
|
||||
) : visibleEnvelopes && visibleEnvelopes.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">
|
||||
{statusFilter ? "Nenhum envelope com este status." : "Nenhum envelope de assinatura criado ainda."}
|
||||
</p>
|
||||
) : visibleEnvelopes ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="px-5 py-3 font-semibold text-title">Envelope</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Empresa</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Signatários</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Criado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleEnvelopes.map((envelope) => (
|
||||
<tr key={envelope.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
to={`/signatures/${envelope.id}`}
|
||||
className="font-semibold text-title hover:text-primary"
|
||||
>
|
||||
{envelope.envelope_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">
|
||||
{companyById.get(envelope.company_id)?.company_name ?? envelope.company_id}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={ENVELOPE_STATUS_VARIANT[envelope.status]}>
|
||||
{ENVELOPE_STATUS_LABEL[envelope.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{envelope.signers.map((signer) => (
|
||||
<div key={signer.id} className="flex items-center gap-2">
|
||||
<span className="text-default">{signer.full_name}</span>
|
||||
<Badge variant={SIGNER_STATUS_VARIANT[signer.status]} className="shrink-0">
|
||||
{SIGNER_STATUS_LABEL[signer.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">{formatDateBR(envelope.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ENVELOPE_STATUSES_OPTIONS = (
|
||||
Object.keys(ENVELOPE_STATUS_LABEL) as EnvelopeStatus[]
|
||||
).map((status) => ({ value: status, label: ENVELOPE_STATUS_LABEL[status] }));
|
||||
136
apps/core-web/src/routes/signatures/signatureStatus.ts
Normal file
136
apps/core-web/src/routes/signatures/signatureStatus.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import type { EnvelopeStatus, SignerStatus } from "../../lib/api/signatures.js";
|
||||
|
||||
/**
|
||||
* pt-BR labels/badge colors for the envelope's 19-state machine (see the
|
||||
* migration's CHECK constraint + `ALLOWED_TRANSITIONS` in
|
||||
* apps/api/src/signatures/state-machine.ts). Several of these
|
||||
* (VIEWED..READY_TO_SIGN/SIGNING) are legacy-fidelity states the envelope
|
||||
* itself never actually reaches in this leva — per-signer granularity is
|
||||
* tracked on `signature_signers.status` instead, and SENT jumps straight
|
||||
* to SIGNED (see `EnvelopeService`'s doc-comment) — but they're kept here
|
||||
* so the UI never crashes on an unexpected value and stays ready if that
|
||||
* changes.
|
||||
*/
|
||||
export const ENVELOPE_STATUS_LABEL: Record<EnvelopeStatus, string> = {
|
||||
DRAFT: "Rascunho",
|
||||
READY: "Pronto para envio",
|
||||
SENT: "Enviado",
|
||||
VIEWED: "Visualizado",
|
||||
IDENTITY_PENDING: "Identidade pendente",
|
||||
CONSENT_PENDING: "Consentimento pendente",
|
||||
OTP_PENDING: "Código pendente",
|
||||
OTP_SENT: "Código enviado",
|
||||
OTP_VERIFIED: "Código verificado",
|
||||
READY_TO_SIGN: "Pronto para assinar",
|
||||
SIGNING: "Assinando",
|
||||
SIGNED: "Assinado",
|
||||
FINALIZING: "Finalizando",
|
||||
COMPLETED: "Concluído",
|
||||
DECLINED: "Recusado",
|
||||
EXPIRED: "Expirado",
|
||||
CANCELLED: "Cancelado",
|
||||
SUPERSEDED: "Substituído",
|
||||
ERROR: "Erro",
|
||||
};
|
||||
|
||||
export const ENVELOPE_STATUS_VARIANT: Record<EnvelopeStatus, BadgeVariant> = {
|
||||
DRAFT: "neutral",
|
||||
READY: "info",
|
||||
SENT: "info",
|
||||
VIEWED: "info",
|
||||
IDENTITY_PENDING: "warning",
|
||||
CONSENT_PENDING: "warning",
|
||||
OTP_PENDING: "warning",
|
||||
OTP_SENT: "warning",
|
||||
OTP_VERIFIED: "warning",
|
||||
READY_TO_SIGN: "warning",
|
||||
SIGNING: "warning",
|
||||
SIGNED: "success",
|
||||
FINALIZING: "warning",
|
||||
COMPLETED: "success",
|
||||
DECLINED: "danger",
|
||||
EXPIRED: "neutral",
|
||||
CANCELLED: "danger",
|
||||
SUPERSEDED: "neutral",
|
||||
ERROR: "danger",
|
||||
};
|
||||
|
||||
export const SIGNER_STATUS_LABEL: Record<SignerStatus, string> = {
|
||||
PENDING: "Pendente",
|
||||
LINK_SENT: "Convite enviado",
|
||||
VIEWED: "Visualizado",
|
||||
DATA_CONFIRMED: "Dados confirmados",
|
||||
CONSENTED: "Consentimento aceito",
|
||||
OTP_VERIFIED: "Código verificado",
|
||||
SIGNED: "Assinado",
|
||||
DECLINED: "Recusado",
|
||||
};
|
||||
|
||||
export const SIGNER_STATUS_VARIANT: Record<SignerStatus, BadgeVariant> = {
|
||||
PENDING: "neutral",
|
||||
LINK_SENT: "info",
|
||||
VIEWED: "info",
|
||||
DATA_CONFIRMED: "warning",
|
||||
CONSENTED: "warning",
|
||||
OTP_VERIFIED: "warning",
|
||||
SIGNED: "success",
|
||||
DECLINED: "danger",
|
||||
};
|
||||
|
||||
const PARTY_ROLE_LABEL: Record<"contratada" | "contratante", string> = {
|
||||
contratada: "Contratada",
|
||||
contratante: "Contratante",
|
||||
};
|
||||
|
||||
export function partyRoleLabel(party: "contratada" | "contratante"): string {
|
||||
return PARTY_ROLE_LABEL[party] ?? party;
|
||||
}
|
||||
|
||||
/** Best-effort pt-BR label for `signature_audit_events.event_type` — falls back to a humanized version of the raw value for anything not explicitly listed, since the column is free-text (`string`, not a DB enum). */
|
||||
const AUDIT_EVENT_LABEL: Record<string, string> = {
|
||||
ENVELOPE_CREATED: "Envelope criado",
|
||||
DOCUMENT_FROZEN: "Documento congelado",
|
||||
DOCUMENT_HASHED: "Hash do documento calculado",
|
||||
ENVELOPE_READY: "Envelope pronto para envio",
|
||||
INVITE_SENT: "Convite enviado",
|
||||
LINK_OPENED: "Link aberto pelo signatário",
|
||||
DOCUMENT_VIEWED: "Documento visualizado",
|
||||
DOCUMENT_READ_CONFIRMED: "Leitura do documento confirmada",
|
||||
CUSTOMER_DATA_CONFIRMED: "Dados do signatário confirmados",
|
||||
ELECTRONIC_SIGNATURE_TERMS_ACCEPTED: "Termos de assinatura eletrônica aceitos",
|
||||
OTP_REQUESTED: "Código de verificação solicitado",
|
||||
OTP_SENT: "Código de verificação enviado",
|
||||
OTP_FAILED: "Código de verificação incorreto",
|
||||
OTP_BLOCKED: "Código de verificação bloqueado (muitas tentativas)",
|
||||
OTP_EXPIRED: "Código de verificação expirado",
|
||||
OTP_VERIFIED: "Código de verificação validado",
|
||||
SIGN_BUTTON_PRESSED: "Botão de assinar pressionado",
|
||||
SIGNATURE_COMMITTED: "Assinatura registrada",
|
||||
ENVELOPE_SIGNED: "Envelope assinado por todas as partes",
|
||||
EVIDENCE_CHECKPOINT_SEALED: "Checkpoint de evidências selado",
|
||||
ENVELOPE_HASH_CALCULATED: "Hash do envelope calculado",
|
||||
CERTIFICATE_GENERATED: "Certificado de conclusão gerado",
|
||||
CONSOLIDATED_PDF_GENERATED: "PDF final consolidado",
|
||||
FINAL_PDF_HASH_CALCULATED: "Hash do PDF final calculado",
|
||||
ENVELOPE_COMPLETED: "Envelope concluído",
|
||||
FINAL_DOCUMENT_EMAILED: "PDF final enviado por e-mail",
|
||||
ENVELOPE_CANCELLED: "Envelope cancelado",
|
||||
};
|
||||
|
||||
export function auditEventLabel(eventType: string): string {
|
||||
return AUDIT_EVENT_LABEL[eventType] ?? eventType.replaceAll("_", " ").toLowerCase();
|
||||
}
|
||||
|
||||
const ACTOR_TYPE_LABEL: Record<string, string> = {
|
||||
SIGNER: "Signatário",
|
||||
BACKOFFICE: "Backoffice",
|
||||
ADMIN: "Administrador",
|
||||
SUPER_ADMIN: "Super admin",
|
||||
SYSTEM: "Sistema",
|
||||
SERVICE: "Serviço",
|
||||
};
|
||||
|
||||
export function actorTypeLabel(actorType: string): string {
|
||||
return ACTOR_TYPE_LABEL[actorType] ?? actorType;
|
||||
}
|
||||
@@ -569,3 +569,51 @@ Suíte de testes nova e grande: `signature-hash-chain.spec.ts` (15 testes, puros
|
||||
|
||||
### Pendências
|
||||
Rate limiting nas rotas públicas (por IP e por token, eden.md §6.8/§10.3) — lacuna conhecida, mesmo padrão de outros endpoints públicos deste projeto que também não têm ainda (ex. forgot-password). UI de frontend para todo o módulo (criação/envio/acompanhamento de envelope no backoffice, tela pública de assinatura do signatário, página pública de verificação) — não construída nesta leva, só o backend. `DECLINED` (recusa de assinatura) é um estado reservado no schema mas sem rota que o produza — mesma lacuna do legado, nunca foi implementado lá também.
|
||||
|
||||
---
|
||||
|
||||
## Rate limiting em todas as rotas públicas — lacuna fechada (eden-security)
|
||||
|
||||
Data: 2026-09-17. Essa lacuna estava registrada em pelo menos três lugares diferentes (`docs/security/threat-model.md` §2.1/§2.2, e as pendências de MFA/forgot-password/assinatura acima): nenhuma rota sem sessão tinha qualquer limite de requisições. Escopo: `POST /v1/auth/{login,mfa/challenge,forgot-password,reset-password,refresh,logout}` (`logout` não estava na lista original do pedido, mas também não tem `JwtAuthGuard` — incluído por completude), `GET /v1/public-signature/:token`, `GET /v1/public-signature/:token/documents/:documentId/pdf`, `POST /v1/public-signature/:token/{documents/:documentId/confirm-read,confirm-data,consent,otp/request,otp/verify,sign}` e `GET /v1/public-verify/:verificationId`.
|
||||
|
||||
### Decisão: guard/decorator próprio, não `@nestjs/throttler`
|
||||
|
||||
Sem Redis no ambiente (`compose.yaml`: "eden-redis (quando houver job real)" — ainda não existe) e API roda como instância única (ADR-0002), então em memória é a escolha certa nos dois casos — `@nestjs/throttler` teria servido igual de bem nesse ponto. A razão real de ter escrito um guard próprio (`apps/api/src/common/rate-limit/`) foi precisar de **chaves compostas por rota**: `public-signature/:token/*` precisa de um balde por IP (pega uma fonte varrendo/testando muitos tokens diferentes) *e* um balde por token (pega um link vazado sendo atacado por qualquer número de IPs — é exatamente o caso que `@nestjs/throttler` não cobre bem sem sobrepor múltiplos throttlers nomeados com trackers customizados por controller, o que na prática vira quase tanto código próprio quanto escrever o guard direto). `RateLimiterService` é um Map com janela fixa (não sliding window/token bucket — trade-off deliberado de simplicidade, documentado no próprio arquivo), `RateLimitGuard` lê metadata de `@RateLimit(...rules)` e aplica todas; sem decorator no handler, o guard é um no-op (por isso pôde ser plugado a nível de controller sem afetar rotas autenticadas do mesmo controller, ex. `mfa/enroll`).
|
||||
|
||||
### Limites
|
||||
|
||||
Todos em `apps/api/src/common/rate-limit/rate-limit-rules.ts`, cada um com a razão numa linha de comentário — resumo: `login`/`mfa/challenge`/`forgot-password`/`reset-password` a 10/min por IP (alvo de adivinhação de credencial — argon2/token de 256 bits já encarece o palpite, isso limita a *taxa*), `refresh`/`logout` a 30/min por IP (token de 256 bits, não é alvo de força bruta — limite mais alto porque sessão ativa com múltiplas abas atualiza sozinha com frequência), `public-signature/*` com um balde de IP compartilhado do controller inteiro (60/min) somado a um balde por token específico da ação (20/5min nas ações comuns, 5/15min em `otp/request` porque dispara e-mail de verdade, 15/5min em `otp/verify` — complementar ao lockout por tentativas erradas que já existe em `public-signature.service.ts::verifyOtp`, persistido no banco, esse sim já sobrevive a troca de IP), `public-verify` a 60/min por IP e 30/min por `verificationId`.
|
||||
|
||||
### O que NÃO foi resolvido (residual, registrado em `docs/security/threat-model.md`)
|
||||
|
||||
Um atacante trocando de IP lentamente (abaixo do limite por IP, em baixo volume, por muito tempo) não é pego por isto — não existe lockout de conta a nível de aplicação para login hoje, só o rate limit por IP. E `app.set('trust proxy', ...)` nunca foi configurado nesta API; se algum dia um reverse proxy/LB externo ficar na frente do `eden-api` em produção, `req.ip` vai reportar o IP do proxy para todo mundo e essa mitigação (e todo log de IP do projeto) fica cega — não configurei isso adivinhando a topologia futura, porque confiar em `X-Forwarded-For` sem saber exatamente quantos hops confiáveis existem abre uma forma de o próprio cliente spoofar seu IP.
|
||||
|
||||
### Verificação
|
||||
`apps/api/test/rate-limit.spec.ts`: metade pura (sem banco/HTTP, no estilo de `signature-hash-chain.spec.ts`) provando que uma chave estoura exatamente no limite configurado, que chaves diferentes (duas IPs, ou dois tokens de assinatura) nunca competem pelo mesmo balde, e que a janela reseta (`vi.useFakeTimers`); metade HTTP real (app real, Postgres real) provando estouro de verdade em `POST /v1/auth/login` (11ª tentativa em 1 minuto vira 429 com `Retry-After`, não mais 401) e que esgotar o balde do login não afeta o balde de `forgot-password` na mesma requisição/IP. Suíte completa: 77/77 verdes (70→77). `pnpm turbo run typecheck build` 28/28. Testado ao vivo contra o container real `eden-api` (rebuild + restart): 10× 401 seguidos de 429 com corpo `{"statusCode":429,"message":"Muitas requisições. Tente novamente mais tarde.","retryAfterSeconds":60}` e header `Retry-After: 60`, log estruturado `WARN [RateLimitGuard] rate limit exceeded: rule=auth-login ...` no `docker logs eden-api`, e confirmado que `forgot-password` seguiu respondendo 200 no mesmo IP enquanto `login` estava bloqueado.
|
||||
|
||||
---
|
||||
|
||||
## UI de Assinatura Eletrônica (backoffice + páginas públicas) — segunda pendência do módulo fechada
|
||||
|
||||
Data: 2026-09-17. Continuação direta, sem parar para autorização — retomando as duas pendências deixadas pela leva anterior (rate limiting, ver entrada acima, e a UI completa do módulo). Antes de delegar o frontend, um gap real de backend foi encontrado e corrigido diretamente (não delegado, por ser rota nova/sensível): `PublicSignatureService.getPublicState` só devolvia metadados dos documentos (título, versão, contagem de páginas) — não existia NENHUMA forma de o signatário efetivamente ver o conteúdo do PDF antes de "confirmar leitura", nem do backoffice baixar o documento congelado ou o PDF final consolidado. Endpoints novos, mesmo padrão de streaming já usado em `documents.controller.ts` (`StreamableFile`, nunca URL assinada — eden.md linha ~4442):
|
||||
- `GET /v1/signature-envelopes/:id/documents/:documentId/pdf` e `GET /v1/signature-envelopes/:id/final-pdf` (autenticados, escopo por revenda como o resto do módulo).
|
||||
- `GET /v1/public-signature/:token/documents/:documentId/pdf` (público, autorização inteiramente pelo token — mesma checagem de posse do documento que `confirmDocumentRead` já usava).
|
||||
|
||||
Cobertos em `test/signature-envelope-flow.spec.ts` (72/72 antes da leva de rate limiting subir para 77/77). `pnpm turbo run typecheck build` 28/28.
|
||||
|
||||
### Frontend (agente `eden-frontend`, uma leva só — public+autenticado não foram paralelizados desta vez, para evitar a reconciliação manual de `App.tsx` que as levas anteriores precisaram fazer a cada par de agentes)
|
||||
|
||||
**Backoffice** (`apps/core-web/src/routes/signatures/`): lista de envelopes (enriquecida por chamada individual `GET /:id`, já que a listagem não traz signatários — mesmo idioma já usado em `TicketsListPage` para o SLA), criação (origem cliente XOR revenda, seleção de `document_generations` já `final`, form-array de signatários contratante — contratada nunca é pedida no formulário), detalhe com documentos/signatários/timeline de auditoria e todas as ações de transição de estado (enviar convite, cancelar, excluir, selar checkpoint, finalizar, baixar PDF final, verificar cadeia), gates de UI espelhando `ENVELOPE_TRANSITIONS` do repositório.
|
||||
|
||||
**Público** (`apps/core-web/src/routes/`, fora do `AuthenticatedLayout`, mesmo padrão de `/login`/`/forgot-password`): `PublicSignaturePage.tsx` em `/assinar/:token` (path literal que o backend já usa no e-mail de convite) — wizard guiado inteiramente por `signer.status`, sempre re-buscando o estado a cada ação para permitir retomar de onde parou se o signatário fechar o navegador no meio; `PublicVerifyPage.tsx` em `/verificar/:verificationId` (idem, path literal do e-mail de conclusão).
|
||||
|
||||
**Achado de infraestrutura do próprio agente**: nenhuma tela existente no projeto tinha um padrão pronto de "esconder ação por permissão" (grep não encontrou nenhum uso de `permissions` em rotas) — o agente adicionou `permissions` ao `AuthContext` (buscado de `/auth/me`) e um `hasPermission` mínimo, usado só para UX (esconder/desabilitar botão), nunca como controle de acesso — a decisão real continua inteiramente no backend.
|
||||
|
||||
**Divergências reais encontradas contra o brief, corrigidas antes de entregar**: `GET /signature-envelopes` não inclui signatários/documentos (resolvido enriquecendo cada linha); `verify-audit` pode retornar `failedAt`/`reason` além de `{valid, eventsChecked}` quando a cadeia está corrompida (UI trata); o contador de tentativas do OTP não é exposto pela API (UI usa mensagem genérica); `sendInvite` não valida o status do envelope no backend (UI esconde o botão em estados terminais por UX, sem depender disso para segurança).
|
||||
|
||||
### Verificação
|
||||
|
||||
`pnpm turbo run typecheck build` 28/28. Teste ao vivo de ponta a ponta contra `eden-api`/`eden-postgres`/`eden-minio` reais (o agente subiu um mail-catcher SMTP efêmero na rede docker para extrair tokens/OTP reais dos e-mails, revertido ao final — `.env` original confirmado intacto via `docker inspect`): empresa+representante legal → revenda → template→geração final → criar envelope → enviar convite → os dois signatários completando o fluxo público inteiro (ver PDF do documento, confirmar leitura, dados, consentimento, OTP errado então certo, assinar) → `SENT`→`SIGNED` automático no último signatário → seal-checkpoint → finalize (Chromium + merge de PDF reais) → `COMPLETED` → download do PDF final autenticado confirmado como PDF válido → `verify-audit` válido (38 eventos) → `public-verify` sem auth retornando o formato esperado com `integrityValid: true`. Depois containers `eden-api`/`eden-core` reconstruídos com o estado final combinado (endpoints de streaming + rate limiting + UI) e testados ao vivo: `/signatures`, `/signatures/new`, `/assinar/:token`, `/verificar/:verificationId` respondendo 200 via proxy same-origin (porta 3001), e o 11º login errado seguido confirmando 429 (rate limiting da leva anterior intacto após o rebuild combinado).
|
||||
|
||||
### Pendências
|
||||
`DECLINED` (recusa de assinatura) segue sem rota que o produza — reservado no schema, mesma lacuna do legado. `contract_versions`/`contract_amendments` (ADR-0006, adiados desde a leva de trava de edição de contrato) agora estão desbloqueados — o fluxo de assinatura já existe — mas ainda não iniciados. `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela própria, incluindo nada do módulo de assinatura (fora de escopo desta leva).
|
||||
|
||||
@@ -21,6 +21,10 @@ Existem por necessidade de negócio (cadastro de cliente/revenda, assinatura ele
|
||||
- Revalidação total de regra de negócio no backend — o cliente é sempre não confiável.
|
||||
- 404/409 sem vazar existência de recurso quando aplicável (mensagens genéricas para "não existe" vs. "já usado").
|
||||
|
||||
**Implementado (2026-09-17):** todas as rotas públicas hoje existentes têm rate limiting — `apps/api/src/common/rate-limit/` (`RateLimiterService` em memória, janela fixa; `RateLimitGuard` + `@RateLimit(...)` por rota). `POST /v1/auth/{login,mfa/challenge,forgot-password,reset-password,refresh,logout}` por IP; `GET/POST /v1/public-signature/:token/*` por IP (balde compartilhado do controller, pega scraping de vários tokens de uma fonte) **e** por token (pega força bruta de um link vazado vindo de qualquer IP); `GET /v1/public-verify/:verificationId` por IP e por id. Limites concretos e a razão de cada um em `apps/api/src/common/rate-limit/rate-limit-rules.ts`. Sem Redis (ainda não existe, ver compose.yaml) — armazenamento local ao processo, correto para a instância única atual (ADR-0002); não sobrevive a múltiplas réplicas do `eden-api`, revisar quando isso mudar. Testes: `apps/api/test/rate-limit.spec.ts`.
|
||||
|
||||
**Risco residual aceito, não coberto por este trabalho:** (1) um atacante que gire IPs lentamente ainda opera abaixo do limite por IP indefinidamente — não há lockout por conta a nível de aplicação além do que já existe no OTP de assinatura; (2) `app.set('trust proxy', ...)` nunca foi configurado — se um reverse proxy/LB externo vier a ficar na frente do `eden-api` em produção, `req.ip` (chave do rate limit e de todo log de IP) passará a ver sempre o IP do proxy, não o do cliente real, cegando esta mitigação — configurar isso corretamente (e só isso, um hop confiável) é pré-requisito de qualquer topologia com proxy na frente da API.
|
||||
|
||||
### 2.2 Autenticação e sessão
|
||||
- Herdar do legado: `token_version`-like mecanismo de invalidação global, MAS evoluir para refresh token rotativo hashado (ADR-0003) — não repetir JWT de 30 dias sem revogação.
|
||||
- Argon2id (não bcrypt) para novo hash de senha — custo a definir em ADR de segurança/config, revisável sem quebrar hashes existentes (versionamento de parâmetro).
|
||||
|
||||
Reference in New Issue
Block a user