rl-core-front 0.6.0 → 0.8.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/README.md +20 -0
- package/package.json +1 -1
- package/src/_services/api/schema.d.ts +381 -2
- package/src/components/app-shell.tsx +229 -51
- package/src/components/ui/data-table.tsx +18 -1
- package/src/components/ui/dialog.tsx +157 -62
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/job-progress.tsx +100 -0
- 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/queues/hooks/use-queue-jobs.ts +103 -0
- package/src/features/queues/queues-screen.tsx +264 -0
- package/src/features/queues/services/queues.service.ts +57 -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/hooks/use-job-progress.ts +192 -0
- package/src/i18n/messages/en.ts +52 -0
- package/src/i18n/messages/pt.ts +53 -0
- package/src/index.ts +24 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ColumnDef,
|
|
5
|
+
OnChangeFn,
|
|
6
|
+
PaginationState,
|
|
7
|
+
} from "@tanstack/react-table";
|
|
8
|
+
import { RotateCcw, Trash2 } from "lucide-react";
|
|
9
|
+
import type { JSX } from "react";
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
Badge,
|
|
14
|
+
Button,
|
|
15
|
+
Card,
|
|
16
|
+
DataTable,
|
|
17
|
+
DataTableFeatures,
|
|
18
|
+
RowActions,
|
|
19
|
+
SegmentedControl,
|
|
20
|
+
SegmentedOption,
|
|
21
|
+
} from "#core/components/ui";
|
|
22
|
+
import { useAuth, useI18n } from "#core/contexts";
|
|
23
|
+
import { useQueueJobs } from "#core/features/queues/hooks/use-queue-jobs";
|
|
24
|
+
import {
|
|
25
|
+
QueueJob,
|
|
26
|
+
QueueJobState,
|
|
27
|
+
} from "#core/features/queues/services/queues.service";
|
|
28
|
+
import { useConfirm } from "#core/hooks/use-confirm";
|
|
29
|
+
|
|
30
|
+
/** Rótulo de cada estado — a mesma chave serve à barra e aos cartões. */
|
|
31
|
+
const STATE_LABEL: Record<QueueJobState, string> = {
|
|
32
|
+
[QueueJobState.WAITING]: "queues.stateWaiting",
|
|
33
|
+
[QueueJobState.ACTIVE]: "queues.stateActive",
|
|
34
|
+
[QueueJobState.COMPLETED]: "queues.stateCompleted",
|
|
35
|
+
[QueueJobState.FAILED]: "queues.stateFailed",
|
|
36
|
+
[QueueJobState.DELAYED]: "queues.stateDelayed",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const STATE_VARIANT: Record<
|
|
40
|
+
QueueJobState,
|
|
41
|
+
"secondary" | "warning" | "success" | "destructive"
|
|
42
|
+
> = {
|
|
43
|
+
[QueueJobState.WAITING]: "secondary",
|
|
44
|
+
[QueueJobState.ACTIVE]: "warning",
|
|
45
|
+
[QueueJobState.COMPLETED]: "success",
|
|
46
|
+
[QueueJobState.FAILED]: "destructive",
|
|
47
|
+
[QueueJobState.DELAYED]: "secondary",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fila de processamento — o que está rodando, o que falhou e o que fazer com
|
|
52
|
+
* isso.
|
|
53
|
+
*
|
|
54
|
+
* Substitui o painel do bull-board: aqui a tela mora no mesmo shell, com a
|
|
55
|
+
* mesma permissão e a mesma tradução do resto do sistema, em vez de um segundo
|
|
56
|
+
* frontend embutido no backend com autenticação própria.
|
|
57
|
+
*/
|
|
58
|
+
export function QueuesScreen(): JSX.Element {
|
|
59
|
+
const { t } = useI18n();
|
|
60
|
+
const { hasPermission } = useAuth();
|
|
61
|
+
const { confirm, confirmDialog } = useConfirm();
|
|
62
|
+
const queue = useQueueJobs();
|
|
63
|
+
|
|
64
|
+
const canManage = hasPermission("queues:manage:any");
|
|
65
|
+
|
|
66
|
+
const stateOptions = useMemo<SegmentedOption[]>(
|
|
67
|
+
() =>
|
|
68
|
+
Object.values(QueueJobState).map((value) => ({
|
|
69
|
+
value,
|
|
70
|
+
label: t(STATE_LABEL[value]),
|
|
71
|
+
})),
|
|
72
|
+
[t],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/** Remover é sem volta: some da fila e não há de onde reprocessar depois. */
|
|
76
|
+
const removeJob = async (id: string): Promise<void> => {
|
|
77
|
+
const confirmed = await confirm({
|
|
78
|
+
title: t("queues.removeTitle"),
|
|
79
|
+
description: t("queues.removeDescription"),
|
|
80
|
+
destructive: true,
|
|
81
|
+
});
|
|
82
|
+
if (confirmed) {
|
|
83
|
+
await queue.remove(id);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const columns = useMemo<ColumnDef<DataTableFeatures, QueueJob, unknown>[]>(
|
|
88
|
+
() => [
|
|
89
|
+
{
|
|
90
|
+
accessorKey: "name",
|
|
91
|
+
header: t("queues.job"),
|
|
92
|
+
meta: { label: t("queues.job") },
|
|
93
|
+
enableSorting: false,
|
|
94
|
+
cell: ({ row }) => (
|
|
95
|
+
<div className="flex flex-col">
|
|
96
|
+
<span className="font-medium">{row.original.name}</span>
|
|
97
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
98
|
+
#{row.original.id}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
accessorKey: "state",
|
|
105
|
+
header: t("queues.state"),
|
|
106
|
+
meta: { label: t("queues.state") },
|
|
107
|
+
enableSorting: false,
|
|
108
|
+
cell: ({ row }) => {
|
|
109
|
+
const state = row.original.state as QueueJobState;
|
|
110
|
+
return (
|
|
111
|
+
<Badge variant={STATE_VARIANT[state] ?? "secondary"}>
|
|
112
|
+
{t(STATE_LABEL[state] ?? "queues.stateWaiting")}
|
|
113
|
+
</Badge>
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
accessorKey: "percent",
|
|
119
|
+
header: t("queues.progress"),
|
|
120
|
+
meta: { label: t("queues.progress") },
|
|
121
|
+
enableSorting: false,
|
|
122
|
+
cell: ({ getValue }) => {
|
|
123
|
+
const percent = getValue() as number | null;
|
|
124
|
+
return percent == null ? "—" : `${percent}%`;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
accessorKey: "attemptsMade",
|
|
129
|
+
header: t("queues.attempts"),
|
|
130
|
+
meta: { label: t("queues.attempts") },
|
|
131
|
+
enableSorting: false,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
accessorKey: "createdAt",
|
|
135
|
+
header: t("queues.createdAt"),
|
|
136
|
+
meta: { label: t("queues.createdAt") },
|
|
137
|
+
enableSorting: false,
|
|
138
|
+
cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
accessorKey: "failedReason",
|
|
142
|
+
header: t("queues.reason"),
|
|
143
|
+
meta: { label: t("queues.reason") },
|
|
144
|
+
enableSorting: false,
|
|
145
|
+
// Cru e por inteiro no `title`: quem lê esta tela é quem vai consertar
|
|
146
|
+
// a causa, e a mensagem truncada some justamente na parte útil.
|
|
147
|
+
cell: ({ getValue }) => {
|
|
148
|
+
const reason = getValue() as string | null;
|
|
149
|
+
return reason ? (
|
|
150
|
+
<span
|
|
151
|
+
className="line-clamp-2 max-w-md font-mono text-xs"
|
|
152
|
+
title={reason}
|
|
153
|
+
>
|
|
154
|
+
{reason}
|
|
155
|
+
</span>
|
|
156
|
+
) : (
|
|
157
|
+
"—"
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "actions",
|
|
163
|
+
header: () => <div className="text-center">{t("common.actions")}</div>,
|
|
164
|
+
meta: { shrink: true },
|
|
165
|
+
enableHiding: false,
|
|
166
|
+
enableSorting: false,
|
|
167
|
+
cell: ({ row }) =>
|
|
168
|
+
canManage ? (
|
|
169
|
+
<RowActions>
|
|
170
|
+
<Button
|
|
171
|
+
variant="ghost"
|
|
172
|
+
size="sm"
|
|
173
|
+
onClick={() => void queue.retry(row.original.id)}
|
|
174
|
+
>
|
|
175
|
+
<RotateCcw className="h-4 w-4" />
|
|
176
|
+
{t("queues.retry")}
|
|
177
|
+
</Button>
|
|
178
|
+
<Button
|
|
179
|
+
variant="ghost"
|
|
180
|
+
size="sm"
|
|
181
|
+
className="text-destructive"
|
|
182
|
+
onClick={() => void removeJob(row.original.id)}
|
|
183
|
+
>
|
|
184
|
+
<Trash2 className="h-4 w-4" />
|
|
185
|
+
{t("common.delete")}
|
|
186
|
+
</Button>
|
|
187
|
+
</RowActions>
|
|
188
|
+
) : null,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
192
|
+
[t, canManage, queue],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const pagination: PaginationState = {
|
|
196
|
+
pageIndex: queue.page,
|
|
197
|
+
pageSize: queue.pageSize,
|
|
198
|
+
};
|
|
199
|
+
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
200
|
+
const next = typeof updater === "function" ? updater(pagination) : updater;
|
|
201
|
+
if (next.pageSize !== queue.pageSize) {
|
|
202
|
+
queue.setPageSize(next.pageSize);
|
|
203
|
+
queue.setPage(0);
|
|
204
|
+
} else if (next.pageIndex !== queue.page) {
|
|
205
|
+
queue.setPage(next.pageIndex);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div>
|
|
211
|
+
<h1 className="mb-1 text-3xl font-bold">{t("queues.title")}</h1>
|
|
212
|
+
<p className="mb-4 text-sm text-muted-foreground">
|
|
213
|
+
{t("queues.subtitle")}
|
|
214
|
+
</p>
|
|
215
|
+
|
|
216
|
+
{/* Os cartões são o resumo E o atalho: clicar troca o estado listado —
|
|
217
|
+
ver "3 falhos" e não poder abri-los seria uma informação morta. */}
|
|
218
|
+
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
|
|
219
|
+
{Object.values(QueueJobState).map((state) => (
|
|
220
|
+
<Card
|
|
221
|
+
key={state}
|
|
222
|
+
role="button"
|
|
223
|
+
tabIndex={0}
|
|
224
|
+
onClick={() => queue.setState(state)}
|
|
225
|
+
className={`cursor-pointer p-3 transition-colors hover:bg-accent ${
|
|
226
|
+
queue.state === state ? "border-primary" : ""
|
|
227
|
+
}`}
|
|
228
|
+
>
|
|
229
|
+
<p className="text-xs text-muted-foreground">
|
|
230
|
+
{t(STATE_LABEL[state])}
|
|
231
|
+
</p>
|
|
232
|
+
<p className="text-2xl font-bold tabular-nums">
|
|
233
|
+
{queue.counts[state]}
|
|
234
|
+
</p>
|
|
235
|
+
</Card>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<DataTable
|
|
240
|
+
columns={columns}
|
|
241
|
+
data={queue.rows}
|
|
242
|
+
getRowId={(r) => r.id}
|
|
243
|
+
storageKey="queues-jobs"
|
|
244
|
+
loading={queue.loading}
|
|
245
|
+
manualPagination
|
|
246
|
+
rowCount={queue.total}
|
|
247
|
+
pagination={pagination}
|
|
248
|
+
onPaginationChange={onPaginationChange}
|
|
249
|
+
enableSorting={false}
|
|
250
|
+
emptyMessage={t("queues.empty")}
|
|
251
|
+
toolbar={
|
|
252
|
+
<SegmentedControl
|
|
253
|
+
value={queue.state}
|
|
254
|
+
onValueChange={(value) => queue.setState(value as QueueJobState)}
|
|
255
|
+
options={stateOptions}
|
|
256
|
+
label={t("queues.state")}
|
|
257
|
+
/>
|
|
258
|
+
}
|
|
259
|
+
/>
|
|
260
|
+
|
|
261
|
+
{confirmDialog}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { api } from "#core/_services/api/axios.factory";
|
|
2
|
+
import type { components } from "#core/_services/api/schema";
|
|
3
|
+
|
|
4
|
+
/** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
|
|
5
|
+
export type QueueJob = components["schemas"]["QueueJobResponse"];
|
|
6
|
+
export type QueueCounts = components["schemas"]["QueueCountsResponse"];
|
|
7
|
+
|
|
8
|
+
export interface Paginated<T> {
|
|
9
|
+
items: T[];
|
|
10
|
+
meta: components["schemas"]["PaginationMetaResponse"];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Estados que a tela lista.
|
|
15
|
+
*
|
|
16
|
+
* Espelha o `LISTABLE_STATES` do backend. `paused` não está aqui porque é
|
|
17
|
+
* estado da fila inteira, não de um job.
|
|
18
|
+
*/
|
|
19
|
+
export enum QueueJobState {
|
|
20
|
+
WAITING = "waiting",
|
|
21
|
+
ACTIVE = "active",
|
|
22
|
+
COMPLETED = "completed",
|
|
23
|
+
FAILED = "failed",
|
|
24
|
+
DELAYED = "delayed",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QueueJobsQuery {
|
|
28
|
+
state: QueueJobState;
|
|
29
|
+
page: number;
|
|
30
|
+
limit: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const queuesService = {
|
|
34
|
+
counts(): Promise<QueueCounts> {
|
|
35
|
+
return api.get<QueueCounts>("/queues/counts").then((r) => r.data);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Uma página de jobs de **um** estado.
|
|
40
|
+
*
|
|
41
|
+
* O estado é obrigatório: no Redis cada estado é uma estrutura própria, e
|
|
42
|
+
* "todos" não é uma consulta que exista.
|
|
43
|
+
*/
|
|
44
|
+
list(query: QueueJobsQuery): Promise<Paginated<QueueJob>> {
|
|
45
|
+
return api
|
|
46
|
+
.get<Paginated<QueueJob>>("/queues/jobs", { params: query })
|
|
47
|
+
.then((r) => r.data);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
retry(id: string): Promise<void> {
|
|
51
|
+
return api.post(`/queues/jobs/${id}/retry`).then(() => undefined);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
remove(id: string): Promise<void> {
|
|
55
|
+
return api.delete(`/queues/jobs/${id}`).then(() => undefined);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -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";
|