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

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

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

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

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

View File

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

View File

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