rl-core-front 0.18.10 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.18.10",
3
+ "version": "0.19.0",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -244,17 +244,17 @@ export interface paths {
244
244
  patch?: never;
245
245
  trace?: never;
246
246
  };
247
- "/auth/password-token/{token}": {
247
+ "/auth/password-token/inspect": {
248
248
  parameters: {
249
249
  query?: never;
250
250
  header?: never;
251
251
  path?: never;
252
252
  cookie?: never;
253
253
  };
254
- /** Verifica a validade do link de senha sem consumi-lo */
255
- get: operations["AuthController_inspectPasswordToken"];
254
+ get?: never;
256
255
  put?: never;
257
- post?: never;
256
+ /** Verifica a validade do link de senha sem consumi-lo */
257
+ post: operations["AuthController_inspectPasswordToken"];
258
258
  delete?: never;
259
259
  options?: never;
260
260
  head?: never;
@@ -838,6 +838,38 @@ export interface paths {
838
838
  patch: operations["NotificationsController_markAsRead"];
839
839
  trace?: never;
840
840
  };
841
+ "/notifications/read": {
842
+ parameters: {
843
+ query?: never;
844
+ header?: never;
845
+ path?: never;
846
+ cookie?: never;
847
+ };
848
+ get?: never;
849
+ put?: never;
850
+ post?: never;
851
+ delete: operations["NotificationsController_dismissRead"];
852
+ options?: never;
853
+ head?: never;
854
+ patch?: never;
855
+ trace?: never;
856
+ };
857
+ "/notifications/{id}": {
858
+ parameters: {
859
+ query?: never;
860
+ header?: never;
861
+ path?: never;
862
+ cookie?: never;
863
+ };
864
+ get?: never;
865
+ put?: never;
866
+ post?: never;
867
+ delete: operations["NotificationsController_dismiss"];
868
+ options?: never;
869
+ head?: never;
870
+ patch?: never;
871
+ trace?: never;
872
+ };
841
873
  "/exports/{handler}": {
842
874
  parameters: {
843
875
  query?: never;
@@ -1148,6 +1180,9 @@ export interface components {
1148
1180
  ForgotPasswordCommand: {
1149
1181
  email: string;
1150
1182
  };
1183
+ InspectPasswordTokenCommand: {
1184
+ token: string;
1185
+ };
1151
1186
  ResetPasswordCommand: {
1152
1187
  token: string;
1153
1188
  newPassword: string;
@@ -1588,6 +1623,13 @@ export interface components {
1588
1623
  sortBy?: string;
1589
1624
  /** @enum {string} */
1590
1625
  sortDir?: "ASC" | "DESC";
1626
+ /**
1627
+ * @description Parâmetros do documento, validados pelo relatório pedido
1628
+ * @example {
1629
+ * "referenceMonth": "2026-09"
1630
+ * }
1631
+ */
1632
+ params?: Record<string, never>;
1591
1633
  };
1592
1634
  ExportAcceptedResponse: {
1593
1635
  /** @example 184 */
@@ -2038,12 +2080,14 @@ export interface operations {
2038
2080
  parameters: {
2039
2081
  query?: never;
2040
2082
  header?: never;
2041
- path: {
2042
- token: string;
2043
- };
2083
+ path?: never;
2044
2084
  cookie?: never;
2045
2085
  };
2046
- requestBody?: never;
2086
+ requestBody: {
2087
+ content: {
2088
+ "application/json": components["schemas"]["InspectPasswordTokenCommand"];
2089
+ };
2090
+ };
2047
2091
  responses: {
2048
2092
  200: {
2049
2093
  headers: {
@@ -3338,6 +3382,42 @@ export interface operations {
3338
3382
  };
3339
3383
  };
3340
3384
  };
3385
+ NotificationsController_dismissRead: {
3386
+ parameters: {
3387
+ query?: never;
3388
+ header?: never;
3389
+ path?: never;
3390
+ cookie?: never;
3391
+ };
3392
+ requestBody?: never;
3393
+ responses: {
3394
+ 204: {
3395
+ headers: {
3396
+ [name: string]: unknown;
3397
+ };
3398
+ content?: never;
3399
+ };
3400
+ };
3401
+ };
3402
+ NotificationsController_dismiss: {
3403
+ parameters: {
3404
+ query?: never;
3405
+ header?: never;
3406
+ path: {
3407
+ id: string;
3408
+ };
3409
+ cookie?: never;
3410
+ };
3411
+ requestBody?: never;
3412
+ responses: {
3413
+ 204: {
3414
+ headers: {
3415
+ [name: string]: unknown;
3416
+ };
3417
+ content?: never;
3418
+ };
3419
+ };
3420
+ };
3341
3421
  ExportController_request: {
3342
3422
  parameters: {
3343
3423
  query?: never;
@@ -3395,7 +3475,7 @@ export interface operations {
3395
3475
  };
3396
3476
  requestBody?: never;
3397
3477
  responses: {
3398
- /** @description A planilha */
3478
+ /** @description O arquivo gerado */
3399
3479
  200: {
3400
3480
  headers: {
3401
3481
  [name: string]: unknown;
@@ -84,10 +84,9 @@ export const authService = {
84
84
 
85
85
  /** Verifica o link de senha antes de exibir o formulário (não consome o token). */
86
86
  inspectPasswordToken(token: string): Promise<PasswordTokenCheck> {
87
+ // No corpo, não na URL: token em path vai parar em log de proxy.
87
88
  return api
88
- .get<PasswordTokenCheck>(
89
- `/auth/password-token/${encodeURIComponent(token)}`,
90
- )
89
+ .post<PasswordTokenCheck>("/auth/password-token/inspect", { token })
91
90
  .then((r) => r.data);
92
91
  },
93
92
 
@@ -18,6 +18,11 @@ const API_ORIGIN = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "").replace(
18
18
  * avatar gravado por versões anteriores e de qualquer endereço externo que
19
19
  * ainda esteja em base.
20
20
  */
21
+ /** Imagem ainda não confirmada por um salvar — vive em `/uploads/<pasta>/pending/`. */
22
+ export function isPendingImagePath(path?: string | null): boolean {
23
+ return /^\/uploads\/[^/]+\/pending\//.test(path ?? "");
24
+ }
25
+
21
26
  export function uploadUrlOf(path?: string | null): string | undefined {
22
27
  if (!path) {
23
28
  return undefined;
@@ -177,7 +177,9 @@ const ADMIN_CHILDREN: NavItem[] = [
177
177
  perm: anyOrTeam("users:read"),
178
178
  },
179
179
  { key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
180
- { key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
180
+ // Gerente o log da própria equipe a rota poda pelo mesmo alcance da
181
+ // listagem de usuários.
182
+ { key: "nav.logs", href: "/logs", icon: ScrollText, perm: anyOrTeam("audit:read") },
181
183
  // Logo abaixo do log de requisições, que é o vizinho natural: os dois contam
182
184
  // o que aconteceu no sistema, um por chamada e outro por alteração de dado.
183
185
  // Restrito a administradores — só quem tem audit:read-trail:any (só Super
@@ -5,7 +5,7 @@ import type { ChangeEvent, ClipboardEvent, DragEvent, JSX } from "react";
5
5
  import { useRef, useState } from "react";
6
6
 
7
7
  import { imagesService } from "#core/_services/images";
8
- import { uploadUrlOf } from "#core/_utils/upload-url";
8
+ import { isPendingImagePath, uploadUrlOf } from "#core/_utils/upload-url";
9
9
  import { Button } from "#core/components/ui/button";
10
10
  import { Input } from "#core/components/ui/input";
11
11
  import { Spinner } from "#core/components/ui/spinner";
@@ -102,9 +102,13 @@ export function ImageUpload({
102
102
  return;
103
103
  }
104
104
 
105
- // O arquivo sai do disco junto: caminho que ninguém mais referencia é
106
- // exatamente o que a faxina não varre (a pasta definitiva não é varrida).
107
- await imagesService.remove(profile, value).catch(() => undefined);
105
+ // a pendente sai do disco por aqui é o upload desta sessão que ninguém
106
+ // referencia. A definitiva pertence ao registro: quem a apaga é o salvar
107
+ // (`discard()` no service da feature), e a API recusa o caminho por esta
108
+ // rota de propósito.
109
+ if (isPendingImagePath(value)) {
110
+ await imagesService.remove(profile, value).catch(() => undefined);
111
+ }
108
112
  onChange(null);
109
113
  clearError();
110
114
  };
package/src/middleware.ts CHANGED
@@ -7,9 +7,119 @@ import { NextRequest, NextResponse } from "next/server";
7
7
  *
8
8
  * Observação: a validação forte do token é feita no backend; aqui é só
9
9
  * o "portão" de roteamento (UX), evitando flicker de telas privadas.
10
+ *
11
+ * Também é aqui que a Content-Security-Policy nasce, porque o nonce precisa
12
+ * ser novo a cada requisição e chegar ao `layout.tsx` antes de a página
13
+ * renderizar — ver `buildContentSecurityPolicy`.
10
14
  */
11
15
  const PUBLIC_PATHS = ["/login", "/forgot-password", "/reset-password"];
12
16
 
17
+ /** Cabeçalho por onde o nonce chega ao `layout.tsx` (`headers().get("x-nonce")`). */
18
+ export const NONCE_HEADER = "x-nonce";
19
+
20
+ export interface ContentSecurityPolicyOptions {
21
+ /** Nonce desta requisição — vai nos scripts inline do Next e no de tema do layout. */
22
+ nonce: string;
23
+ /** Origens extras de `connect-src` (outra API, um serviço de terceiros). */
24
+ extraConnectSrc?: string[];
25
+ /** Origens extras de `img-src` (CDN de imagens, S3). */
26
+ extraImgSrc?: string[];
27
+ }
28
+
29
+ /**
30
+ * Os mesmos padrões do `axios.factory` e do `socket-context`: sem a variável, a
31
+ * CSP precisa liberar exatamente para onde o cliente vai cair, senão o navegador
32
+ * bloqueia toda chamada.
33
+ */
34
+ const DEFAULT_API_BASE_URL = "http://localhost:3108/api/v1";
35
+ const DEFAULT_SOCKET_URL = "http://localhost:3108";
36
+
37
+ const originOf = (url: string): string | null => {
38
+ try {
39
+ return new URL(url).origin;
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+
45
+ /** Origem da API (`NEXT_PUBLIC_API_BASE_URL`), sem prefixo — é de lá que vêm avatar e download. */
46
+ const apiOrigin = (): string | null =>
47
+ originOf(process.env.NEXT_PUBLIC_API_BASE_URL ?? DEFAULT_API_BASE_URL);
48
+
49
+ /** Origem do socket (`NEXT_PUBLIC_SOCKET_URL`) — costuma ser a da API, mas pode divergir. */
50
+ const socketOrigin = (): string | null =>
51
+ originOf(process.env.NEXT_PUBLIC_SOCKET_URL ?? DEFAULT_SOCKET_URL);
52
+
53
+ /**
54
+ * A CSP do core.
55
+ *
56
+ * Defesa em profundidade: hoje não há sink de XSS na interface, e a CSP é o
57
+ * que limita o estrago de um que apareça num componente de terceiro. Script só
58
+ * com nonce (`strict-dynamic` deixa o Next carregar os chunks dele); `img` e
59
+ * `connect` liberam a origem da API, porque avatar, axios e socket.io falam
60
+ * com ela. `style` aceita inline porque Tailwind e Radix escrevem `style=`
61
+ * direto no elemento — bloquear isso quebraria todo popover e menu.
62
+ *
63
+ * `unsafe-eval` só em desenvolvimento: o React o usa para reconstruir stack
64
+ * de erro do servidor no navegador; em produção nem React nem Next o usam.
65
+ *
66
+ * Exportada para o projeto que precisar de mais origens montar a própria: chama
67
+ * esta com `extraConnectSrc`/`extraImgSrc` dentro do middleware dele.
68
+ */
69
+ export const buildContentSecurityPolicy = ({
70
+ nonce,
71
+ extraConnectSrc = [],
72
+ extraImgSrc = [],
73
+ }: ContentSecurityPolicyOptions): string => {
74
+ const api = apiOrigin();
75
+ const socket = socketOrigin();
76
+ const isDev = process.env.NODE_ENV === "development";
77
+ // O socket.io começa por polling HTTP e sobe para WebSocket: as duas formas.
78
+ const connect = [
79
+ "'self'",
80
+ api,
81
+ socket,
82
+ socket?.replace(/^http/, "ws") ?? null,
83
+ ...extraConnectSrc,
84
+ ].filter(Boolean);
85
+ const img = ["'self'", "blob:", "data:", api, ...extraImgSrc].filter(Boolean);
86
+
87
+ return [
88
+ "default-src 'self'",
89
+ `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`,
90
+ "style-src 'self' 'unsafe-inline'",
91
+ `img-src ${img.join(" ")}`,
92
+ "font-src 'self' data:",
93
+ `connect-src ${connect.join(" ")}`,
94
+ "object-src 'none'",
95
+ "base-uri 'self'",
96
+ "form-action 'self'",
97
+ "frame-ancestors 'none'",
98
+ ].join("; ");
99
+ };
100
+
101
+ /**
102
+ * Devolve a resposta "segue em frente" já com o nonce e a CSP nos dois lados:
103
+ * no request (é de onde o Next lê o nonce para os scripts dele) e no response.
104
+ */
105
+ export const nextWithSecurityHeaders = (
106
+ req: NextRequest,
107
+ options: Omit<ContentSecurityPolicyOptions, "nonce"> = {},
108
+ ): NextResponse => {
109
+ const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
110
+ const csp = buildContentSecurityPolicy({ nonce, ...options });
111
+
112
+ const requestHeaders = new Headers(req.headers);
113
+ requestHeaders.set(NONCE_HEADER, nonce);
114
+ requestHeaders.set("Content-Security-Policy", csp);
115
+
116
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
117
+ response.headers.set("Content-Security-Policy", csp);
118
+ response.headers.set("X-Content-Type-Options", "nosniff");
119
+ response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
120
+ return response;
121
+ };
122
+
13
123
  export function middleware(req: NextRequest): NextResponse<unknown> {
14
124
  const { pathname } = req.nextUrl;
15
125
  const hasSession =
@@ -34,7 +144,7 @@ export function middleware(req: NextRequest): NextResponse<unknown> {
34
144
  return NextResponse.redirect(url);
35
145
  }
36
146
 
37
- return NextResponse.next();
147
+ return nextWithSecurityHeaders(req);
38
148
  }
39
149
 
40
150
  export const config = {