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.
@@ -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>
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ import { api } from "#core/_services/api/axios.factory";
6
+ import { useSocket } from "#core/contexts/socket-context";
7
+
8
+ /**
9
+ * Eventos que a fila do backend empurra (ver `job.constants.ts` do
10
+ * `rl-core-api`). Saem pela mesma conexão das notificações — o hook não abre
11
+ * socket nenhum.
12
+ */
13
+ export const JOB_PROGRESS_EVENT = "job:progress";
14
+ export const JOB_COMPLETED_EVENT = "job:completed";
15
+ export const JOB_FAILED_EVENT = "job:failed";
16
+
17
+ export type JobState = "idle" | "running" | "completed" | "failed";
18
+
19
+ interface JobProgressEvent {
20
+ jobId: string;
21
+ name: string;
22
+ processed: number;
23
+ total: number;
24
+ percent: number;
25
+ message?: string;
26
+ }
27
+
28
+ interface JobCompletedEvent<TSummary> {
29
+ jobId: string;
30
+ name: string;
31
+ summary: TSummary;
32
+ }
33
+
34
+ interface JobFailedEvent {
35
+ jobId: string;
36
+ name: string;
37
+ errorCode: string | null;
38
+ message: string;
39
+ }
40
+
41
+ /** `GET /jobs/:id` — a mesma informação, para quem perdeu os eventos. */
42
+ interface JobStatusResponse<TSummary> {
43
+ id: string;
44
+ name: string;
45
+ state: "waiting" | "active" | "completed" | "failed" | "delayed" | "unknown";
46
+ progress: {
47
+ processed: number;
48
+ total: number;
49
+ percent: number;
50
+ message?: string;
51
+ } | null;
52
+ summary: TSummary | null;
53
+ error: { code: string | null; message: string } | null;
54
+ }
55
+
56
+ export interface UseJobProgressResult<TSummary> {
57
+ state: JobState;
58
+ percent: number;
59
+ processed: number;
60
+ total: number;
61
+ message: string | null;
62
+ /** Relatório que o processador devolveu — só depois de `completed`. */
63
+ summary: TSummary | null;
64
+ error: { code: string | null; message: string } | null;
65
+ }
66
+
67
+ const IDLE = {
68
+ state: "idle" as JobState,
69
+ percent: 0,
70
+ processed: 0,
71
+ total: 0,
72
+ message: null,
73
+ summary: null,
74
+ error: null,
75
+ };
76
+
77
+ /**
78
+ * Acompanha um job em segundo plano.
79
+ *
80
+ * ```tsx
81
+ * const [jobId, setJobId] = useState<string | null>(null);
82
+ * const job = useJobProgress<ImportSummary>(jobId);
83
+ * ```
84
+ *
85
+ * Três coisas que o evento sozinho não resolve, e por isso o hook também
86
+ * consulta `GET /jobs/:id`:
87
+ *
88
+ * - o job pode ter terminado **antes** de a tela assinar (arquivo pequeno);
89
+ * - a aba pode ter sido recarregada no meio, com o `jobId` guardado;
90
+ * - o socket pode cair, e o que passou na janela offline não volta sozinho —
91
+ * por isso a releitura também acontece a cada reconexão.
92
+ */
93
+ export function useJobProgress<TSummary = unknown>(
94
+ jobId: string | null,
95
+ ): UseJobProgressResult<TSummary> {
96
+ const { on, reconnectCount } = useSocket();
97
+ const [result, setResult] = useState<UseJobProgressResult<TSummary>>(
98
+ IDLE as UseJobProgressResult<TSummary>,
99
+ );
100
+
101
+ // Job novo começa do zero: sem isto a barra do anterior ficaria em 100%.
102
+ useEffect(() => {
103
+ setResult(IDLE as UseJobProgressResult<TSummary>);
104
+ }, [jobId]);
105
+
106
+ const sync = useCallback(async (): Promise<void> => {
107
+ if (!jobId) {
108
+ return;
109
+ }
110
+ try {
111
+ const { data } = await api.get<JobStatusResponse<TSummary>>(
112
+ `/jobs/${jobId}`,
113
+ );
114
+ setResult({
115
+ state:
116
+ data.state === "completed"
117
+ ? "completed"
118
+ : data.state === "failed"
119
+ ? "failed"
120
+ : "running",
121
+ percent: data.progress?.percent ?? 0,
122
+ processed: data.progress?.processed ?? 0,
123
+ total: data.progress?.total ?? 0,
124
+ message: data.progress?.message ?? null,
125
+ summary: data.summary,
126
+ error: data.error,
127
+ });
128
+ } catch {
129
+ // Job expirado ou fora de alcance: o socket ainda pode trazer o fim, e
130
+ // derrubar a tela por causa da consulta de apoio seria pior.
131
+ }
132
+ }, [jobId]);
133
+
134
+ useEffect(() => {
135
+ void sync();
136
+ }, [sync, reconnectCount]);
137
+
138
+ useEffect(() => {
139
+ if (!jobId) {
140
+ return;
141
+ }
142
+
143
+ const offProgress = on<JobProgressEvent>(JOB_PROGRESS_EVENT, (data) => {
144
+ if (data.jobId !== jobId) {
145
+ return;
146
+ }
147
+ setResult((current) => ({
148
+ ...current,
149
+ state: "running",
150
+ percent: data.percent,
151
+ processed: data.processed,
152
+ total: data.total,
153
+ message: data.message ?? null,
154
+ }));
155
+ });
156
+
157
+ const offCompleted = on<JobCompletedEvent<TSummary>>(
158
+ JOB_COMPLETED_EVENT,
159
+ (data) => {
160
+ if (data.jobId !== jobId) {
161
+ return;
162
+ }
163
+ setResult((current) => ({
164
+ ...current,
165
+ state: "completed",
166
+ percent: 100,
167
+ summary: data.summary,
168
+ error: null,
169
+ }));
170
+ },
171
+ );
172
+
173
+ const offFailed = on<JobFailedEvent>(JOB_FAILED_EVENT, (data) => {
174
+ if (data.jobId !== jobId) {
175
+ return;
176
+ }
177
+ setResult((current) => ({
178
+ ...current,
179
+ state: "failed",
180
+ error: { code: data.errorCode, message: data.message },
181
+ }));
182
+ });
183
+
184
+ return () => {
185
+ offProgress();
186
+ offCompleted();
187
+ offFailed();
188
+ };
189
+ }, [jobId, on]);
190
+
191
+ return result;
192
+ }
@@ -11,6 +11,7 @@ export const en: Messages = {
11
11
  profile: "My profile",
12
12
  logs: "Logs",
13
13
  audit: "Audit",
14
+ queues: "Queues",
14
15
  },
