rl-core-front 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usu\u00e1rios, RBAC, auditoria, logs e listagens com filtro din\u00e2mico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@ import {
36
36
  } from "#core/components/ui";
37
37
  import { UserAvatar } from "#core/components/user-avatar";
38
38
  import { useAuth, useBrand, useI18n } from "#core/contexts";
39
+ import { ForbiddenScreen } from "#core/features/errors/forbidden-screen";
39
40
  import { NotificationsCenter } from "#core/features/notifications/notifications-center";
40
41
  import { ForcePasswordChange } from "#core/features/profile/force-password-change";
41
42
  import { cn } from "#core/lib/utils";
@@ -115,9 +116,21 @@ export interface AppShellProps {
115
116
  children: React.ReactNode;
116
117
  /** Telas do projeto no menu, entre o dashboard e o grupo de administração. */
117
118
  navItems?: AppNavItem[];
119
+ /**
120
+ * O que mostrar quando a rota atual exige permissão que o usuário não tem.
121
+ *
122
+ * Padrão: a `ForbiddenScreen` do core. Passe outra para trocar a tela sem
123
+ * abrir mão do bloqueio — que é o ponto: esconder o item do menu não impede
124
+ * ninguém de digitar a URL.
125
+ */
126
+ forbidden?: React.ReactNode;
118
127
  }
119
128
 
120
- export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
129
+ export function AppShell({
130
+ children,
131
+ navItems,
132
+ forbidden,
133
+ }: AppShellProps): JSX.Element {
121
134
  const { user, loading, logout, hasPermission } = useAuth();
122
135
  const { t } = useI18n();
123
136
  const brand = useBrand();
@@ -159,6 +172,36 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
159
172
  // eslint-disable-next-line react-hooks/exhaustive-deps
160
173
  [user, navItems],
161
174
  );
175
+ /**
176
+ * A rota atual exige permissão que o usuário não tem?
177
+ *
178
+ * Percorre **todas** as rotas conhecidas, não só as que sobreviveram ao
179
+ * filtro do menu: é justamente a que foi filtrada que precisa ser barrada
180
+ * aqui. Esconder o item nunca impediu ninguém de digitar a URL — sem este
181
+ * bloqueio a tela montava, chamava a API, tomava 403 e virava uma página
182
+ * vazia com um toast, em vez de dizer que falta acesso.
183
+ *
184
+ * Rota que não está em lista nenhuma passa: o shell não sabe o que ela exige,
185
+ * e chutar bloqueio esconderia tela legítima do projeto que não declarou
186
+ * `navItems`.
187
+ */
188
+ const routeBlocked = useMemo(() => {
189
+ const known: NavItem[] = [
190
+ DASHBOARD,
191
+ PROFILE,
192
+ ...ADMIN_CHILDREN,
193
+ ...(navItems ?? []).map((item) => ({
194
+ key: item.label,
195
+ href: item.href,
196
+ icon: item.icon,
197
+ perm: item.permission ?? null,
198
+ })),
199
+ ];
200
+ const current = known.find((item) => item.href === pathname);
201
+ return current ? !allows(current.perm) : false;
202
+ // eslint-disable-next-line react-hooks/exhaustive-deps
203
+ }, [user, navItems, pathname]);
204
+
162
205
  const adminActive = adminChildren.some((c) => pathname === c.href);
163
206
  const [adminOpen, setAdminOpen] = useState(adminActive);
164
207
 
@@ -379,8 +422,8 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
379
422
  )}
380
423
  >
381
424
  <div className="p-4 md:p-8">
382
- <Breadcrumbs />
383
- {children}
425
+ {!routeBlocked && <Breadcrumbs />}
426
+ {routeBlocked ? (forbidden ?? <ForbiddenScreen />) : children}
384
427
  </div>
385
428
  </main>
386
429
  </div>
@@ -103,6 +103,15 @@ interface DataTableProps<T extends RowData> {
103
103
  * não os desmarca ao aplicar nem ao limpar: não são dele.
104
104
  */
105
105
  hiddenFilterFields?: string[];
106
+ /**
107
+ * Texto da tabela vazia, quando a listagem sabe algo que a tabela não sabe.
108
+ *
109
+ * Sem isto, "Nenhum registro" é a resposta para tudo — inclusive para
110
+ * listagem podada por permissão, onde os registros existem e é o alcance de
111
+ * quem olha que não chega neles. Uma tela vazia que não diz o motivo é
112
+ * indistinguível de bug, e manda o usuário procurar defeito onde não há.
113
+ */
114
+ emptyMessage?: string;
106
115
  }
107
116
 
108
117
  /** Extras que uma coluna pode declarar em `meta`. */
@@ -154,6 +163,7 @@ export function DataTable<T extends RowData>({
154
163
  enableSorting = true,
155
164
  filters,
156
165
  hiddenFilterFields,
166
+ emptyMessage,
157
167
  }: DataTableProps<T>): JSX.Element {
158
168
  const { t } = useI18n();
159
169
  const [colsOpen, setColsOpen] = useState(false);
@@ -232,6 +242,13 @@ export function DataTable<T extends RowData>({
232
242
  : 0
233
243
  : activeFilterCount(filters.values, hiddenFilterFields);
234
244
 
245
+ // Vazio com filtro aplicado não é "não há registros", é "não há *estes*
246
+ // registros" — e a diferença decide se o usuário vai limpar o filtro ou
247
+ // procurar um bug.
248
+ const emptyText =
249
+ emptyMessage ??
250
+ (activeFilters > 0 ? t("table.emptyFiltered") : t("table.empty"));
251
+
235
252
  return (
236
253
  <div>
237
254
  <div className="mb-3 flex items-center justify-between gap-2">
@@ -323,7 +340,7 @@ export function DataTable<T extends RowData>({
323
340
  colSpan={colSpan}
324
341
  className="h-24 text-center text-muted-foreground"
325
342
  >
326
- {t("table.empty")}
343
+ {emptyText}
327
344
  </TableCell>
328
345
  </TableRow>
329
346
  )}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
- import { Maximize2, Minimize2, X } from "lucide-react";
4
+ import { Expand, Shrink, X } from "lucide-react";
5
5
  import * as React from "react";
6
6
 
7
7
  import { cn } from "#core/lib/utils";
@@ -83,6 +83,53 @@ const expandedWidth = (className?: string): string => {
83
83
  return WIDTH_SCALE[Math.min(index + EXPAND_STEPS, last)];
84
84
  };
85
85
 
86
+ /**
87
+ * Cabeçalho e rodapé vêm antes de `DialogContent`/`DialogForm` porque os dois
88
+ * procuram por eles entre os próprios filhos. A busca é pelo `displayName`, e
89
+ * não pela referência do componente: o pacote é publicado em código-fonte e
90
+ * resolvido por subpath (`#core/*`), então o consumidor pode acabar com outra
91
+ * instância do módulo — aí `child.type === DialogHeader` é falso mesmo sendo o
92
+ * mesmo componente, e o cabeçalho cai silenciosamente no corpo, desalinhado.
93
+ */
94
+ const DialogHeader = ({
95
+ className,
96
+ ...props
97
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
98
+ <div
99
+ className={cn(
100
+ "flex min-w-0 flex-col space-y-1.5 text-left",
101
+ className,
102
+ )}
103
+ {...props}
104
+ />
105
+ );
106
+ DialogHeader.displayName = "DialogHeader";
107
+
108
+ const DialogFooter = ({
109
+ className,
110
+ ...props
111
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
112
+ <div
113
+ className={cn(
114
+ "flex shrink-0 flex-col-reverse gap-2 border-t border-border pt-4 sm:flex-row sm:justify-end",
115
+ className,
116
+ )}
117
+ {...props}
118
+ />
119
+ );
120
+ DialogFooter.displayName = "DialogFooter";
121
+
122
+ /** Acha um filho pelo `displayName` — ver a nota acima. */
123
+ const indexOfChild = (
124
+ items: React.ReactNode[],
125
+ displayName: string,
126
+ ): number =>
127
+ items.findIndex(
128
+ (child) =>
129
+ React.isValidElement(child) &&
130
+ (child.type as { displayName?: string })?.displayName === displayName,
131
+ );
132
+
86
133
  export interface DialogContentProps
87
134
  extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
88
135
  /**
@@ -98,19 +145,30 @@ export interface DialogContentProps
98
145
  const DialogContent = React.forwardRef<
99
146
  React.ElementRef<typeof DialogPrimitive.Content>,
100
147
  DialogContentProps
101
- >(({ className, children, expandable = true, ...props }, ref) => {
148
+ >(({ className, children, expandable = true, onOpenAutoFocus, ...props }, ref) => {
102
149
  // Reinicia a cada montagem: o Radix desmonta o conteúdo ao fechar, então
103
150
  // reabrir traz o modal no tamanho normal — modal pequeno que volta ampliado
104
151
  // surpreende quem o abriu.
105
152
  const [expanded, setExpanded] = React.useState(false);
106
153
 
154
+ // O cabeçalho sai da lista para dividir a primeira linha com os botões; o
155
+ // resto segue na ordem em que o chamador escreveu.
156
+ const items = React.Children.toArray(children);
157
+ const headerAt = indexOfChild(items, "DialogHeader");
158
+ const header = headerAt >= 0 ? items[headerAt] : null;
159
+ const body = headerAt >= 0 ? items.filter((_, i) => i !== headerAt) : items;
160
+
107
161
  return (
108
162
  <DialogPortal>
109
163
  <DialogOverlay />
110
164
  <DialogPrimitive.Content
111
165
  ref={ref}
112
166
  className={cn(
113
- "fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 overflow-y-auto",
167
+ // `flex flex-col` + `overflow-hidden`: o scroll é do corpo, não do
168
+ // modal inteiro. Com `overflow-y-auto` aqui, título e botões rolavam
169
+ // junto e sumiam — quem descia até o fim de uma lista longa perdia o
170
+ // "Salvar" de vista.
171
+ "fixed left-[50%] top-[50%] z-50 flex flex-col translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 overflow-hidden",
114
172
  "w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] rounded-xl",
115
173
  DEFAULT_WIDTH,
116
174
  className,
@@ -118,31 +176,56 @@ const DialogContent = React.forwardRef<
118
176
  // o chamador declarou, e só enquanto estiver ampliado.
119
177
  expanded && expandedWidth(className),
120
178
  )}
179
+ onOpenAutoFocus={(event) => {
180
+ onOpenAutoFocus?.(event);
181
+ if (event.defaultPrevented) {
182
+ return;
183
+ }
184
+ // O Radix foca o primeiro elemento focável, e desde que os botões de
185
+ // ampliar/fechar entraram no cabeçalho esse primeiro passou a ser o
186
+ // de ampliar — abrir o modal e teclar Enter maximizava a janela em
187
+ // vez de enviar o formulário. Quando há campo, o foco é dele.
188
+ const conteudo = event.target as HTMLElement | null;
189
+ const primeiroCampo = conteudo?.querySelector<HTMLElement>(
190
+ "form input:not([type='hidden']), form textarea, form select",
191
+ );
192
+ if (primeiroCampo) {
193
+ event.preventDefault();
194
+ primeiroCampo.focus();
195
+ }
196
+ }}
121
197
  {...props}
122
198
  >
123
- {children}
124
- <div className="absolute right-4 top-4 flex items-center gap-2">
125
- {expandable && (
126
- <button
127
- type="button"
128
- onClick={() => setExpanded((value) => !value)}
129
- className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring"
130
- >
131
- {expanded ? (
132
- <Minimize2 className="h-4 w-4" />
133
- ) : (
134
- <Maximize2 className="h-4 w-4" />
135
- )}
136
- <span className="sr-only">
137
- {expanded ? "Restaurar" : "Ampliar"}
138
- </span>
139
- </button>
140
- )}
141
- <DialogPrimitive.Close className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
142
- <X className="h-4 w-4" />
143
- <span className="sr-only">Fechar</span>
144
- </DialogPrimitive.Close>
199
+ {/* Título e botões na mesma linha de um flex, em vez de os botões
200
+ flutuarem `absolute`: posicionados por coordenada eles nunca
201
+ batiam com a altura do título — mudava o tamanho da fonte e o
202
+ alinhamento saía. Aqui `items-center` resolve sozinho. */}
203
+ <div className="flex shrink-0 items-center justify-between gap-4 border-b border-border pb-4">
204
+ <div className="min-w-0 flex-1">{header}</div>
205
+ <div className="flex items-center gap-2">
206
+ {expandable && (
207
+ <button
208
+ type="button"
209
+ onClick={() => setExpanded((value) => !value)}
210
+ className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
211
+ >
212
+ {expanded ? (
213
+ <Shrink className="h-4 w-4" />
214
+ ) : (
215
+ <Expand className="h-4 w-4" />
216
+ )}
217
+ <span className="sr-only">
218
+ {expanded ? "Restaurar" : "Ampliar"}
219
+ </span>
220
+ </button>
221
+ )}
222
+ <DialogPrimitive.Close className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
223
+ <X className="h-4 w-4" />
224
+ <span className="sr-only">Fechar</span>
225
+ </DialogPrimitive.Close>
226
+ </div>
145
227
  </div>
228
+ {body}
146
229
  </DialogPrimitive.Content>
147
230
  </DialogPortal>
148
231
  );
@@ -152,48 +235,49 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
152
235
  /**
153
236
  * Corpo do modal como formulário — é o que faz o `Enter` submeter.
154
237
  * O `preventDefault` fica aqui porque, sem ele, o Enter recarrega a página.
155
- * O `DialogFooter` com o botão `type="submit"` precisa ficar dentro deste form.
238
+ *
239
+ * O `DialogFooter` é separado dos demais filhos e fica **fora da área
240
+ * rolável**, ainda dentro do `<form>`. As duas metades importam: fora do
241
+ * scroll porque `sticky` deixa o conteúdo correr por baixo dos botões — com
242
+ * fundo próprio ele só disfarça, e qualquer filho com fundo ou `z-index`
243
+ * atravessa; dentro do `<form>` porque é lá que mora o `type="submit"`, e
244
+ * tirá-lo dali quebraria o Enter, que é a razão de este componente existir.
245
+ *
246
+ * A separação é feita aqui, e não pedida ao chamador, para os oito modais que
247
+ * já existem continuarem escritos como estão.
156
248
  */
157
249
  const DialogForm = React.forwardRef<
158
250
  HTMLFormElement,
159
251
  React.ComponentPropsWithoutRef<"form">
160
- >(({ className, onSubmit, ...props }, ref) => (
161
- <form
162
- ref={ref}
163
- onSubmit={(e) => {
164
- e.preventDefault();
165
- onSubmit?.(e);
166
- }}
167
- className={cn("flex flex-col gap-4", className)}
168
- {...props}
169
- />
170
- ));
171
- DialogForm.displayName = "DialogForm";
172
-
173
- const DialogHeader = ({
174
- className,
175
- ...props
176
- }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
177
- <div
178
- className={cn("flex flex-col space-y-1.5 text-left", className)}
179
- {...props}
180
- />
181
- );
182
- DialogHeader.displayName = "DialogHeader";
252
+ >(({ className, onSubmit, children, ...props }, ref) => {
253
+ const items = React.Children.toArray(children);
254
+ const footerAt = indexOfChild(items, "DialogFooter");
255
+ const hasFooter = footerAt >= 0;
183
256
 
184
- const DialogFooter = ({
185
- className,
186
- ...props
187
- }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
188
- <div
189
- className={cn(
190
- "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
191
- className,
192
- )}
193
- {...props}
194
- />
195
- );
196
- DialogFooter.displayName = "DialogFooter";
257
+ return (
258
+ <form
259
+ ref={ref}
260
+ onSubmit={(e) => {
261
+ e.preventDefault();
262
+ onSubmit?.(e);
263
+ }}
264
+ className={cn("flex min-h-0 flex-1 flex-col gap-4", className)}
265
+ {...props}
266
+ >
267
+ {/* `min-h-0` é o que permite encolher dentro do flex e ativar o scroll:
268
+ sem ele o corpo assume a altura do conteúdo e estoura o modal. */}
269
+ {/* `px-1` reserva a folga que o anel de foco ocupa: o `ring` é
270
+ desenhado fora da borda do input, e sem essa margem o container
271
+ de scroll corta o anel do primeiro e do último campo.
272
+ `-mx-1` devolve o recuo para o conteúdo seguir alinhado. */}
273
+ <div className="-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1 [scrollbar-width:thin]">
274
+ {hasFooter ? items.slice(0, footerAt) : items}
275
+ </div>
276
+ {hasFooter && items.slice(footerAt)}
277
+ </form>
278
+ );
279
+ });
280
+ DialogForm.displayName = "DialogForm";
197
281
 
198
282
  const DialogTitle = React.forwardRef<
199
283
  React.ElementRef<typeof DialogPrimitive.Title>,
@@ -45,7 +45,7 @@ const SheetContent = React.forwardRef<
45
45
  {...props}
46
46
  >
47
47
  {children}
48
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
48
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
49
49
  <X className="h-4 w-4" />
50
50
  <span className="sr-only">Fechar</span>
51
51
  </DialogPrimitive.Close>
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import type { LucideIcon } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { Button } from "#core/components/ui";
7
+
8
+ export interface ErrorStateProps {
9
+ icon: LucideIcon;
10
+ code: string;
11
+ title: string;
12
+ description: string;
13
+ /** Ação principal. Sem ela a tela é só o aviso — usado onde não há para onde voltar. */
14
+ action?: { label: string; onClick: () => void };
15
+ }
16
+
17
+ /**
18
+ * O corpo comum das telas de erro (403, 404).
19
+ *
20
+ * Existe para as duas não divergirem: são a mesma peça com ícone e texto
21
+ * diferentes, e escritas separadas elas viram dois desenhos parecidos-mas-não-
22
+ * iguais na primeira vez que alguém ajusta só uma.
23
+ *
24
+ * Não depende do `AppShell`: a 404 do Next é capturada na raiz do app, fora da
25
+ * área autenticada, e uma tela de erro que só funciona logado não serve para o
26
+ * caso mais comum — link velho aberto por quem nem sessão tem.
27
+ */
28
+ export function ErrorState({
29
+ icon: Icon,
30
+ code,
31
+ title,
32
+ description,
33
+ action,
34
+ }: ErrorStateProps): JSX.Element {
35
+ return (
36
+ <div className="flex min-h-[60vh] flex-col items-center justify-center px-6 text-center">
37
+ <div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
38
+ <Icon className="h-8 w-8 text-muted-foreground" />
39
+ </div>
40
+
41
+ <p className="mb-2 font-mono text-sm text-muted-foreground">{code}</p>
42
+ <h1 className="mb-3 text-2xl font-bold">{title}</h1>
43
+ <p className="mb-8 max-w-md text-sm text-muted-foreground">
44
+ {description}
45
+ </p>
46
+
47
+ {action && <Button onClick={action.onClick}>{action.label}</Button>}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { ShieldOff } from "lucide-react";
4
+ import { useRouter } from "next/navigation";
5
+ import type { JSX } from "react";
6
+
7
+ import { useI18n } from "#core/contexts";
8
+ import { ErrorState } from "#core/features/errors/error-state";
9
+
10
+ /**
11
+ * 403 — a pessoa está autenticada, mas a tela não é dela.
12
+ *
13
+ * Diz que **falta permissão**, e não que a página não existe: quem chegou aqui
14
+ * costuma ter recebido o link de alguém, e "peça acesso a um administrador" é a
15
+ * única instrução acionável. Fingir 404 esconderia o motivo e transformaria um
16
+ * pedido de acesso de um minuto em suporte.
17
+ *
18
+ * O `AppShell` a mostra sozinho quando a rota exige permissão que falta; o
19
+ * projeto troca por outra passando `forbidden` ao shell.
20
+ */
21
+ export function ForbiddenScreen(): JSX.Element {
22
+ const { t } = useI18n();
23
+ const router = useRouter();
24
+
25
+ return (
26
+ <ErrorState
27
+ icon={ShieldOff}
28
+ code="403"
29
+ title={t("errors.forbiddenTitle")}
30
+ description={t("errors.forbiddenDescription")}
31
+ action={{
32
+ label: t("errors.backToDashboard"),
33
+ onClick: () => router.push("/dashboard"),
34
+ }}
35
+ />
36
+ );
37
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { FileQuestion } from "lucide-react";
4
+ import { useRouter } from "next/navigation";
5
+ import type { JSX } from "react";
6
+
7
+ import { useI18n } from "#core/contexts";
8
+ import { ErrorState } from "#core/features/errors/error-state";
9
+
10
+ /**
11
+ * 404 no tema do sistema.
12
+ *
13
+ * O projeto adota criando `src/app/not-found.tsx` com uma linha
14
+ * (`export { NotFoundScreen as default } from "rl-core-front"`) — ou escreve a
15
+ * própria no lugar, já que `not-found.tsx` é rota especial do Next e pertence
16
+ * ao projeto. Sem isso vale a página padrão do Next: fundo branco, texto em
17
+ * inglês, ignorando tema e marca.
18
+ */
19
+ export function NotFoundScreen(): JSX.Element {
20
+ const { t } = useI18n();
21
+ const router = useRouter();
22
+
23
+ return (
24
+ <ErrorState
25
+ icon={FileQuestion}
26
+ code="404"
27
+ title={t("errors.notFoundTitle")}
28
+ description={t("errors.notFoundDescription")}
29
+ action={{
30
+ label: t("errors.backToDashboard"),
31
+ onClick: () => router.push("/dashboard"),
32
+ }}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,210 @@
1
+ "use client";
2
+
3
+ import { ChevronDown } from "lucide-react";
4
+ import type { JSX } from "react";
5
+ import { useMemo, useState } from "react";
6
+
7
+ import { Badge, Checkbox, Input } from "#core/components/ui";
8
+ import { useI18n } from "#core/contexts";
9
+ import { Permission } from "#core/features/rbac/services/rbac.service";
10
+
11
+ /** Uma área do sistema com tudo que se pode fazer nela. */
12
+ interface ResourceGroup {
13
+ name: string;
14
+ label: string;
15
+ permissions: Permission[];
16
+ }
17
+
18
+ export interface PermissionMatrixProps {
19
+ /** Catálogo inteiro, como `/rbac/permissions` devolve. */
20
+ permissions: Permission[];
21
+ /** Ids marcados. */
22
+ value: string[];
23
+ onChange: (permissionIds: string[]) => void;
24
+ /** Somente leitura: usado no cartão de resumo do papel. */
25
+ readOnly?: boolean;
26
+ }
27
+
28
+ /**
29
+ * As permissões de um papel na forma como se pensa nelas: **uma área por
30
+ * bloco, e dentro dela o que se pode fazer ali**.
31
+ *
32
+ * Substitui a lista chapada de checkboxes com o código cru (`users:read:any`),
33
+ * que obrigava a ler 48 strings técnicas numa caixa de 240px para montar um
34
+ * papel — e que só piora, porque cada feature nova do projeto acrescenta a sua
35
+ * leva. Agrupar por recurso é o que mantém a tela legível quando o catálogo
36
+ * dobra de tamanho.
37
+ *
38
+ * Os rótulos saem da descrição que o backend já manda, com o nome cru de
39
+ * reserva: catálogo estendido pelo projeto (`finance`, `funkos`) aparece aqui
40
+ * sem precisar de tradução no core.
41
+ */
42
+ export function PermissionMatrix({
43
+ permissions,
44
+ value,
45
+ onChange,
46
+ readOnly,
47
+ }: PermissionMatrixProps): JSX.Element {
48
+ const { t } = useI18n();
49
+ const [search, setSearch] = useState("");
50
+ const [collapsed, setCollapsed] = useState<string[]>([]);
51
+
52
+ const groups = useMemo<ResourceGroup[]>(
53
+ () => groupByResource(permissions),
54
+ [permissions],
55
+ );
56
+
57
+ const visible = useMemo<ResourceGroup[]>(() => {
58
+ const term = search.trim().toLowerCase();
59
+ if (!term) {
60
+ return groups;
61
+ }
62
+ // Busca pelo nome da área e pelo código: quem já sabe o que procura
63
+ // digita `finance`, quem não sabe procura "Financeiro".
64
+ return groups
65
+ .map((group) => ({
66
+ ...group,
67
+ permissions: group.label.toLowerCase().includes(term)
68
+ ? group.permissions
69
+ : group.permissions.filter((p) =>
70
+ `${p.code} ${p.description ?? ""}`.toLowerCase().includes(term),
71
+ ),
72
+ }))
73
+ .filter((group) => group.permissions.length > 0);
74
+ }, [groups, search]);
75
+
76
+ const toggle = (id: string): void =>
77
+ onChange(
78
+ value.includes(id) ? value.filter((x) => x !== id) : [...value, id],
79
+ );
80
+
81
+ const toggleGroup = (group: ResourceGroup): void => {
82
+ const ids = group.permissions.map((p) => p.id);
83
+ const allOn = ids.every((id) => value.includes(id));
84
+ onChange(
85
+ allOn
86
+ ? value.filter((id) => !ids.includes(id))
87
+ : [...value, ...ids.filter((id) => !value.includes(id))],
88
+ );
89
+ };
90
+
91
+ const toggleCollapse = (name: string): void =>
92
+ setCollapsed((c) =>
93
+ c.includes(name) ? c.filter((x) => x !== name) : [...c, name],
94
+ );
95
+
96
+ return (
97
+ <div className="space-y-3">
98
+ {!readOnly && (
99
+ <Input
100
+ value={search}
101
+ onChange={(e) => setSearch(e.target.value)}
102
+ placeholder={t("rbac.searchPermissions")}
103
+ />
104
+ )}
105
+
106
+ {/* Sem scroll próprio: quem rola é o corpo do modal. Dois scrolls
107
+ aninhados prendem a roda do mouse no de dentro. */}
108
+ <div className="space-y-2">
109
+ {visible.map((group) => {
110
+ const ids = group.permissions.map((p) => p.id);
111
+ const selected = ids.filter((id) => value.includes(id)).length;
112
+ const isCollapsed = collapsed.includes(group.name);
113
+
114
+ return (
115
+ <div
116
+ key={group.name}
117
+ className="rounded-md border border-border bg-card"
118
+ >
119
+ <div className="flex items-center gap-2 px-3 py-2">
120
+ {!readOnly && (
121
+ <Checkbox
122
+ checked={selected === ids.length}
123
+ onCheckedChange={() => toggleGroup(group)}
124
+ aria-label={group.label}
125
+ />
126
+ )}
127
+ <button
128
+ type="button"
129
+ onClick={() => toggleCollapse(group.name)}
130
+ className="flex flex-1 items-center gap-2 text-left"
131
+ >
132
+ <span className="text-sm font-medium">{group.label}</span>
133
+ <Badge variant="outline" className="font-mono text-xs">
134
+ {selected}/{ids.length}
135
+ </Badge>
136
+ <ChevronDown
137
+ className={`ml-auto h-4 w-4 text-muted-foreground transition-transform ${
138
+ isCollapsed ? "-rotate-90" : ""
139
+ }`}
140
+ />
141
+ </button>
142
+ </div>
143
+
144
+ {!isCollapsed && (
145
+ <div className="grid grid-cols-1 gap-1 border-t border-border px-3 py-2 sm:grid-cols-2">
146
+ {group.permissions.map((p) => (
147
+ <label
148
+ key={p.id}
149
+ className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 text-sm hover:bg-muted/50"
150
+ >
151
+ <Checkbox
152
+ checked={value.includes(p.id)}
153
+ onCheckedChange={() => toggle(p.id)}
154
+ disabled={readOnly}
155
+ className="mt-0.5"
156
+ />
157
+ <span className="flex flex-col">
158
+ <span>{actionLabel(p)}</span>
159
+ <span className="font-mono text-xs text-muted-foreground">
160
+ {p.code}
161
+ </span>
162
+ </span>
163
+ </label>
164
+ ))}
165
+ </div>
166
+ )}
167
+ </div>
168
+ );
169
+ })}
170
+
171
+ {visible.length === 0 && (
172
+ <p className="py-6 text-center text-sm text-muted-foreground">
173
+ {t("rbac.noPermissionsFound")}
174
+ </p>
175
+ )}
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Agrupa por recurso, caindo no prefixo do código quando a relação não veio.
183
+ *
184
+ * O fallback existe porque `code` é a única parte que **sempre** chega: uma
185
+ * permissão órfã (resource apagado) sumiria da tela em vez de aparecer para
186
+ * ser corrigida, e uma permissão invisível continua valendo no backend.
187
+ */
188
+ const groupByResource = (permissions: Permission[]): ResourceGroup[] => {
189
+ const map = new Map<string, ResourceGroup>();
190
+
191
+ for (const permission of permissions) {
192
+ const name = permission.resource?.name ?? permission.code.split(":")[0];
193
+ const label =
194
+ permission.resource?.description?.trim() || name;
195
+
196
+ const group = map.get(name) ?? { name, label, permissions: [] };
197
+ group.permissions.push(permission);
198
+ map.set(name, group);
199
+ }
200
+
201
+ return [...map.values()].sort((a, b) => a.label.localeCompare(b.label));
202
+ };
203
+
204
+ /** "Ler/visualizar · Apenas recursos próprios" — ação e alcance, nesta ordem. */
205
+ const actionLabel = (permission: Permission): string => {
206
+ const [, action, scope] = permission.code.split(":");
207
+ const actionText = permission.action?.description?.trim() || action;
208
+ const scopeText = permission.scope?.description?.trim() || scope;
209
+ return `${actionText} · ${scopeText}`;
210
+ };
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "#core/components/ui";
6
+ import { useAuth, useI18n } from "#core/contexts";
7
+ import { ActionsPanel } from "#core/features/rbac/panels/actions-panel";
8
+ import { PermissionsPanel } from "#core/features/rbac/panels/permissions-panel";
9
+ import { ResourcesPanel } from "#core/features/rbac/panels/resources-panel";
10
+ import { ScopesPanel } from "#core/features/rbac/panels/scopes-panel";
11
+
12
+ /**
13
+ * A mecânica do RBAC — as peças de que um código `resource:action:scope` é
14
+ * feito, mais o catálogo de permissões montado com elas.
15
+ *
16
+ * As quatro viviam como abas irmãs de "Papéis", o que punha a manutenção do
17
+ * modelo no mesmo nível do trabalho de todo dia: quem entra para dar acesso a
18
+ * alguém não deveria esbarrar em "Resources" e "Scopes" no caminho. Aqui elas
19
+ * continuam inteiras, mas atrás de uma porta só.
20
+ */
21
+ export function DefinitionsPanel(): JSX.Element {
22
+ const { t } = useI18n();
23
+ const { hasPermission } = useAuth();
24
+
25
+ const tabs = [
26
+ {
27
+ value: "permissions",
28
+ label: t("rbac.tabs.permissions"),
29
+ perm: "permissions:read:any",
30
+ panel: <PermissionsPanel />,
31
+ },
32
+ {
33
+ value: "resources",
34
+ label: t("rbac.tabs.resources"),
35
+ perm: "resources:read:any",
36
+ panel: <ResourcesPanel />,
37
+ },
38
+ {
39
+ value: "actions",
40
+ label: t("rbac.tabs.actions"),
41
+ perm: "actions:read:any",
42
+ panel: <ActionsPanel />,
43
+ },
44
+ {
45
+ value: "scopes",
46
+ label: t("rbac.tabs.scopes"),
47
+ perm: "scopes:read:any",
48
+ panel: <ScopesPanel />,
49
+ },
50
+ ].filter((tab) => hasPermission(tab.perm));
51
+
52
+ if (tabs.length === 0) {
53
+ return <></>;
54
+ }
55
+
56
+ return (
57
+ <div className="space-y-4">
58
+ <p className="text-sm text-muted-foreground">{t("rbac.definitionsHint")}</p>
59
+ <Tabs defaultValue={tabs[0].value}>
60
+ <TabsList>
61
+ {tabs.map((tab) => (
62
+ <TabsTrigger key={tab.value} value={tab.value}>
63
+ {tab.label}
64
+ </TabsTrigger>
65
+ ))}
66
+ </TabsList>
67
+ {tabs.map((tab) => (
68
+ <TabsContent key={tab.value} value={tab.value}>
69
+ {tab.panel}
70
+ </TabsContent>
71
+ ))}
72
+ </Tabs>
73
+ </div>
74
+ );
75
+ }
@@ -1,6 +1,7 @@
1
1
  // Barrel da pasta — importe daqui de fora; dentro da pasta use o caminho direto.
2
2
  export * from "./actions-panel";
3
3
  export * from "./catalog-panel";
4
+ export * from "./definitions-panel";
4
5
  export * from "./groups-panel";
5
6
  export * from "./permissions-panel";
6
7
  export * from "./resources-panel";
@@ -1,15 +1,17 @@
1
1
  "use client";
2
2
 
3
+ import { yupResolver } from "@hookform/resolvers/yup";
3
4
  import { Pencil, Plus, Trash2 } from "lucide-react";
4
5
  import type { JSX } from "react";
5
- import { useEffect, useState } from "react";
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { useForm } from "react-hook-form";
8
+ import * as yup from "yup";
6
9
 
7
10
  import {
8
11
  Badge,
9
12
  Button,
10
13
  Card,
11
14
  CardContent,
12
- Checkbox,
13
15
  Dialog,
14
16
  DialogContent,
15
17
  DialogFooter,
@@ -21,6 +23,7 @@ import {
21
23
  Label,
22
24
  } from "#core/components/ui";
23
25
  import { useAuth, useI18n } from "#core/contexts";
26
+ import { PermissionMatrix } from "#core/features/rbac/components/permission-matrix";
24
27
  import {
25
28
  Permission,
26
29
  rbacService,
@@ -29,6 +32,12 @@ import {
29
32
  import { useConfirm } from "#core/hooks/use-confirm";
30
33
  import { RequestOperation, useRequest } from "#core/hooks/use-request";
31
34
 
35
+ interface RoleFormValues {
36
+ name: string;
37
+ description?: string;
38
+ permissionIds: string[];
39
+ }
40
+
32
41
  export function RolesPanel(): JSX.Element {
33
42
  const { run, loading } = useRequest();
34
43
  const { t } = useI18n();
@@ -38,12 +47,29 @@ export function RolesPanel(): JSX.Element {
38
47
  const [perms, setPerms] = useState<Permission[]>([]);
39
48
  const [open, setOpen] = useState(false);
40
49
  const [editing, setEditing] = useState<Role | null>(null);
41
- const [form, setForm] = useState({
42
- name: "",
43
- description: "",
44
- permissionIds: [] as string[],
50
+
51
+ // Mesmo padrão das outras telas do core (react-hook-form + Yup): o erro sai
52
+ // sob o campo, e não como toast da API depois da viagem até o servidor.
53
+ const schema = yup.object({
54
+ name: yup.string().trim().required(t("validation.required")),
55
+ description: yup.string().optional(),
56
+ permissionIds: yup.array().of(yup.string().required()).required(),
45
57
  });
46
58
 
59
+ const {
60
+ register,
61
+ handleSubmit,
62
+ reset,
63
+ setValue,
64
+ watch,
65
+ formState: { errors },
66
+ } = useForm<RoleFormValues>({
67
+ resolver: yupResolver(schema),
68
+ defaultValues: { name: "", description: "", permissionIds: [] },
69
+ });
70
+
71
+ const permissionIds = watch("permissionIds");
72
+
47
73
  const load = async (): Promise<void> => {
48
74
  const [r, p] = await Promise.all([
49
75
  run(() => rbacService.listRoles()),
@@ -59,12 +85,12 @@ export function RolesPanel(): JSX.Element {
59
85
 
60
86
  const openNew = (): void => {
61
87
  setEditing(null);
62
- setForm({ name: "", description: "", permissionIds: [] });
88
+ reset({ name: "", description: "", permissionIds: [] });
63
89
  setOpen(true);
64
90
  };
65
91
  const openEdit = (role: Role): void => {
66
92
  setEditing(role);
67
- setForm({
93
+ reset({
68
94
  name: role.name,
69
95
  description: role.description ?? "",
70
96
  permissionIds: (role.permissions ?? []).map((p) => p.id),
@@ -72,12 +98,12 @@ export function RolesPanel(): JSX.Element {
72
98
  setOpen(true);
73
99
  };
74
100
 
75
- const save = async (): Promise<void> => {
101
+ const save = async (values: RoleFormValues): Promise<void> => {
76
102
  const res = editing
77
- ? await run(() => rbacService.updateRole(editing.id, form), {
103
+ ? await run(() => rbacService.updateRole(editing.id, values), {
78
104
  success: RequestOperation.Update,
79
105
  })
80
- : await run(() => rbacService.createRole(form), {
106
+ : await run(() => rbacService.createRole(values), {
81
107
  success: RequestOperation.Create,
82
108
  });
83
109
  if (res) {
@@ -106,13 +132,6 @@ export function RolesPanel(): JSX.Element {
106
132
 
107
133
  const canManage =
108
134
  hasPermission("roles:update:any") || hasPermission("roles:delete:any");
109
- const togglePerm = (id: string): void =>
110
- setForm((f) => ({
111
- ...f,
112
- permissionIds: f.permissionIds.includes(id)
113
- ? f.permissionIds.filter((x) => x !== id)
114
- : [...f.permissionIds, id],
115
- }));
116
135
 
117
136
  return (
118
137
  <div>
@@ -122,6 +141,7 @@ export function RolesPanel(): JSX.Element {
122
141
  {t("common.create")}
123
142
  </Button>
124
143
  )}
144
+
125
145
  <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
126
146
  {roles.map((role) => (
127
147
  <Card key={role.id}>
@@ -152,62 +172,52 @@ export function RolesPanel(): JSX.Element {
152
172
  </div>
153
173
  )}
154
174
  </div>
155
- <p className="mb-2 text-sm text-muted-foreground">
175
+ <p className="mb-3 text-sm text-muted-foreground">
156
176
  {role.description}
157
177
  </p>
158
- <div className="flex flex-wrap gap-1">
159
- {(role.permissions ?? []).map((p) => (
160
- <Badge key={p.id} variant="outline">
161
- {p.code}
162
- </Badge>
163
- ))}
164
- </div>
178
+ <RoleSummary role={role} />
165
179
  </CardContent>
166
180
  </Card>
167
181
  ))}
168
182
  </div>
169
183
 
170
184
  <Dialog open={open} onOpenChange={(o) => !o && setOpen(false)}>
171
- <DialogContent className="max-w-lg">
185
+ <DialogContent className="max-w-3xl">
172
186
  <DialogHeader>
173
187
  <DialogTitle>
174
188
  {editing ? t("common.edit") : t("common.create")}
175
189
  </DialogTitle>
176
190
  </DialogHeader>
177
- <DialogForm onSubmit={() => void save()}>
178
- <Field label={t("users.name")}>
179
- <Input
180
- value={form.name}
181
- onChange={(e) => setForm({ ...form, name: e.target.value })}
182
- />
183
- </Field>
184
- <Field label="Descrição">
185
- <Input
186
- value={form.description}
187
- onChange={(e) =>
188
- setForm({ ...form, description: e.target.value })
191
+ <DialogForm onSubmit={handleSubmit((values) => void save(values))}>
192
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
193
+ <Field label={t("rbac.roleName")} error={errors.name?.message}>
194
+ <Input {...register("name")} />
195
+ </Field>
196
+ <Field
197
+ label={t("common.description")}
198
+ error={errors.description?.message}
199
+ >
200
+ <Input {...register("description")} />
201
+ </Field>
202
+ </div>
203
+
204
+ <div className="space-y-2">
205
+ <Label>{t("rbac.screensAndPermissions")}</Label>
206
+ <PermissionMatrix
207
+ permissions={perms}
208
+ value={permissionIds}
209
+ onChange={(ids) =>
210
+ setValue("permissionIds", ids, { shouldDirty: true })
189
211
  }
190
212
  />
191
- </Field>
192
- <div className="space-y-2">
193
- <Label>{t("dashboard.permissions")}</Label>
194
- <div className="flex max-h-60 flex-col gap-2 overflow-y-auto rounded-md border border-border p-3">
195
- {perms.map((p) => (
196
- <label
197
- key={p.id}
198
- className="flex cursor-pointer items-center gap-2 text-sm"
199
- >
200
- <Checkbox
201
- checked={form.permissionIds.includes(p.id)}
202
- onCheckedChange={() => togglePerm(p.id)}
203
- />
204
- {p.code}
205
- </label>
206
- ))}
207
- </div>
208
213
  </div>
214
+
209
215
  <DialogFooter className="gap-2">
210
- <Button type="button" variant="outline" onClick={() => setOpen(false)}>
216
+ <Button
217
+ type="button"
218
+ variant="outline"
219
+ onClick={() => setOpen(false)}
220
+ >
211
221
  {t("common.cancel")}
212
222
  </Button>
213
223
  <Button type="submit" disabled={loading}>
@@ -222,3 +232,49 @@ export function RolesPanel(): JSX.Element {
222
232
  </div>
223
233
  );
224
234
  }
235
+
236
+ /**
237
+ * Resumo do papel: as **áreas** que ele alcança, não os códigos.
238
+ *
239
+ * O cartão listava uma badge por permissão — no Super Admin, que tem todas,
240
+ * isso é uma parede de 48 códigos que não se lê e empurra os outros papéis
241
+ * para fora da tela.
242
+ */
243
+ function RoleSummary({ role }: { role: Role }): JSX.Element {
244
+ const { t } = useI18n();
245
+ const permissions = useMemo(() => role.permissions ?? [], [role.permissions]);
246
+
247
+ const resources = useMemo(() => {
248
+ const names = new Set(
249
+ permissions.map((p) => p.resource?.description?.trim() || p.code.split(":")[0]),
250
+ );
251
+ return [...names].sort((a, b) => a.localeCompare(b));
252
+ }, [permissions]);
253
+
254
+ if (permissions.length === 0) {
255
+ return (
256
+ <p className="text-sm text-muted-foreground">{t("rbac.noPermissions")}</p>
257
+ );
258
+ }
259
+
260
+ const shown = resources.slice(0, 6);
261
+ const rest = resources.length - shown.length;
262
+
263
+ return (
264
+ <div className="space-y-2">
265
+ <p className="text-xs text-muted-foreground">
266
+ {t("rbac.permissionCount")
267
+ .replace("{count}", String(permissions.length))
268
+ .replace("{screens}", String(resources.length))}
269
+ </p>
270
+ <div className="flex flex-wrap gap-1">
271
+ {shown.map((name) => (
272
+ <Badge key={name} variant="outline">
273
+ {name}
274
+ </Badge>
275
+ ))}
276
+ {rest > 0 && <Badge variant="outline">+{rest}</Badge>}
277
+ </div>
278
+ </div>
279
+ );
280
+ }
@@ -5,14 +5,23 @@ import type { JSX } from "react";
5
5
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "#core/components/ui";
6
6
  import { useAuth, useI18n } from "#core/contexts";
7
7
  import {
8
- ActionsPanel,
8
+ DefinitionsPanel,
9
9
  GroupsPanel,
10
- PermissionsPanel,
11
- ResourcesPanel,
12
10
  RolesPanel,
13
- ScopesPanel,
14
11
  } from "#core/features/rbac/panels";
15
12
 
13
+ /**
14
+ * Controle de acesso, organizado pelo que se vem fazer aqui.
15
+ *
16
+ * **Papéis** é o trabalho de todo dia — é onde se decide que telas cada tipo de
17
+ * pessoa alcança. **Grupos** é o organograma, para quem usa escopo de equipe.
18
+ * **Definições** é a mecânica (recursos, ações, escopos e o catálogo de
19
+ * permissões), que o seed já preenche e quase nunca se toca.
20
+ *
21
+ * Eram seis abas no mesmo nível, três delas CRUD do modelo relacional com
22
+ * rótulo em inglês: a tarefa comum ficava do lado da manutenção rara, e a tela
23
+ * pedia que se soubesse o desenho do RBAC antes de conceder um acesso.
24
+ */
16
25
  export function RbacScreen(): JSX.Element {
17
26
  const { t } = useI18n();
18
27
  const { hasPermission } = useAuth();
@@ -20,41 +29,30 @@ export function RbacScreen(): JSX.Element {
20
29
  const tabs = [
21
30
  {
22
31
  value: "roles",
23
- label: "Papéis",
24
- perm: "roles:read:any",
32
+ label: t("rbac.tabs.roles"),
33
+ perm: ["roles:read:any"],
25
34
  panel: <RolesPanel />,
26
35
  },
27
- {
28
- value: "permissions",
29
- label: t("dashboard.permissions"),
30
- perm: "permissions:read:any",
31
- panel: <PermissionsPanel />,
32
- },
33
36
  {
34
37
  value: "groups",
35
- label: "Grupos",
36
- perm: "groups:read:any",
38
+ label: t("rbac.tabs.groups"),
39
+ perm: ["groups:read:any"],
37
40
  panel: <GroupsPanel />,
38
41
  },
39
42
  {
40
- value: "resources",
41
- label: "Resources",
42
- perm: "resources:read:any",
43
- panel: <ResourcesPanel />,
44
- },
45
- {
46
- value: "actions",
47
- label: "Actions",
48
- perm: "actions:read:any",
49
- panel: <ActionsPanel />,
50
- },
51
- {
52
- value: "scopes",
53
- label: "Scopes",
54
- perm: "scopes:read:any",
55
- panel: <ScopesPanel />,
43
+ value: "definitions",
44
+ label: t("rbac.tabs.definitions"),
45
+ // Aparece para quem alcança qualquer uma das quatro de dentro: exigir as
46
+ // quatro esconderia a aba de quem administra só o catálogo.
47
+ perm: [
48
+ "permissions:read:any",
49
+ "resources:read:any",
50
+ "actions:read:any",
51
+ "scopes:read:any",
52
+ ],
53
+ panel: <DefinitionsPanel />,
56
54
  },
57
- ].filter((x) => hasPermission(x.perm));
55
+ ].filter((tab) => tab.perm.some((code) => hasPermission(code)));
58
56
 
59
57
  return (
60
58
  <div>
@@ -62,15 +60,15 @@ export function RbacScreen(): JSX.Element {
62
60
  {tabs.length > 0 && (
63
61
  <Tabs defaultValue={tabs[0].value}>
64
62
  <TabsList>
65
- {tabs.map((x) => (
66
- <TabsTrigger key={x.value} value={x.value}>
67
- {x.label}
63
+ {tabs.map((tab) => (
64
+ <TabsTrigger key={tab.value} value={tab.value}>
65
+ {tab.label}
68
66
  </TabsTrigger>
69
67
  ))}
70
68
  </TabsList>
71
- {tabs.map((x) => (
72
- <TabsContent key={x.value} value={x.value}>
73
- {x.panel}
69
+ {tabs.map((tab) => (
70
+ <TabsContent key={tab.value} value={tab.value}>
71
+ {tab.panel}
74
72
  </TabsContent>
75
73
  ))}
76
74
  </Tabs>
@@ -93,6 +93,7 @@ export const en: Messages = {
93
93
  results: "results",
94
94
  perPage: "per page",
95
95
  empty: "No records",
96
+ emptyFiltered: "No records match this filter",
96
97
  },
97
98
  filters: {
98
99
  button: "Filters",
@@ -170,6 +171,7 @@ export const en: Messages = {
170
171
  no: "No",
171
172
  actions: "Actions",
172
173
  loading: "Loading...",
174
+ description: "Description",
173
175
  language: "Language",
174
176
  theme: "Theme",
175
177
  light: "Light",
@@ -295,6 +297,23 @@ export const en: Messages = {
295
297
  title: "Roles & Permissions",
296
298
  manager: "Manager",
297
299
  noManager: "No manager",
300
+ tabs: {
301
+ roles: "Roles",
302
+ groups: "Groups",
303
+ definitions: "Definitions",
304
+ permissions: "Permissions",
305
+ resources: "Areas",
306
+ actions: "Actions",
307
+ scopes: "Scopes",
308
+ },
309
+ roleName: "Role name",
310
+ screensAndPermissions: "Areas and permissions",
311
+ searchPermissions: "Search area or permission…",
312
+ noPermissionsFound: "No permissions found",
313
+ noPermissions: "No permissions granted",
314
+ permissionCount: "{count} permissions across {screens} areas",
315
+ definitionsHint:
316
+ "The building blocks of a permission code. The seed creates them — only touch this when adding a new resource.",
298
317
  },
299
318
  validation: {
300
319
  required: "Required field",
@@ -312,6 +331,13 @@ export const en: Messages = {
312
331
  },
313
332
  },
314
333
  errors: {
334
+ notFoundTitle: "Page not found",
335
+ notFoundDescription:
336
+ "The address you opened does not exist or has moved. Check the link and try again.",
337
+ forbiddenTitle: "You don't have access to this area",
338
+ forbiddenDescription:
339
+ "Your account lacks permission to open this screen. Ask a system administrator for access.",
340
+ backToDashboard: "Back to home",
315
341
  unexpected: "Unexpected error",
316
342
  codes: {
317
343
  ACCOUNT_INACTIVE:
@@ -95,6 +95,7 @@ export const pt = {
95
95
  results: "resultados",
96
96
  perPage: "por página",
97
97
  empty: "Nenhum registro",
98
+ emptyFiltered: "Nenhum registro para este filtro",
98
99
  },
99
100
  filters: {
100
101
  button: "Filtros",
@@ -172,6 +173,7 @@ export const pt = {
172
173
  no: "Não",
173
174
  actions: "Ações",
174
175
  loading: "Carregando...",
176
+ description: "Descrição",
175
177
  language: "Idioma",
176
178
  theme: "Tema",
177
179
  light: "Claro",
@@ -298,6 +300,24 @@ export const pt = {
298
300
  title: "Papéis & Permissões",
299
301
  manager: "Gerente",
300
302
  noManager: "Sem gerente",
303
+ tabs: {
304
+ roles: "Papéis",
305
+ groups: "Grupos",
306
+ definitions: "Definições",
307
+ permissions: "Permissões",
308
+ resources: "Áreas",
309
+ actions: "Ações",
310
+ scopes: "Alcances",
311
+ },
312
+ roleName: "Nome do papel",
313
+ screensAndPermissions: "Áreas e permissões",
314
+ searchPermissions: "Buscar área ou permissão…",
315
+ noPermissionsFound: "Nenhuma permissão encontrada",
316
+ noPermissions: "Nenhuma permissão concedida",
317
+ // `{count}` e `{screens}` são trocados no componente.
318
+ permissionCount: "{count} permissões em {screens} áreas",
319
+ definitionsHint:
320
+ "As peças que formam um código de permissão. O seed já as cria — mexa aqui só ao acrescentar um recurso novo.",
301
321
  },
302
322
  validation: {
303
323
  required: "Campo obrigatório",
@@ -318,6 +338,13 @@ export const pt = {
318
338
  codeShort: "Código muito curto",
319
339
  },
320
340
  errors: {
341
+ notFoundTitle: "Página não encontrada",
342
+ notFoundDescription:
343
+ "O endereço que você abriu não existe ou foi movido. Confira o link e tente de novo.",
344
+ forbiddenTitle: "Você não tem acesso a esta área",
345
+ forbiddenDescription:
346
+ "Sua conta não tem permissão para abrir esta tela. Peça acesso a um administrador do sistema.",
347
+ backToDashboard: "Voltar para o início",
321
348
  unexpected: "Erro inesperado",
322
349
  // Texto por `errorCode` do backend. Só entra aqui o código cuja mensagem é
323
350
  // fixa — quando o servidor manda dado dinâmico (tentativas restantes, por
package/src/index.ts CHANGED
@@ -13,6 +13,10 @@
13
13
  // ---------------------------------------------------------------------------
14
14
  export { AuditScreen } from "#core/features/audit/audit-screen";
15
15
  export { DashboardScreen } from "#core/features/dashboard/dashboard-screen";
16
+ export type { ErrorStateProps } from "#core/features/errors/error-state";
17
+ export { ErrorState } from "#core/features/errors/error-state";
18
+ export { ForbiddenScreen } from "#core/features/errors/forbidden-screen";
19
+ export { NotFoundScreen } from "#core/features/errors/not-found-screen";
16
20
  export { LoginScreen } from "#core/features/login/login-screen";
17
21
  export { LogsScreen } from "#core/features/logs/logs-screen";
18
22
  export { ProfileScreen } from "#core/features/profile/profile-screen";