rl-core-front 0.5.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 +1 -1
- package/src/components/app-shell.tsx +46 -3
- package/src/components/ui/data-table.tsx +18 -1
- package/src/components/ui/dialog.tsx +146 -62
- package/src/components/ui/sheet.tsx +1 -1
- package/src/features/errors/error-state.tsx +50 -0
- package/src/features/errors/forbidden-screen.tsx +37 -0
- package/src/features/errors/not-found-screen.tsx +35 -0
- package/src/features/rbac/components/permission-matrix.tsx +210 -0
- package/src/features/rbac/panels/definitions-panel.tsx +75 -0
- package/src/features/rbac/panels/index.ts +1 -0
- package/src/features/rbac/panels/roles-panel.tsx +113 -57
- package/src/features/rbac/rbac-screen.tsx +35 -37
- package/src/i18n/messages/en.ts +26 -0
- package/src/i18n/messages/pt.ts +27 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rl-core-front",
|
|
3
|
-
"version": "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({
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
|
|
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
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
className,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
88
|
+
reset({ name: "", description: "", permissionIds: [] });
|
|
63
89
|
setOpen(true);
|
|
64
90
|
};
|
|
65
91
|
const openEdit = (role: Role): void => {
|
|
66
92
|
setEditing(role);
|
|
67
|
-
|
|
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,
|
|
103
|
+
? await run(() => rbacService.updateRole(editing.id, values), {
|
|
78
104
|
success: RequestOperation.Update,
|
|
79
105
|
})
|
|
80
|
-
: await run(() => rbacService.createRole(
|
|
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-
|
|
175
|
+
<p className="mb-3 text-sm text-muted-foreground">
|
|
156
176
|
{role.description}
|
|
157
177
|
</p>
|
|
158
|
-
<
|
|
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-
|
|
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
|
-
<
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
41
|
-
label: "
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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((
|
|
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((
|
|
66
|
-
<TabsTrigger key={
|
|
67
|
-
{
|
|
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((
|
|
72
|
-
<TabsContent key={
|
|
73
|
-
{
|
|
69
|
+
{tabs.map((tab) => (
|
|
70
|
+
<TabsContent key={tab.value} value={tab.value}>
|
|
71
|
+
{tab.panel}
|
|
74
72
|
</TabsContent>
|
|
75
73
|
))}
|
|
76
74
|
</Tabs>
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -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:
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -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";
|