15
16
  logs: {
16
17
  outcomeSuccess: "Success",
@@ -84,6 +85,31 @@ export const en: Messages = {
84
85
  permissions: "Permissions",
85
86
  roles: "Roles",
86
87
  },
88
+ job: {
89
+ running: "Processing…",
90
+ completed: "Processing finished",
91
+ failed: "The processing could not be completed",
92
+ },
93
+ queues: {
94
+ title: "Queues",
95
+ subtitle: "Background processing — what is running and what failed",
96
+ job: "Job",
97
+ state: "State",
98
+ progress: "Progress",
99
+ attempts: "Attempts",
100
+ createdAt: "Created at",
101
+ reason: "Failure reason",
102
+ retry: "Retry",
103
+ removeTitle: "Remove this job?",
104
+ removeDescription:
105
+ "It leaves the queue and can no longer be retried from here.",
106
+ empty: "No jobs in this state",
107
+ stateWaiting: "Waiting",
108
+ stateActive: "Active",
109
+ stateCompleted: "Completed",
110
+ stateFailed: "Failed",
111
+ stateDelayed: "Delayed",
112
+ },
87
113
  table: {
88
114
  columns: "Columns",
89
115
  columnsButton: "Columns",
@@ -93,6 +119,7 @@ export const en: Messages = {
93
119
  results: "results",
94
120
  perPage: "per page",
95
121
  empty: "No records",
122
+ emptyFiltered: "No records match this filter",
96
123
  },
97
124
  filters: {
98
125
  button: "Filters",
@@ -170,6 +197,7 @@ export const en: Messages = {
170
197
  no: "No",
171
198
  actions: "Actions",
172
199
  loading: "Loading...",
200
+ description: "Description",
173
201
  language: "Language",
174
202
  theme: "Theme",
175
203
  light: "Light",
@@ -295,6 +323,23 @@ export const en: Messages = {
295
323
  title: "Roles & Permissions",
296
324
  manager: "Manager",
297
325
  noManager: "No manager",
326
+ tabs: {
327
+ roles: "Roles",
328
+ groups: "Groups",
329
+ definitions: "Definitions",
330
+ permissions: "Permissions",
331
+ resources: "Areas",
332
+ actions: "Actions",
333
+ scopes: "Scopes",
334
+ },
335
+ roleName: "Role name",
336
+ screensAndPermissions: "Areas and permissions",
337
+ searchPermissions: "Search area or permission…",
338
+ noPermissionsFound: "No permissions found",
339
+ noPermissions: "No permissions granted",
340
+ permissionCount: "{count} permissions across {screens} areas",
341
+ definitionsHint:
342
+ "The building blocks of a permission code. The seed creates them — only touch this when adding a new resource.",
298
343
  },
299
344
  validation: {
300
345
  required: "Required field",
@@ -312,6 +357,13 @@ export const en: Messages = {
312
357
  },
313
358
  },
314
359
  errors: {
360
+ notFoundTitle: "Page not found",
361
+ notFoundDescription:
362
+ "The address you opened does not exist or has moved. Check the link and try again.",
363
+ forbiddenTitle: "You don't have access to this area",
364
+ forbiddenDescription:
365
+ "Your account lacks permission to open this screen. Ask a system administrator for access.",
366
+ backToDashboard: "Back to home",
315
367
  unexpected: "Unexpected error",
316
368
  codes: {
317
369
  ACCOUNT_INACTIVE: