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:
@@ -7,7 +7,7 @@ import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
} from "../api/auth.js";
|
||||
import type { PublicUser } from "../api/auth.js";
|
||||
import type { PermissionGrant, PublicUser } from "../api/auth.js";
|
||||
import { clearSession, readSession, writeSession } from "./session.js";
|
||||
|
||||
export type AuthStatus = "loading" | "authenticated" | "anonymous";
|
||||
@@ -22,6 +22,8 @@ interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: PublicUser | null;
|
||||
accessToken: string | null;
|
||||
/** `null` while still loading; `"ALL"` for super-admin-style grants — see `hasPermission` in `./permissions.js`. Never used for actual access control (that's server-side only), only to hide/disable actions the user can't perform. */
|
||||
permissions: "ALL" | PermissionGrant[] | null;
|
||||
/** Resolves to `{ mfaRequired: true, mfaChallenge }` when a TOTP code is needed next, otherwise logs in fully. */
|
||||
login: (email: string, password: string) => Promise<MfaPending | { mfaRequired: false }>;
|
||||
submitMfa: (mfaChallenge: string, code: string) => Promise<void>;
|
||||
@@ -35,6 +37,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||
const [permissions, setPermissions] = useState<"ALL" | PermissionGrant[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readSession();
|
||||
@@ -51,6 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(me.user);
|
||||
setAccessToken(stored.accessToken);
|
||||
setRefreshToken(stored.refreshToken);
|
||||
setPermissions(me.permissions);
|
||||
setStatus("authenticated");
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -69,6 +73,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setAccessToken(result.accessToken);
|
||||
setRefreshToken(result.refreshToken);
|
||||
setStatus("authenticated");
|
||||
// `/auth/login` doesn't return permission grants (only `/auth/me` does)
|
||||
// — fetch them right away so nav/action visibility is correct from the
|
||||
// first authenticated render, not just after a manual refresh.
|
||||
fetchMe(result.accessToken)
|
||||
.then((me) => setPermissions(me.permissions))
|
||||
.catch(() => setPermissions(null));
|
||||
return { mfaRequired: false as const };
|
||||
}, []);
|
||||
|
||||
@@ -79,6 +89,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setAccessToken(result.accessToken);
|
||||
setRefreshToken(result.refreshToken);
|
||||
setStatus("authenticated");
|
||||
fetchMe(result.accessToken)
|
||||
.then((me) => setPermissions(me.permissions))
|
||||
.catch(() => setPermissions(null));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
@@ -91,12 +104,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
setRefreshToken(null);
|
||||
setPermissions(null);
|
||||
setStatus("anonymous");
|
||||
}, [refreshToken]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ status, user, accessToken, login, submitMfa, logout }),
|
||||
[status, user, accessToken, login, submitMfa, logout],
|
||||
() => ({ status, user, accessToken, permissions, login, submitMfa, logout }),
|
||||
[status, user, accessToken, permissions, login, submitMfa, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
19
apps/core-web/src/lib/auth/permissions.ts
Normal file
19
apps/core-web/src/lib/auth/permissions.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { PermissionGrant } from "../api/auth.js";
|
||||
|
||||
/**
|
||||
* Mirrors (loosely — this is UX only, not authorization) the shape of
|
||||
* `maxGrantedScope` in `apps/api/src/auth/permissions.ts`: the backend is
|
||||
* always the one deciding whether an action is allowed (every mutating
|
||||
* route re-checks via `RequirePermission`/`maxGrantedScope` itself). This
|
||||
* only answers "should the button even be shown/enabled" — hiding a
|
||||
* button a user can't use is UX, not access control.
|
||||
*/
|
||||
export function hasPermission(
|
||||
permissions: "ALL" | PermissionGrant[] | null,
|
||||
resource: string,
|
||||
action: string,
|
||||
): boolean {
|
||||
if (permissions === "ALL") return true;
|
||||
if (!permissions) return false;
|
||||
return permissions.some((grant) => grant.resource === resource && grant.action === action);
|
||||
}
|
||||
Reference in New Issue
Block a user